Merge commit '02958f42ea74027c15158431297f7ba0e5d12b56'

This commit is contained in:
Isaac 2024-05-08 00:24:24 +04:00
commit e8821fbd3f
28 changed files with 793 additions and 464 deletions

View File

@ -591,11 +591,15 @@ final class ColorGridComponent: Component {
else {
return nil
}
let row = Int(point.y / size.height * 10.0)
let col = Int(point.x / size.width * 12.0)
let row = max(0, min(10, Int(point.y / size.height * 10.0)))
let col = max(0, min(12, Int(point.x / size.width * 12.0)))
let index = row * 12 + col
return DrawingColor(rgb: palleteColors[index])
if index < palleteColors.count {
return DrawingColor(rgb: palleteColors[index])
} else {
return DrawingColor(rgb: 0x000000)
}
}
@objc func handlePress(_ gestureRecognizer: UILongPressGestureRecognizer) {

View File

@ -230,7 +230,9 @@ class PremiumCoinComponent: Component {
self.sceneView.scene = scene
self.sceneView.delegate = self
let _ = self.sceneView.snapshot()
self.didSetReady = true
self._ready.set(.single(true))
self.onReady()
}
private var didSetReady = false

View File

@ -20,29 +20,40 @@ import TelegramUIPreferences
public final class PremiumGradientBackgroundComponent: Component {
public let colors: [UIColor]
public let cornerRadius: CGFloat
public let topOverscroll: Bool
public init(
colors: [UIColor]
colors: [UIColor],
cornerRadius: CGFloat = 10.0,
topOverscroll: Bool = false
) {
self.colors = colors
self.cornerRadius = cornerRadius
self.topOverscroll = topOverscroll
}
public static func ==(lhs: PremiumGradientBackgroundComponent, rhs: PremiumGradientBackgroundComponent) -> Bool {
if lhs.colors != rhs.colors {
return false
}
if lhs.cornerRadius != rhs.cornerRadius {
return false
}
if lhs.topOverscroll != rhs.topOverscroll {
return false
}
return true
}
public final class View: UIView {
private let clipLayer: CALayer
private let clipLayer: CAReplicatorLayer
private let gradientLayer: CAGradientLayer
private var component: PremiumGradientBackgroundComponent?
override init(frame: CGRect) {
self.clipLayer = CALayer()
self.clipLayer.cornerRadius = 10.0
self.clipLayer = CAReplicatorLayer()
self.clipLayer.masksToBounds = true
self.gradientLayer = CAGradientLayer()
@ -61,22 +72,36 @@ public final class PremiumGradientBackgroundComponent: Component {
func update(component: PremiumGradientBackgroundComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: Transition) -> CGSize {
self.clipLayer.frame = CGRect(origin: .zero, size: CGSize(width: availableSize.width, height: availableSize.height + 10.0))
self.gradientLayer.frame = CGRect(origin: .zero, size: availableSize)
var locations: [NSNumber] = []
let delta = 1.0 / CGFloat(component.colors.count - 1)
for i in 0 ..< component.colors.count {
locations.append((delta * CGFloat(i)) as NSNumber)
}
self.gradientLayer.locations = locations
self.gradientLayer.colors = component.colors.reversed().map { $0.cgColor }
self.gradientLayer.type = .radial
self.gradientLayer.startPoint = CGPoint(x: 1.0, y: 0.0)
self.gradientLayer.endPoint = CGPoint(x: -2.0, y: 3.0)
self.clipLayer.cornerRadius = component.cornerRadius
self.component = component
self.setupGradientAnimations()
if component.topOverscroll {
self.clipLayer.instanceCount = 2
var instanceTransform = CATransform3DIdentity
instanceTransform = CATransform3DTranslate(instanceTransform, 0.0, -availableSize.height * 1.5, 0.0)
instanceTransform = CATransform3DScale(instanceTransform, 1.0, -2.0, 1.0)
self.clipLayer.instanceTransform = instanceTransform
self.clipLayer.masksToBounds = false
} else {
self.clipLayer.masksToBounds = true
}
return availableSize
}

View File

@ -282,7 +282,9 @@ final class PremiumStarComponent: Component {
self.sceneView.scene = scene
self.sceneView.delegate = self
let _ = self.sceneView.snapshot()
self.didSetReady = true
self._ready.set(.single(true))
self.onReady()
}
private var didSetReady = false

View File

@ -308,7 +308,9 @@ private final class BoostHeaderComponent: CombinedComponent {
UIColor(rgb: 0x6b93ff),
UIColor(rgb: 0x8878ff),
UIColor(rgb: 0xe46ace)
]
],
cornerRadius: 0.0,
topOverscroll: true
),
availableSize: size,
transition: context.transition

View File

@ -25,7 +25,9 @@ public class TranslationMessageAttribute: MessageAttribute, Equatable {
public let entities: [MessageTextEntity]
public let toLang: String
public let additional: [Additional]
public let additional:[Additional]
public let pollSolution: Additional?
public var associatedPeerIds: [PeerId] {
return []
@ -35,12 +37,14 @@ public class TranslationMessageAttribute: MessageAttribute, Equatable {
text: String,
entities: [MessageTextEntity],
additional:[Additional] = [],
pollSolution: Additional? = nil,
toLang: String
) {
self.text = text
self.entities = entities
self.toLang = toLang
self.additional = additional
self.pollSolution = pollSolution
}
required public init(decoder: PostboxDecoder) {
@ -48,6 +52,7 @@ public class TranslationMessageAttribute: MessageAttribute, Equatable {
self.entities = decoder.decodeObjectArrayWithDecoderForKey("entities")
self.additional = decoder.decodeObjectArrayWithDecoderForKey("additional")
self.toLang = decoder.decodeStringForKey("toLang", orElse: "")
self.pollSolution = decoder.decodeObjectForKey("pollSolution") as? Additional
}
public func encode(_ encoder: PostboxEncoder) {
@ -55,6 +60,12 @@ public class TranslationMessageAttribute: MessageAttribute, Equatable {
encoder.encodeObjectArray(self.entities, forKey: "entities")
encoder.encodeString(self.toLang, forKey: "toLang")
encoder.encodeObjectArray(self.additional, forKey: "additional")
if let pollSolution {
encoder.encodeObject(pollSolution, forKey: "pollSolution")
} else {
encoder.encodeNil(forKey: "pollSolution")
}
}
public static func ==(lhs: TranslationMessageAttribute, rhs: TranslationMessageAttribute) -> Bool {
@ -70,6 +81,9 @@ public class TranslationMessageAttribute: MessageAttribute, Equatable {
if lhs.additional != rhs.additional {
return false
}
if lhs.pollSolution != rhs.pollSolution {
return false
}
return true
}
}

View File

@ -512,7 +512,7 @@ public extension TelegramEngine {
return _internal_translate_texts(network: self.account.network, texts: texts, toLang: toLang)
}
public func translateMessages(messageIds: [EngineMessage.Id], toLang: String) -> Signal<Void, TranslationError> {
public func translateMessages(messageIds: [EngineMessage.Id], toLang: String) -> Signal<Never, TranslationError> {
return _internal_translateMessages(account: self.account, messageIds: messageIds, toLang: toLang)
}

View File

@ -84,13 +84,18 @@ func _internal_translate_texts(network: Network, texts: [(String, [MessageTextEn
}
}
func _internal_translateMessages(account: Account, messageIds: [EngineMessage.Id], toLang: String) -> Signal<Void, TranslationError> {
guard let peerId = messageIds.first?.peerId else {
return .never()
func _internal_translateMessages(account: Account, messageIds: [EngineMessage.Id], toLang: String) -> Signal<Never, TranslationError> {
var signals: [Signal<Void, TranslationError>] = []
for (peerId, messageIds) in messagesIdsGroupedByPeerId(messageIds) {
signals.append(_internal_translateMessagesByPeerId(account: account, peerId: peerId, messageIds: messageIds, toLang: toLang))
}
return combineLatest(signals)
|> ignoreValues
}
private func _internal_translateMessagesByPeerId(account: Account, peerId: EnginePeer.Id, messageIds: [EngineMessage.Id], toLang: String) -> Signal<Void, TranslationError> {
return account.postbox.transaction { transaction -> (Api.InputPeer?, [Message]) in
return (transaction.getPeer(peerId).flatMap(apiInputPeer), messageIds.compactMap({ transaction.getMessage($0) }))
return (transaction.getPeer(peerId).flatMap(apiInputPeer), messageIds.compactMap({ transaction.getMessage($0) }))
}
|> castError(TranslationError.self)
|> mapToSignal { (inputPeer, messages) -> Signal<Void, TranslationError> in
@ -111,6 +116,9 @@ func _internal_translateMessages(account: Account, messageIds: [EngineMessage.Id
for option in poll.options {
texts.append((option.text, option.entities))
}
if let solution = poll.results.solution {
texts.append((solution.text, solution.entities))
}
return _internal_translate_texts(network: account.network, texts: texts, toLang: toLang)
}
@ -167,20 +175,34 @@ func _internal_translateMessages(account: Account, messageIds: [EngineMessage.Id
if !pollResults.isEmpty {
for (i, poll) in polls.enumerated() {
let result = pollResults[i]
transaction.updateMessage(poll.1, update: { currentMessage in
let storeForwardInfo = currentMessage.forwardInfo.flatMap(StoreMessageForwardInfo.init)
var attributes = currentMessage.attributes.filter { !($0 is TranslationMessageAttribute) }
var attrOptions: [TranslationMessageAttribute.Additional] = []
for (i, _) in poll.0.options.enumerated() {
let translated = result[i + 1]
attrOptions.append(.init(text: translated.0, entities: translated.1))
}
let updatedAttribute: TranslationMessageAttribute = TranslationMessageAttribute(text: result[0].0, entities: result[0].1, additional: attrOptions, toLang: toLang)
attributes.append(updatedAttribute)
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))
})
if !result.isEmpty {
transaction.updateMessage(poll.1, update: { currentMessage in
let storeForwardInfo = currentMessage.forwardInfo.flatMap(StoreMessageForwardInfo.init)
var attributes = currentMessage.attributes.filter { !($0 is TranslationMessageAttribute) }
var attrOptions: [TranslationMessageAttribute.Additional] = []
for (i, _) in poll.0.options.enumerated() {
var translated = result.count > i + 1 ? result[i + 1] : (poll.0.options[i].text, poll.0.options[i].entities)
if translated.0.isEmpty {
translated = (poll.0.options[i].text, poll.0.options[i].entities)
}
attrOptions.append(.init(text: translated.0, entities: translated.1))
}
let solution: TranslationMessageAttribute.Additional?
if result.count > 1 + poll.0.options.count, !result[result.count - 1].0.isEmpty {
solution = .init(text: result[result.count - 1].0, entities: result[result.count - 1].1)
} else {
solution = nil
}
let title = result[0].0.isEmpty ? (poll.0.text, poll.0.textEntities) : result[0]
let updatedAttribute: TranslationMessageAttribute = TranslationMessageAttribute(text: title.0, entities: title.1, additional: attrOptions, pollSolution: solution, toLang: toLang)
attributes.append(updatedAttribute)
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))
})
}
}
}
}

View File

@ -3453,6 +3453,18 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI
credibilityIconView?.removeFromSuperview()
})
}
if let boostBadgeNode = strongSelf.boostBadgeNode {
strongSelf.boostBadgeNode = nil
boostBadgeNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.1, removeOnCompletion: false, completion: { [weak boostBadgeNode] _ in
boostBadgeNode?.removeFromSupernode()
})
}
if let boostIconNode = strongSelf.boostIconNode {
strongSelf.boostIconNode = nil
boostIconNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.1, removeOnCompletion: false, completion: { [weak boostIconNode] _ in
boostIconNode?.removeFromSuperview()
})
}
} else {
strongSelf.nameNode?.removeFromSupernode()
strongSelf.nameNode = nil
@ -3460,19 +3472,23 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI
strongSelf.adminBadgeNode = nil
strongSelf.credibilityIconView?.removeFromSuperview()
strongSelf.credibilityIconView = nil
strongSelf.nameButtonNode?.removeFromSupernode()
strongSelf.nameButtonNode = nil
strongSelf.nameHighlightNode?.removeFromSupernode()
strongSelf.nameHighlightNode = nil
strongSelf.credibilityButtonNode?.removeFromSupernode()
strongSelf.credibilityButtonNode = nil
strongSelf.credibilityHighlightNode?.removeFromSupernode()
strongSelf.credibilityHighlightNode = nil
strongSelf.boostButtonNode?.removeFromSupernode()
strongSelf.boostButtonNode = nil
strongSelf.boostHighlightNode?.removeFromSupernode()
strongSelf.boostHighlightNode = nil
strongSelf.boostBadgeNode?.removeFromSupernode()
strongSelf.boostBadgeNode = nil
strongSelf.boostIconNode?.removeFromSuperview()
strongSelf.boostIconNode = nil
}
strongSelf.nameButtonNode?.removeFromSupernode()
strongSelf.nameButtonNode = nil
strongSelf.nameHighlightNode?.removeFromSupernode()
strongSelf.nameHighlightNode = nil
strongSelf.credibilityButtonNode?.removeFromSupernode()
strongSelf.credibilityButtonNode = nil
strongSelf.credibilityHighlightNode?.removeFromSupernode()
strongSelf.credibilityHighlightNode = nil
strongSelf.boostButtonNode?.removeFromSupernode()
strongSelf.boostButtonNode = nil
strongSelf.boostHighlightNode?.removeFromSupernode()
strongSelf.boostHighlightNode = nil
}
let timingFunction = kCAMediaTimingFunctionSpring

View File

@ -755,7 +755,7 @@ public final class ChatMessageAvatarHeaderNodeImpl: ListViewItemHeaderNode, Chat
self.controllerInteraction?.displayMessageTooltip(id, self.presentationData.strings.Conversation_ForwardAuthorHiddenTooltip, self, self.avatarNode.frame)
} else if let peer = self.peer {
if let adMessageId = self.adMessageId {
self.controllerInteraction?.activateAdAction(adMessageId)
self.controllerInteraction?.activateAdAction(adMessageId, nil)
} else {
if let channel = peer as? TelegramChannel, case .broadcast = channel.info {
self.controllerInteraction?.openPeer(EnginePeer(peer), .chat(textInputState: nil, subject: nil, peekData: nil), self.messageReference, .default)

View File

@ -140,7 +140,7 @@ public final class ChatMessageWebpageBubbleContentNode: ChatMessageBubbleContent
self.contentNode.activateAction = { [weak self] in
if let strongSelf = self, let item = strongSelf.item {
if let _ = item.message.adAttribute {
item.controllerInteraction.activateAdAction(item.message.id)
item.controllerInteraction.activateAdAction(item.message.id, strongSelf.contentNode.makeProgress())
} else {
var webPageContent: TelegramMediaWebpageLoadedContent?
for media in item.message.media {

View File

@ -604,7 +604,7 @@ final class ChatRecentActionsControllerNode: ViewControllerTracingNode {
}, openLargeEmojiInfo: { _, _, _ in
}, openJoinLink: { _ in
}, openWebView: { _, _, _, _ in
}, activateAdAction: { _ in
}, activateAdAction: { _, _ in
}, openRequestedPeerSelection: { _, _, _, _ in
}, saveMediaToFiles: { _ in
}, openNoAdsDemo: {

View File

@ -232,7 +232,7 @@ public final class ChatControllerInteraction: ChatControllerInteractionProtocol
public let openLargeEmojiInfo: (String, String?, TelegramMediaFile) -> Void
public let openJoinLink: (String) -> Void
public let openWebView: (String, String, Bool, ChatOpenWebViewSource) -> Void
public let activateAdAction: (EngineMessage.Id) -> Void
public let activateAdAction: (EngineMessage.Id, Promise<Bool>?) -> Void
public let openRequestedPeerSelection: (EngineMessage.Id, ReplyMarkupButtonRequestPeerType, Int32, Int32) -> Void
public let saveMediaToFiles: (EngineMessage.Id) -> Void
public let openNoAdsDemo: () -> Void
@ -357,7 +357,7 @@ public final class ChatControllerInteraction: ChatControllerInteractionProtocol
openLargeEmojiInfo: @escaping (String, String?, TelegramMediaFile) -> Void,
openJoinLink: @escaping (String) -> Void,
openWebView: @escaping (String, String, Bool, ChatOpenWebViewSource) -> Void,
activateAdAction: @escaping (EngineMessage.Id) -> Void,
activateAdAction: @escaping (EngineMessage.Id, Promise<Bool>?) -> Void,
openRequestedPeerSelection: @escaping (EngineMessage.Id, ReplyMarkupButtonRequestPeerType, Int32, Int32) -> Void,
saveMediaToFiles: @escaping (EngineMessage.Id) -> Void,
openNoAdsDemo: @escaping () -> Void,

View File

@ -0,0 +1,274 @@
import Foundation
import UIKit
import Display
import SwiftSignalKit
import Postbox
import TelegramCore
import AccountContext
import TextFormat
import SaveToCameraRoll
import ImageCompression
import LocalMediaResources
public extension MediaEditorScreen {
static func makeEditStoryController(
context: AccountContext,
peer: EnginePeer,
storyItem: EngineStoryItem,
videoPlaybackPosition: Double?,
repost: Bool,
transitionIn: MediaEditorScreen.TransitionIn,
transitionOut: MediaEditorScreen.TransitionOut?,
completed: @escaping () -> Void = {},
willDismiss: @escaping () -> Void = {},
update: @escaping (Disposable?) -> Void
) -> MediaEditorScreen? {
guard let peerReference = PeerReference(peer._asPeer()) else {
return nil
}
let subject: Signal<MediaEditorScreen.Subject?, NoError>
subject = getStorySource(engine: context.engine, peerId: peer.id, id: Int64(storyItem.id))
|> mapToSignal { source in
if !repost, let source {
return .single(.draft(source, Int64(storyItem.id)))
} else {
let media = storyItem.media._asMedia()
return fetchMediaData(context: context, postbox: context.account.postbox, userLocation: .peer(peerReference.id), customUserContentType: .story, mediaReference: .story(peer: peerReference, id: storyItem.id, media: media))
|> mapToSignal { (value, isImage) -> Signal<MediaEditorScreen.Subject?, NoError> in
guard case let .data(data) = value, data.complete else {
return .complete()
}
if let image = UIImage(contentsOfFile: data.path) {
return .single(nil)
|> then(
.single(.image(image, PixelDimensions(image.size), nil, .bottomRight))
|> delay(0.1, queue: Queue.mainQueue())
)
} else {
var duration: Double?
if let file = media as? TelegramMediaFile {
duration = file.duration
}
let symlinkPath = data.path + ".mp4"
if fileSize(symlinkPath) == nil {
let _ = try? FileManager.default.linkItem(atPath: data.path, toPath: symlinkPath)
}
return .single(nil)
|> then(
.single(.video(symlinkPath, nil, false, nil, nil, PixelDimensions(width: 720, height: 1280), duration ?? 0.0, [], .bottomRight))
)
}
}
}
}
let initialCaption: NSAttributedString?
let initialPrivacy: EngineStoryPrivacy?
let initialMediaAreas: [MediaArea]
if repost {
initialCaption = nil
initialPrivacy = nil
initialMediaAreas = []
} else {
initialCaption = chatInputStateStringWithAppliedEntities(storyItem.text, entities: storyItem.entities)
initialPrivacy = storyItem.privacy
initialMediaAreas = storyItem.mediaAreas
}
let externalState = MediaEditorTransitionOutExternalState(
storyTarget: nil,
isForcedTarget: false,
isPeerArchived: false,
transitionOut: nil
)
var updateProgressImpl: ((Float) -> Void)?
let controller = MediaEditorScreen(
context: context,
mode: .storyEditor,
subject: subject,
isEditing: !repost,
forwardSource: repost ? (peer, storyItem) : nil,
initialCaption: initialCaption,
initialPrivacy: initialPrivacy,
initialMediaAreas: initialMediaAreas,
initialVideoPosition: videoPlaybackPosition,
transitionIn: transitionIn,
transitionOut: { finished, isNew in
if repost && finished {
if let transitionOut = externalState.transitionOut?(externalState.storyTarget, externalState.isPeerArchived), let destinationView = transitionOut.destinationView {
return MediaEditorScreen.TransitionOut(
destinationView: destinationView,
destinationRect: transitionOut.destinationRect,
destinationCornerRadius: transitionOut.destinationCornerRadius
)
} else {
return nil
}
} else {
return transitionOut
}
},
completion: { result, commit in
let entities = generateChatInputTextEntities(result.caption)
if repost {
let target: Stories.PendingTarget
let targetPeerId: EnginePeer.Id
if let sendAsPeerId = result.options.sendAsPeerId {
target = .peer(sendAsPeerId)
targetPeerId = sendAsPeerId
} else {
target = .myStories
targetPeerId = context.account.peerId
}
externalState.storyTarget = target
completed()
let _ = (context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: targetPeerId))
|> deliverOnMainQueue).startStandalone(next: { peer in
guard let peer else {
return
}
if case let .user(user) = peer {
externalState.isPeerArchived = user.storiesHidden ?? false
} else if case let .channel(channel) = peer {
externalState.isPeerArchived = channel.storiesHidden ?? false
}
let forwardInfo = Stories.PendingForwardInfo(peerId: peerReference.id, storyId: storyItem.id, isModified: result.media != nil)
if let rootController = context.sharedContext.mainWindow?.viewController as? TelegramRootControllerInterface {
var existingMedia: EngineMedia?
if let _ = result.media {
} else {
existingMedia = storyItem.media
}
rootController.proceedWithStoryUpload(target: target, result: result as! MediaEditorScreenResult, existingMedia: existingMedia, forwardInfo: forwardInfo, externalState: externalState, commit: commit)
}
})
} else {
var updatedText: String?
var updatedEntities: [MessageTextEntity]?
if result.caption.string != storyItem.text || entities != storyItem.entities {
updatedText = result.caption.string
updatedEntities = entities
}
if let mediaResult = result.media {
switch mediaResult {
case let .image(image, dimensions):
updateProgressImpl?(0.0)
let tempFile = TempBox.shared.tempFile(fileName: "file")
defer {
TempBox.shared.dispose(tempFile)
}
if let imageData = compressImageToJPEG(image, quality: 0.7, tempFilePath: tempFile.path) {
update((context.engine.messages.editStory(peerId: peer.id, id: storyItem.id, media: .image(dimensions: dimensions, data: imageData, stickers: result.stickers), mediaAreas: result.mediaAreas, text: updatedText, entities: updatedEntities, privacy: nil)
|> deliverOnMainQueue).startStrict(next: { result in
switch result {
case let .progress(progress):
updateProgressImpl?(progress)
case .completed:
Queue.mainQueue().after(0.1) {
willDismiss()
HapticFeedback().success()
commit({})
}
}
}))
}
case let .video(content, firstFrameImage, values, duration, dimensions):
updateProgressImpl?(0.0)
if let valuesData = try? JSONEncoder().encode(values) {
let data = MemoryBuffer(data: valuesData)
let digest = MemoryBuffer(data: data.md5Digest())
let adjustments = VideoMediaResourceAdjustments(data: data, digest: digest, isStory: true)
let resource: TelegramMediaResource
switch content {
case let .imageFile(path):
resource = LocalFileVideoMediaResource(randomId: Int64.random(in: .min ... .max), path: path, adjustments: adjustments)
case let .videoFile(path):
resource = LocalFileVideoMediaResource(randomId: Int64.random(in: .min ... .max), path: path, adjustments: adjustments)
case let .asset(localIdentifier):
resource = VideoLibraryMediaResource(localIdentifier: localIdentifier, conversion: .compress(adjustments))
}
let tempFile = TempBox.shared.tempFile(fileName: "file")
defer {
TempBox.shared.dispose(tempFile)
}
let firstFrameImageData = firstFrameImage.flatMap { compressImageToJPEG($0, quality: 0.6, tempFilePath: tempFile.path) }
let firstFrameFile = firstFrameImageData.flatMap { data -> TempBoxFile? in
let file = TempBox.shared.tempFile(fileName: "image.jpg")
if let _ = try? data.write(to: URL(fileURLWithPath: file.path)) {
return file
} else {
return nil
}
}
update((context.engine.messages.editStory(peerId: peer.id, id: storyItem.id, media: .video(dimensions: dimensions, duration: duration, resource: resource, firstFrameFile: firstFrameFile, stickers: result.stickers), mediaAreas: result.mediaAreas, text: updatedText, entities: updatedEntities, privacy: nil)
|> deliverOnMainQueue).startStrict(next: { result in
switch result {
case let .progress(progress):
updateProgressImpl?(progress)
case .completed:
Queue.mainQueue().after(0.1) {
willDismiss()
HapticFeedback().success()
commit({})
}
}
}))
}
default:
break
}
} else if updatedText != nil {
let _ = (context.engine.messages.editStory(peerId: peer.id, id: storyItem.id, media: nil, mediaAreas: nil, text: updatedText, entities: updatedEntities, privacy: nil)
|> deliverOnMainQueue).startStandalone(next: { result in
switch result {
case .completed:
Queue.mainQueue().after(0.1) {
willDismiss()
HapticFeedback().success()
commit({})
}
default:
break
}
})
} else {
willDismiss()
HapticFeedback().success()
commit({})
}
}
}
)
controller.willDismiss = willDismiss
controller.navigationPresentation = .flatModal
updateProgressImpl = { [weak controller] progress in
controller?.updateEditProgress(progress, cancel: {
update(nil)
})
}
return controller
}
}

View File

@ -3109,6 +3109,13 @@ public final class MediaEditorScreen: ViewController, UIDropInteractionDelegate
if controller.isEmbeddedEditor == true {
mediaEditor.onFirstDisplay = { [weak self] in
if let self {
if let transitionInView = self.transitionInView {
self.transitionInView = nil
transitionInView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak transitionInView] _ in
transitionInView?.removeFromSuperview()
})
}
if effectiveSubject.isPhoto {
self.previewContainerView.layer.allowsGroupOpacity = true
self.previewContainerView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25, completion: { _ in
@ -3765,6 +3772,7 @@ public final class MediaEditorScreen: ViewController, UIDropInteractionDelegate
if let transitionOut = controller.transitionOut(finished, isNew), let destinationView = transitionOut.destinationView {
var destinationTransitionView: UIView?
var destinationTransitionRect: CGRect = .zero
if !finished {
if let transitionIn = controller.transitionIn, case let .gallery(galleryTransitionIn) = transitionIn, let sourceImage = galleryTransitionIn.sourceImage, isNew != true {
let sourceSuperView = galleryTransitionIn.sourceView?.superview?.superview
@ -3774,6 +3782,7 @@ public final class MediaEditorScreen: ViewController, UIDropInteractionDelegate
destinationTransitionOutView.frame = self.previewContainerView.convert(self.previewContainerView.bounds, to: sourceSuperView)
sourceSuperView?.addSubview(destinationTransitionOutView)
destinationTransitionView = destinationTransitionOutView
destinationTransitionRect = galleryTransitionIn.sourceRect
}
if let view = self.componentHost.view as? MediaEditorScreenComponent.View {
view.animateOut(to: .gallery)
@ -3853,7 +3862,7 @@ public final class MediaEditorScreen: ViewController, UIDropInteractionDelegate
if let destinationTransitionView {
self.previewContainerView.layer.allowsGroupOpacity = true
self.previewContainerView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, removeOnCompletion: false)
destinationTransitionView.layer.animateFrame(from: destinationTransitionView.frame, to: destinationView.convert(destinationView.bounds, to: destinationTransitionView.superview), duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, completion: { [weak destinationTransitionView] _ in
destinationTransitionView.layer.animateFrame(from: destinationTransitionView.frame, to: destinationView.convert(destinationTransitionRect, to: destinationTransitionView.superview), duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, completion: { [weak destinationTransitionView] _ in
destinationTransitionView?.removeFromSuperview()
})
}

View File

@ -3316,7 +3316,7 @@ final class PeerInfoScreenNode: ViewControllerTracingNode, PeerInfoScreenNodePro
}, openLargeEmojiInfo: { _, _, _ in
}, openJoinLink: { _ in
}, openWebView: { _, _, _, _ in
}, activateAdAction: { _ in
}, activateAdAction: { _, _ in
}, openRequestedPeerSelection: { _, _, _, _ in
}, saveMediaToFiles: { _ in
}, openNoAdsDemo: {

View File

@ -43,6 +43,7 @@ swift_library(
"//submodules/UndoUI",
"//submodules/TelegramUI/Components/PlainButtonComponent",
"//submodules/Components/ComponentDisplayAdapters",
"//submodules/TelegramUI/Components/MediaEditorScreen",
],
visibility = [
"//visibility:public",

View File

@ -33,6 +33,7 @@ import ShareController
import UndoUI
import PlainButtonComponent
import ComponentDisplayAdapters
import MediaEditorScreen
private let mediaBadgeBackgroundColor = UIColor(white: 0.0, alpha: 0.6)
private let mediaBadgeTextColor = UIColor.white
@ -1266,6 +1267,7 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr
private let listDisposable = MetaDisposable()
private var hiddenMediaDisposable: Disposable?
private let updateDisposable = MetaDisposable()
private var numberOfItemsToRequest: Int = 50
private var isRequestingView: Bool = false
@ -1765,6 +1767,7 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr
self.hiddenMediaDisposable?.dispose()
self.animationTimer?.invalidate()
self.presentationDataDisposable?.dispose()
self.updateDisposable.dispose()
}
public func loadHole(anchor: SparseItemGrid.HoleAnchor, at location: SparseItemGrid.HoleLocation) -> Signal<Never, NoError> {
@ -1858,16 +1861,54 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr
})))
}
/*items.append(.action(ContextMenuActionItem(text: self.presentationData.strings.StoryList_ItemAction_Edit, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Edit"), color: theme.contextMenu.primaryColor) }, action: { [weak self] c, _ in
c?.dismiss(completion: {
guard let self else {
return
}
let _ = self
})
})))*/
items.append(.action(ContextMenuActionItem(text: self.presentationData.strings.StoryList_ItemAction_Edit, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Edit"), color: theme.contextMenu.primaryColor) }, action: { [weak self] c, _ in
c?.dismiss(completion: {
guard let self else {
return
}
let _ = (self.context.engine.data.get(
TelegramEngine.EngineData.Item.Peer.Peer(id: self.peerId)
)
|> deliverOnMainQueue).startStandalone(next: { [weak self] peer in
guard let self, let peer else {
return
}
var foundItemLayer: SparseItemGridLayer?
var sourceImage: UIImage?
self.itemGrid.forEachVisibleItem { gridItem in
guard let itemLayer = gridItem.layer as? ItemLayer else {
return
}
if let listItem = itemLayer.item, listItem.story.id == item.id {
foundItemLayer = itemLayer
if let contents = itemLayer.contents, CFGetTypeID(contents as CFTypeRef) == CGImage.typeID {
sourceImage = UIImage(cgImage: contents as! CGImage)
}
}
}
guard let controller = MediaEditorScreen.makeEditStoryController(
context: self.context,
peer: peer,
storyItem: item,
videoPlaybackPosition: nil,
repost: false,
transitionIn: .gallery(MediaEditorScreen.TransitionIn.GalleryTransitionIn(sourceView: self.itemGrid.view, sourceRect: foundItemLayer?.frame ?? .zero, sourceImage: sourceImage)),
transitionOut: MediaEditorScreen.TransitionOut(destinationView: self.itemGrid.view, destinationRect: foundItemLayer?.frame ?? .zero, destinationCornerRadius: 0.0),
update: { [weak self] disposable in
guard let self else {
return
}
self.updateDisposable.set(disposable)
}
) else {
return
}
self.parentController?.push(controller)
})
})
})))
}
if !item.isForwardingDisabled, case .everyone = item.privacy?.base {
@ -1880,7 +1921,7 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr
let _ = (self.context.engine.data.get(
TelegramEngine.EngineData.Item.Peer.Peer(id: self.peerId)
)
|> deliverOnMainQueue).startStandalone(next: { [weak self] peer in
|> deliverOnMainQueue).startStandalone(next: { [weak self] peer in
guard let self else {
return
}

View File

@ -4308,7 +4308,7 @@ public final class StoryItemSetContainerComponent: Component {
self.sendMessageContext.currentSpeechHolder = speechHolder
}
case .translate:
self.sendMessageContext.performTranslateTextAction(view: self, text: text.string)
self.sendMessageContext.performTranslateTextAction(view: self, text: text.string, entities: [])
case .quote:
break
}
@ -5359,13 +5359,9 @@ public final class StoryItemSetContainerComponent: Component {
private let updateDisposable = MetaDisposable()
func openStoryEditing(repost: Bool = false) {
guard let component = self.component, let peerReference = PeerReference(component.slice.peer._asPeer()) else {
guard let component = self.component else {
return
}
let context = component.context
let peerId = component.slice.peer.id
let item = component.slice.item.storyItem
let id = item.id
self.isEditingStory = true
self.updateIsProgressPaused()
@ -5376,277 +5372,39 @@ public final class StoryItemSetContainerComponent: Component {
videoPlaybackPosition = view.videoPlaybackPosition
}
let subject: Signal<MediaEditorScreen.Subject?, NoError>
subject = getStorySource(engine: component.context.engine, peerId: component.context.account.peerId, id: Int64(item.id))
|> mapToSignal { source in
if !repost, let source {
return .single(.draft(source, Int64(item.id)))
} else {
let media = item.media._asMedia()
return fetchMediaData(context: context, postbox: context.account.postbox, userLocation: .peer(peerReference.id), customUserContentType: .story, mediaReference: .story(peer: peerReference, id: item.id, media: media))
|> mapToSignal { (value, isImage) -> Signal<MediaEditorScreen.Subject?, NoError> in
guard case let .data(data) = value, data.complete else {
return .complete()
}
if let image = UIImage(contentsOfFile: data.path) {
return .single(nil)
|> then(
.single(.image(image, PixelDimensions(image.size), nil, .bottomRight))
|> delay(0.1, queue: Queue.mainQueue())
)
} else {
var duration: Double?
if let file = media as? TelegramMediaFile {
duration = file.duration
}
let symlinkPath = data.path + ".mp4"
if fileSize(symlinkPath) == nil {
let _ = try? FileManager.default.linkItem(atPath: data.path, toPath: symlinkPath)
}
return .single(nil)
|> then(
.single(.video(symlinkPath, nil, false, nil, nil, PixelDimensions(width: 720, height: 1280), duration ?? 0.0, [], .bottomRight))
)
}
}
}
}
let initialCaption: NSAttributedString?
let initialPrivacy: EngineStoryPrivacy?
let initialMediaAreas: [MediaArea]
if repost {
initialCaption = nil
initialPrivacy = nil
initialMediaAreas = []
} else {
initialCaption = chatInputStateStringWithAppliedEntities(item.text, entities: item.entities)
initialPrivacy = item.privacy
initialMediaAreas = item.mediaAreas
}
let externalState = MediaEditorTransitionOutExternalState(
storyTarget: nil,
isForcedTarget: false,
isPeerArchived: false,
transitionOut: nil
)
var updateProgressImpl: ((Float) -> Void)?
let controller = MediaEditorScreen(
context: context,
mode: .storyEditor,
subject: subject,
isEditing: !repost,
forwardSource: repost ? (component.slice.peer, item) : nil,
initialCaption: initialCaption,
initialPrivacy: initialPrivacy,
initialMediaAreas: initialMediaAreas,
initialVideoPosition: videoPlaybackPosition,
guard let controller = MediaEditorScreen.makeEditStoryController(
context: component.context,
peer: component.slice.peer,
storyItem: component.slice.item.storyItem,
videoPlaybackPosition: videoPlaybackPosition,
repost: repost,
transitionIn: .noAnimation,
transitionOut: { finished, isNew in
if repost && finished {
if let transitionOut = externalState.transitionOut?(externalState.storyTarget, externalState.isPeerArchived), let destinationView = transitionOut.destinationView {
return MediaEditorScreen.TransitionOut(
destinationView: destinationView,
destinationRect: transitionOut.destinationRect,
destinationCornerRadius: transitionOut.destinationCornerRadius
)
} else {
return nil
}
} else {
return nil
}
},
completion: { [weak self] result, commit in
transitionOut: nil,
completed: { [weak self] in
guard let self else {
return
}
let entities = generateChatInputTextEntities(result.caption)
if repost {
let target: Stories.PendingTarget
let targetPeerId: EnginePeer.Id
if let sendAsPeerId = result.options.sendAsPeerId {
target = .peer(sendAsPeerId)
targetPeerId = sendAsPeerId
} else {
target = .myStories
targetPeerId = context.account.peerId
}
externalState.storyTarget = target
self.component?.controller()?.dismiss(animated: false)
let _ = (context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: targetPeerId))
|> deliverOnMainQueue).startStandalone(next: { peer in
guard let peer else {
return
}
if case let .user(user) = peer {
externalState.isPeerArchived = user.storiesHidden ?? false
} else if case let .channel(channel) = peer {
externalState.isPeerArchived = channel.storiesHidden ?? false
}
let forwardInfo = Stories.PendingForwardInfo(peerId: component.slice.peer.id, storyId: item.id, isModified: result.media != nil)
if let rootController = context.sharedContext.mainWindow?.viewController as? TelegramRootControllerInterface {
var existingMedia: EngineMedia?
if let _ = result.media {
} else {
existingMedia = item.media
}
rootController.proceedWithStoryUpload(target: target, result: result as! MediaEditorScreenResult, existingMedia: existingMedia, forwardInfo: forwardInfo, externalState: externalState, commit: commit)
}
})
} else {
var updatedText: String?
var updatedEntities: [MessageTextEntity]?
if result.caption.string != item.text || entities != item.entities {
updatedText = result.caption.string
updatedEntities = entities
}
if let mediaResult = result.media {
switch mediaResult {
case let .image(image, dimensions):
updateProgressImpl?(0.0)
let tempFile = TempBox.shared.tempFile(fileName: "file")
defer {
TempBox.shared.dispose(tempFile)
}
if let imageData = compressImageToJPEG(image, quality: 0.7, tempFilePath: tempFile.path) {
self.updateDisposable.set((context.engine.messages.editStory(peerId: peerId, id: id, media: .image(dimensions: dimensions, data: imageData, stickers: result.stickers), mediaAreas: result.mediaAreas, text: updatedText, entities: updatedEntities, privacy: nil)
|> deliverOnMainQueue).startStrict(next: { [weak self] result in
guard let self else {
return
}
switch result {
case let .progress(progress):
updateProgressImpl?(progress)
case .completed:
Queue.mainQueue().after(0.1) {
self.isEditingStory = false
self.rewindCurrentItem()
self.updateIsProgressPaused()
self.state?.updated(transition: .easeInOut(duration: 0.2))
HapticFeedback().success()
commit({})
}
}
}))
}
case let .video(content, firstFrameImage, values, duration, dimensions):
updateProgressImpl?(0.0)
if let valuesData = try? JSONEncoder().encode(values) {
let data = MemoryBuffer(data: valuesData)
let digest = MemoryBuffer(data: data.md5Digest())
let adjustments = VideoMediaResourceAdjustments(data: data, digest: digest, isStory: true)
let resource: TelegramMediaResource
switch content {
case let .imageFile(path):
resource = LocalFileVideoMediaResource(randomId: Int64.random(in: .min ... .max), path: path, adjustments: adjustments)
case let .videoFile(path):
resource = LocalFileVideoMediaResource(randomId: Int64.random(in: .min ... .max), path: path, adjustments: adjustments)
case let .asset(localIdentifier):
resource = VideoLibraryMediaResource(localIdentifier: localIdentifier, conversion: .compress(adjustments))
}
let tempFile = TempBox.shared.tempFile(fileName: "file")
defer {
TempBox.shared.dispose(tempFile)
}
let firstFrameImageData = firstFrameImage.flatMap { compressImageToJPEG($0, quality: 0.6, tempFilePath: tempFile.path) }
let firstFrameFile = firstFrameImageData.flatMap { data -> TempBoxFile? in
let file = TempBox.shared.tempFile(fileName: "image.jpg")
if let _ = try? data.write(to: URL(fileURLWithPath: file.path)) {
return file
} else {
return nil
}
}
self.updateDisposable.set((context.engine.messages.editStory(peerId: peerId, id: id, media: .video(dimensions: dimensions, duration: duration, resource: resource, firstFrameFile: firstFrameFile, stickers: result.stickers), mediaAreas: result.mediaAreas, text: updatedText, entities: updatedEntities, privacy: nil)
|> deliverOnMainQueue).startStrict(next: { [weak self] result in
guard let self else {
return
}
switch result {
case let .progress(progress):
updateProgressImpl?(progress)
case .completed:
Queue.mainQueue().after(0.1) {
self.isEditingStory = false
self.rewindCurrentItem()
self.updateIsProgressPaused()
self.state?.updated(transition: .easeInOut(duration: 0.2))
HapticFeedback().success()
commit({})
}
}
}))
}
default:
break
}
} else if updatedText != nil {
let _ = (context.engine.messages.editStory(peerId: peerId, id: id, media: nil, mediaAreas: nil, text: updatedText, entities: updatedEntities, privacy: nil)
|> deliverOnMainQueue).startStandalone(next: { [weak self] result in
switch result {
case .completed:
Queue.mainQueue().after(0.1) {
if let self {
self.isEditingStory = false
self.rewindCurrentItem()
self.updateIsProgressPaused()
self.state?.updated(transition: .easeInOut(duration: 0.2))
HapticFeedback().success()
}
commit({})
}
default:
break
}
})
} else {
self.isEditingStory = false
self.rewindCurrentItem()
self.updateIsProgressPaused()
self.state?.updated(transition: .easeInOut(duration: 0.2))
HapticFeedback().success()
commit({})
}
self.component?.controller()?.dismiss(animated: false)
},
willDismiss: { [weak self] in
guard let self else {
return
}
self.isEditingStory = false
self.rewindCurrentItem()
self.updateIsProgressPaused()
self.state?.updated(transition: .easeInOut(duration: 0.2))
},
update: { [weak self] disposable in
guard let self else {
return
}
self.updateDisposable.set(disposable)
}
)
controller.willDismiss = { [weak self] in
self?.isEditingStory = false
self?.rewindCurrentItem()
self?.updateIsProgressPaused()
self?.state?.updated(transition: .easeInOut(duration: 0.2))
) else {
return
}
controller.navigationPresentation = .flatModal
self.component?.controller()?.push(controller)
updateProgressImpl = { [weak controller, weak self] progress in
controller?.updateEditProgress(progress, cancel: { [weak self] in
self?.updateDisposable.set(nil)
})
}
}
private func presentSaveUpgradeScreen() {
@ -7059,7 +6817,7 @@ public final class StoryItemSetContainerComponent: Component {
guard let self, let component = self.component else {
return
}
self.sendMessageContext.performTranslateTextAction(view: self, text: component.slice.item.storyItem.text)
self.sendMessageContext.performTranslateTextAction(view: self, text: component.slice.item.storyItem.text, entities: component.slice.item.storyItem.entities)
})))
}
}

View File

@ -1159,7 +1159,7 @@ final class StoryItemSetContainerSendMessage {
controller.present(shareController, in: .window(.root))
}
func performTranslateTextAction(view: StoryItemSetContainerComponent.View, text: String) {
func performTranslateTextAction(view: StoryItemSetContainerComponent.View, text: String, entities: [MessageTextEntity]) {
guard let component = view.component else {
return
}
@ -1190,7 +1190,7 @@ final class StoryItemSetContainerSendMessage {
let _ = ApplicationSpecificNotice.incrementTranslationSuggestion(accountManager: component.context.sharedContext.accountManager, timestamp: Int32(Date().timeIntervalSince1970)).start()
let translateController = TranslateScreen(context: component.context, forceTheme: defaultDarkPresentationTheme, text: text, canCopy: true, fromLanguage: language, ignoredLanguages: translationSettings.ignoredLanguages)
let translateController = TranslateScreen(context: component.context, forceTheme: defaultDarkPresentationTheme, text: text, entities: entities, canCopy: true, fromLanguage: language, ignoredLanguages: translationSettings.ignoredLanguages)
translateController.pushController = { [weak view] c in
guard let view, let component = view.component else {
return

View File

@ -0,0 +1,231 @@
import Foundation
import UIKit
import Display
import SwiftSignalKit
import Postbox
import TelegramCore
import TelegramUIPreferences
import AccountContext
import MediaPickerUI
import MediaPasteboardUI
import LegacyMediaPickerUI
import MediaEditor
extension ChatControllerImpl {
func displayPasteMenu(_ subjects: [MediaPickerScreen.Subject.Media]) {
let _ = (self.context.sharedContext.accountManager.transaction { transaction -> GeneratedMediaStoreSettings in
let entry = transaction.getSharedData(ApplicationSpecificSharedDataKeys.generatedMediaStoreSettings)?.get(GeneratedMediaStoreSettings.self)
return entry ?? GeneratedMediaStoreSettings.defaultSettings
}
|> deliverOnMainQueue).startStandalone(next: { [weak self] settings in
if let strongSelf = self, let peer = strongSelf.presentationInterfaceState.renderedPeer?.peer {
strongSelf.chatDisplayNode.dismissInput()
let controller = mediaPasteboardScreen(
context: strongSelf.context,
updatedPresentationData: strongSelf.updatedPresentationData,
peer: EnginePeer(peer),
subjects: subjects,
presentMediaPicker: { [weak self] subject, saveEditedPhotos, bannedSendPhotos, bannedSendVideos, present in
if let strongSelf = self {
strongSelf.presentMediaPicker(subject: subject, saveEditedPhotos: saveEditedPhotos, bannedSendPhotos: bannedSendPhotos, bannedSendVideos: bannedSendVideos, present: present, updateMediaPickerContext: { _ in }, completion: { [weak self] signals, silentPosting, scheduleTime, getAnimatedTransitionSource, completion in
self?.enqueueMediaMessages(signals: signals, silentPosting: silentPosting, scheduleTime: scheduleTime, getAnimatedTransitionSource: getAnimatedTransitionSource, completion: completion)
})
}
},
getSourceRect: nil
)
controller.navigationPresentation = .flatModal
strongSelf.push(controller)
}
})
}
func enqueueGifData(_ data: Data) {
self.enqueueMediaMessageDisposable.set((legacyEnqueueGifMessage(account: self.context.account, data: data) |> deliverOnMainQueue).startStrict(next: { [weak self] message in
if let strongSelf = self {
let replyMessageSubject = strongSelf.presentationInterfaceState.interfaceState.replyMessageSubject
strongSelf.chatDisplayNode.setupSendActionOnViewUpdate({
if let strongSelf = self {
strongSelf.chatDisplayNode.collapseInput()
strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: false, {
$0.updatedInterfaceState { $0.withUpdatedReplyMessageSubject(nil) }
})
}
}, nil)
strongSelf.sendMessages([message].map { $0.withUpdatedReplyToMessageId(replyMessageSubject?.subjectModel) })
}
}))
}
func enqueueVideoData(_ data: Data) {
self.enqueueMediaMessageDisposable.set((legacyEnqueueGifMessage(account: self.context.account, data: data) |> deliverOnMainQueue).startStrict(next: { [weak self] message in
if let strongSelf = self {
let replyMessageSubject = strongSelf.presentationInterfaceState.interfaceState.replyMessageSubject
strongSelf.chatDisplayNode.setupSendActionOnViewUpdate({
if let strongSelf = self {
strongSelf.chatDisplayNode.collapseInput()
strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: false, {
$0.updatedInterfaceState { $0.withUpdatedReplyMessageSubject(nil) }
})
}
}, nil)
strongSelf.sendMessages([message].map { $0.withUpdatedReplyToMessageId(replyMessageSubject?.subjectModel) })
}
}))
}
func enqueueStickerImage(_ image: UIImage, isMemoji: Bool) {
let size = image.size.aspectFitted(CGSize(width: 512.0, height: 512.0))
self.enqueueMediaMessageDisposable.set((convertToWebP(image: image, targetSize: size, targetBoundingSize: size, quality: 0.9) |> deliverOnMainQueue).startStrict(next: { [weak self] data in
if let strongSelf = self, !data.isEmpty {
let resource = LocalFileMediaResource(fileId: Int64.random(in: Int64.min ... Int64.max))
strongSelf.context.account.postbox.mediaBox.storeResourceData(resource.id, data: data)
var fileAttributes: [TelegramMediaFileAttribute] = []
fileAttributes.append(.FileName(fileName: "sticker.webp"))
fileAttributes.append(.Sticker(displayText: "", packReference: nil, maskData: nil))
fileAttributes.append(.ImageSize(size: PixelDimensions(size)))
let media = TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: Int64.random(in: Int64.min ... Int64.max)), partialReference: nil, resource: resource, previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "image/webp", size: Int64(data.count), attributes: fileAttributes)
let message = EnqueueMessage.message(text: "", attributes: [], inlineStickers: [:], mediaReference: .standalone(media: media), threadId: strongSelf.chatLocation.threadId, replyToMessageId: nil, replyToStoryId: nil, localGroupingKey: nil, correlationId: nil, bubbleUpEmojiOrStickersets: [])
let replyMessageSubject = strongSelf.presentationInterfaceState.interfaceState.replyMessageSubject
strongSelf.chatDisplayNode.setupSendActionOnViewUpdate({
if let strongSelf = self {
strongSelf.chatDisplayNode.collapseInput()
strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: false, {
$0.updatedInterfaceState { $0.withUpdatedReplyMessageSubject(nil) }
})
}
}, nil)
strongSelf.sendMessages([message].map { $0.withUpdatedReplyToMessageId(replyMessageSubject?.subjectModel) })
}
}))
}
func enqueueStickerFile(_ file: TelegramMediaFile) {
let message = EnqueueMessage.message(text: "", attributes: [], inlineStickers: [:], mediaReference: .standalone(media: file), threadId: self.chatLocation.threadId, replyToMessageId: nil, replyToStoryId: nil, localGroupingKey: nil, correlationId: nil, bubbleUpEmojiOrStickersets: [])
let replyMessageSubject = self.presentationInterfaceState.interfaceState.replyMessageSubject
self.chatDisplayNode.setupSendActionOnViewUpdate({ [weak self] in
if let strongSelf = self {
strongSelf.chatDisplayNode.collapseInput()
strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: false, {
$0.updatedInterfaceState { $0.withUpdatedReplyMessageSubject(nil) }
})
}
}, nil)
self.sendMessages([message].map { $0.withUpdatedReplyToMessageId(replyMessageSubject?.subjectModel) })
Queue.mainQueue().after(3.0) {
if let message = self.chatDisplayNode.historyNode.lastVisbleMesssage(), let file = message.media.first(where: { $0 is TelegramMediaFile }) as? TelegramMediaFile, file.isSticker {
self.context.engine.stickers.addRecentlyUsedSticker(fileReference: .message(message: MessageReference(message), media: file))
}
}
}
func enqueueAnimatedStickerData(_ data: Data) {
guard let animatedImage = UIImage.animatedImageFromData(data: data), let thumbnailImage = animatedImage.images.first else {
return
}
let dimensions = PixelDimensions(width: 1080, height: 1920)
let image = generateImage(dimensions.cgSize, opaque: false, scale: 1.0, rotatedContext: { size, context in
context.clear(CGRect(origin: .zero, size: size))
})!
let blackImage = generateImage(dimensions.cgSize, opaque: true, scale: 1.0, rotatedContext: { size, context in
context.setFillColor(UIColor.black.cgColor)
context.fill(CGRect(origin: .zero, size: size))
})!
let stickerEntity = DrawingStickerEntity(content: .animatedImage(data, thumbnailImage))
stickerEntity.referenceDrawingSize = dimensions.cgSize
stickerEntity.position = CGPoint(x: dimensions.cgSize.width / 2.0, y: dimensions.cgSize.height / 2.0)
stickerEntity.scale = 3.5
let entities: [CodableDrawingEntity] = [
.sticker(stickerEntity)
]
let values = MediaEditorValues(
peerId: self.context.account.peerId,
originalDimensions: dimensions,
cropOffset: .zero,
cropRect: nil,
cropScale: 1.0,
cropRotation: 1.0,
cropMirroring: false,
cropOrientation: .up,
gradientColors: [.clear, .clear],
videoTrimRange: nil,
videoIsMuted: false,
videoIsFullHd: false,
videoIsMirrored: false,
videoVolume: nil,
additionalVideoPath: nil,
additionalVideoIsDual: false,
additionalVideoPosition: nil,
additionalVideoScale: nil,
additionalVideoRotation: nil,
additionalVideoPositionChanges: [],
additionalVideoTrimRange: nil,
additionalVideoOffset: nil,
additionalVideoVolume: nil,
nightTheme: false,
drawing: nil,
maskDrawing: blackImage,
entities: entities,
toolValues: [:],
audioTrack: nil,
audioTrackTrimRange: nil,
audioTrackOffset: nil,
audioTrackVolume: nil,
audioTrackSamples: nil,
qualityPreset: nil
)
let configuration = recommendedVideoExportConfiguration(values: values, duration: animatedImage.duration, frameRate: 30.0, isSticker: true)
let path = NSTemporaryDirectory() + "\(Int64.random(in: Int64.min ... Int64.max)).webm"
let videoExport = MediaEditorVideoExport(
postbox: self.context.account.postbox,
subject: .image(image: image),
configuration: configuration,
outputPath: path
)
videoExport.start()
let _ = (videoExport.status
|> deliverOnMainQueue).startStandalone(next: { [weak self] status in
guard let self else {
return
}
switch status {
case .completed:
var fileAttributes: [TelegramMediaFileAttribute] = []
fileAttributes.append(.FileName(fileName: "sticker.webm"))
fileAttributes.append(.Sticker(displayText: "", packReference: nil, maskData: nil))
fileAttributes.append(.Video(duration: animatedImage.duration, size: PixelDimensions(width: 512, height: 512), flags: [], preloadSize: nil))
let previewRepresentations: [TelegramMediaImageRepresentation] = []
// if let thumbnailResource {
// previewRepresentations.append(TelegramMediaImageRepresentation(dimensions: dimensions, resource: thumbnailResource, progressiveSizes: [], immediateThumbnailData: nil))
// }
let resource = LocalFileMediaResource(fileId: Int64.random(in: Int64.min ... Int64.max))
self.context.account.postbox.mediaBox.copyResourceData(resource.id, fromTempPath: path)
let file = TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: Int64.random(in: Int64.min ... Int64.max)), partialReference: nil, resource: resource, previewRepresentations: previewRepresentations, videoThumbnails: [], immediateThumbnailData: nil, mimeType: "video/webm", size: 0, attributes: fileAttributes)
self.enqueueStickerFile(file)
default:
break
}
})
// self.stickerVideoExport = videoExport
}
}

View File

@ -4095,12 +4095,12 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G
strongSelf.present(controller, in: .window(.root))
}
})
}, activateAdAction: { [weak self] messageId in
}, activateAdAction: { [weak self] messageId, progress in
guard let self, let message = self.chatDisplayNode.historyNode.messageInCurrentHistoryView(messageId), let adAttribute = message.adAttribute else {
return
}
self.chatDisplayNode.historyNode.adMessagesContext?.markAction(opaqueId: adAttribute.opaqueId)
self.controllerInteraction?.openUrl(ChatControllerInteraction.OpenUrl(url: adAttribute.url, concealed: false, external: true))
self.controllerInteraction?.openUrl(ChatControllerInteraction.OpenUrl(url: adAttribute.url, concealed: false, external: true, progress: progress))
}, openRequestedPeerSelection: { [weak self] messageId, peerType, buttonId, maxQuantity in
guard let self else {
return
@ -9182,122 +9182,6 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G
}))
}
func displayPasteMenu(_ subjects: [MediaPickerScreen.Subject.Media]) {
let _ = (self.context.sharedContext.accountManager.transaction { transaction -> GeneratedMediaStoreSettings in
let entry = transaction.getSharedData(ApplicationSpecificSharedDataKeys.generatedMediaStoreSettings)?.get(GeneratedMediaStoreSettings.self)
return entry ?? GeneratedMediaStoreSettings.defaultSettings
}
|> deliverOnMainQueue).startStandalone(next: { [weak self] settings in
if let strongSelf = self, let peer = strongSelf.presentationInterfaceState.renderedPeer?.peer {
strongSelf.chatDisplayNode.dismissInput()
let controller = mediaPasteboardScreen(
context: strongSelf.context,
updatedPresentationData: strongSelf.updatedPresentationData,
peer: EnginePeer(peer),
subjects: subjects,
presentMediaPicker: { [weak self] subject, saveEditedPhotos, bannedSendPhotos, bannedSendVideos, present in
if let strongSelf = self {
strongSelf.presentMediaPicker(subject: subject, saveEditedPhotos: saveEditedPhotos, bannedSendPhotos: bannedSendPhotos, bannedSendVideos: bannedSendVideos, present: present, updateMediaPickerContext: { _ in }, completion: { [weak self] signals, silentPosting, scheduleTime, getAnimatedTransitionSource, completion in
self?.enqueueMediaMessages(signals: signals, silentPosting: silentPosting, scheduleTime: scheduleTime, getAnimatedTransitionSource: getAnimatedTransitionSource, completion: completion)
})
}
},
getSourceRect: nil
)
controller.navigationPresentation = .flatModal
strongSelf.push(controller)
}
})
}
func enqueueGifData(_ data: Data) {
self.enqueueMediaMessageDisposable.set((legacyEnqueueGifMessage(account: self.context.account, data: data) |> deliverOnMainQueue).startStrict(next: { [weak self] message in
if let strongSelf = self {
let replyMessageSubject = strongSelf.presentationInterfaceState.interfaceState.replyMessageSubject
strongSelf.chatDisplayNode.setupSendActionOnViewUpdate({
if let strongSelf = self {
strongSelf.chatDisplayNode.collapseInput()
strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: false, {
$0.updatedInterfaceState { $0.withUpdatedReplyMessageSubject(nil) }
})
}
}, nil)
strongSelf.sendMessages([message].map { $0.withUpdatedReplyToMessageId(replyMessageSubject?.subjectModel) })
}
}))
}
func enqueueVideoData(_ data: Data) {
self.enqueueMediaMessageDisposable.set((legacyEnqueueGifMessage(account: self.context.account, data: data) |> deliverOnMainQueue).startStrict(next: { [weak self] message in
if let strongSelf = self {
let replyMessageSubject = strongSelf.presentationInterfaceState.interfaceState.replyMessageSubject
strongSelf.chatDisplayNode.setupSendActionOnViewUpdate({
if let strongSelf = self {
strongSelf.chatDisplayNode.collapseInput()
strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: false, {
$0.updatedInterfaceState { $0.withUpdatedReplyMessageSubject(nil) }
})
}
}, nil)
strongSelf.sendMessages([message].map { $0.withUpdatedReplyToMessageId(replyMessageSubject?.subjectModel) })
}
}))
}
func enqueueStickerImage(_ image: UIImage, isMemoji: Bool) {
let size = image.size.aspectFitted(CGSize(width: 512.0, height: 512.0))
self.enqueueMediaMessageDisposable.set((convertToWebP(image: image, targetSize: size, targetBoundingSize: size, quality: 0.9) |> deliverOnMainQueue).startStrict(next: { [weak self] data in
if let strongSelf = self, !data.isEmpty {
let resource = LocalFileMediaResource(fileId: Int64.random(in: Int64.min ... Int64.max))
strongSelf.context.account.postbox.mediaBox.storeResourceData(resource.id, data: data)
var fileAttributes: [TelegramMediaFileAttribute] = []
fileAttributes.append(.FileName(fileName: "sticker.webp"))
fileAttributes.append(.Sticker(displayText: "", packReference: nil, maskData: nil))
fileAttributes.append(.ImageSize(size: PixelDimensions(size)))
let media = TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: Int64.random(in: Int64.min ... Int64.max)), partialReference: nil, resource: resource, previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "image/webp", size: Int64(data.count), attributes: fileAttributes)
let message = EnqueueMessage.message(text: "", attributes: [], inlineStickers: [:], mediaReference: .standalone(media: media), threadId: strongSelf.chatLocation.threadId, replyToMessageId: nil, replyToStoryId: nil, localGroupingKey: nil, correlationId: nil, bubbleUpEmojiOrStickersets: [])
let replyMessageSubject = strongSelf.presentationInterfaceState.interfaceState.replyMessageSubject
strongSelf.chatDisplayNode.setupSendActionOnViewUpdate({
if let strongSelf = self {
strongSelf.chatDisplayNode.collapseInput()
strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: false, {
$0.updatedInterfaceState { $0.withUpdatedReplyMessageSubject(nil) }
})
}
}, nil)
strongSelf.sendMessages([message].map { $0.withUpdatedReplyToMessageId(replyMessageSubject?.subjectModel) })
}
}))
}
func enqueueStickerFile(_ file: TelegramMediaFile) {
let message = EnqueueMessage.message(text: "", attributes: [], inlineStickers: [:], mediaReference: .standalone(media: file), threadId: self.chatLocation.threadId, replyToMessageId: nil, replyToStoryId: nil, localGroupingKey: nil, correlationId: nil, bubbleUpEmojiOrStickersets: [])
let replyMessageSubject = self.presentationInterfaceState.interfaceState.replyMessageSubject
self.chatDisplayNode.setupSendActionOnViewUpdate({ [weak self] in
if let strongSelf = self {
strongSelf.chatDisplayNode.collapseInput()
strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: false, {
$0.updatedInterfaceState { $0.withUpdatedReplyMessageSubject(nil) }
})
}
}, nil)
self.sendMessages([message].map { $0.withUpdatedReplyToMessageId(replyMessageSubject?.subjectModel) })
Queue.mainQueue().after(3.0) {
if let message = self.chatDisplayNode.historyNode.lastVisbleMesssage(), let file = message.media.first(where: { $0 is TelegramMediaFile }) as? TelegramMediaFile, file.isSticker {
self.context.engine.stickers.addRecentlyUsedSticker(fileReference: .message(message: MessageReference(message), media: file))
}
}
}
func enqueueChatContextResult(_ results: ChatContextResultCollection, _ result: ChatContextResult, hideVia: Bool = false, closeMediaInput: Bool = false, silentPosting: Bool = false, resetTextInputState: Bool = true) {
if !canSendMessagesToChat(self.presentationInterfaceState) {
return
@ -9815,7 +9699,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G
let disposable = openUserGeneratedUrl(context: self.context, peerId: self.peerView?.peerId, url: url, concealed: concealed, skipUrlAuth: skipUrlAuth, skipConcealedAlert: skipConcealedAlert, present: { [weak self] c in
self?.present(c, in: .window(.root))
}, openResolved: { [weak self] resolved in
self?.openResolved(result: resolved, sourceMessageId: message?.id, forceExternal: forceExternal, concealed: concealed, commit: commit)
self?.openResolved(result: resolved, sourceMessageId: message?.id, progress: progress, forceExternal: forceExternal, concealed: concealed, commit: commit)
}, progress: progress)
self.navigationActionDisposable.set(disposable)
}, performAction: true)

View File

@ -251,9 +251,46 @@ func openResolvedUrlImpl(
navigationController?.pushViewController(InstantPageController(context: context, webPage: webpage, sourceLocation: InstantPageSourceLocation(userLocation: .other, peerType: .channel), anchor: anchor))
case let .join(link):
dismissInput()
present(JoinLinkPreviewController(context: context, link: link, navigateToPeer: { peer, peekData in
openPeer(peer, .chat(textInputState: nil, subject: nil, peekData: peekData))
}, parentNavigationController: navigationController), nil)
if let progress {
let progressSignal = Signal<Never, NoError> { subscriber in
progress.set(.single(true))
return ActionDisposable {
Queue.mainQueue().async() {
progress.set(.single(false))
}
}
}
|> runOn(Queue.mainQueue())
|> delay(0.1, queue: Queue.mainQueue())
let progressDisposable = progressSignal.startStrict()
var signal = context.engine.peers.joinLinkInformation(link)
signal = signal
|> afterDisposed {
Queue.mainQueue().async {
progressDisposable.dispose()
}
}
let _ = (signal
|> deliverOnMainQueue).startStandalone(next: { [weak navigationController] resolvedState in
switch resolvedState {
case let .alreadyJoined(peer):
openPeer(peer, .chat(textInputState: nil, subject: nil, peekData: nil))
case let .peek(peer, deadline):
openPeer(peer, .chat(textInputState: nil, subject: nil, peekData: ChatPeekTimeout(deadline: deadline, linkData: link)))
default:
present(JoinLinkPreviewController(context: context, link: link, navigateToPeer: { peer, peekData in
openPeer(peer, .chat(textInputState: nil, subject: nil, peekData: peekData))
}, parentNavigationController: navigationController, resolvedState: resolvedState), nil)
}
})
} else {
present(JoinLinkPreviewController(context: context, link: link, navigateToPeer: { peer, peekData in
openPeer(peer, .chat(textInputState: nil, subject: nil, peekData: peekData))
}, parentNavigationController: navigationController), nil)
}
case let .localization(identifier):
dismissInput()
present(LanguageLinkPreviewController(context: context, identifier: identifier), nil)

View File

@ -165,7 +165,7 @@ final class OverlayAudioPlayerControllerNode: ViewControllerTracingNode, ASGestu
}, openLargeEmojiInfo: { _, _, _ in
}, openJoinLink: { _ in
}, openWebView: { _, _, _, _ in
}, activateAdAction: { _ in
}, activateAdAction: { _, _ in
}, openRequestedPeerSelection: { _, _, _, _ in
}, saveMediaToFiles: { _ in
}, openNoAdsDemo: {

View File

@ -1759,7 +1759,7 @@ public final class SharedAccountContextImpl: SharedAccountContext {
}, openLargeEmojiInfo: { _, _, _ in
}, openJoinLink: { _ in
}, openWebView: { _, _, _, _ in
}, activateAdAction: { _ in
}, activateAdAction: { _, _ in
}, openRequestedPeerSelection: { _, _, _, _ in
}, saveMediaToFiles: { _ in
}, openNoAdsDemo: {

View File

@ -27,6 +27,7 @@ swift_library(
"//submodules/ComponentFlow:ComponentFlow",
"//submodules/Components/ViewControllerComponent:ViewControllerComponent",
"//submodules/Components/MultilineTextComponent:MultilineTextComponent",
"//submodules/Components/MultilineTextWithEntitiesComponent:MultilineTextWithEntitiesComponent",
"//submodules/Components/BundleIconComponent:BundleIconComponent",
"//submodules/UndoUI:UndoUI",
"//submodules/ActivityIndicator:ActivityIndicator",

View File

@ -109,8 +109,8 @@ 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<Void, NoError> {
return context.account.postbox.transaction { transaction -> Signal<Void, NoError> in
public func translateMessageIds(context: AccountContext, messageIds: [EngineMessage.Id], toLang: String) -> Signal<Never, NoError> {
return context.account.postbox.transaction { transaction -> Signal<Never, NoError> in
var messageIdsToTranslate: [EngineMessage.Id] = []
var messageIdsSet = Set<EngineMessage.Id>()
for messageId in messageIds {
@ -152,7 +152,7 @@ public func translateMessageIds(context: AccountContext, messageIds: [EngineMess
}
}
return context.engine.messages.translateMessages(messageIds: messageIdsToTranslate, toLang: toLang)
|> `catch` { _ -> Signal<Void, NoError> in
|> `catch` { _ -> Signal<Never, NoError> in
return .complete()
}
} |> switchToLatest

View File

@ -11,6 +11,7 @@ import Speak
import ComponentFlow
import ViewControllerComponent
import MultilineTextComponent
import MultilineTextWithEntitiesComponent
import BundleIconComponent
import UndoUI
@ -35,15 +36,17 @@ private final class TranslateScreenComponent: CombinedComponent {
let context: AccountContext
let text: String
let entities: [MessageTextEntity]
let fromLanguage: String?
let toLanguage: String
let copyTranslation: ((String) -> Void)?
let changeLanguage: (String, String, @escaping (String, String) -> Void) -> Void
let expand: () -> Void
init(context: AccountContext, text: String, fromLanguage: String?, toLanguage: String, copyTranslation: ((String) -> Void)?, changeLanguage: @escaping (String, String, @escaping (String, String) -> Void) -> Void, expand: @escaping () -> Void) {
init(context: AccountContext, text: String, entities: [MessageTextEntity], fromLanguage: String?, toLanguage: String, copyTranslation: ((String) -> Void)?, changeLanguage: @escaping (String, String, @escaping (String, String) -> Void) -> Void, expand: @escaping () -> Void) {
self.context = context
self.text = text
self.entities = entities
self.fromLanguage = fromLanguage
self.toLanguage = toLanguage
self.copyTranslation = copyTranslation
@ -58,6 +61,9 @@ private final class TranslateScreenComponent: CombinedComponent {
if lhs.text != rhs.text {
return false
}
if lhs.entities != rhs.entities {
return false
}
if lhs.fromLanguage != rhs.fromLanguage {
return false
}
@ -995,7 +1001,7 @@ public class TranslateScreen: ViewController {
public var wasDismissed: (() -> Void)?
public convenience init(context: AccountContext, forceTheme: PresentationTheme? = nil, text: String, canCopy: Bool, fromLanguage: String?, toLanguage: String? = nil, isExpanded: Bool = false, ignoredLanguages: [String]? = nil) {
public convenience init(context: AccountContext, forceTheme: PresentationTheme? = nil, text: String, entities: [MessageTextEntity] = [], canCopy: Bool, fromLanguage: String?, toLanguage: String? = nil, isExpanded: Bool = false, ignoredLanguages: [String]? = nil) {
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
var baseLanguageCode = presentationData.strings.baseLanguageCode
@ -1024,7 +1030,7 @@ public class TranslateScreen: ViewController {
var copyTranslationImpl: ((String) -> Void)?
var changeLanguageImpl: ((String, String, @escaping (String, String) -> Void) -> Void)?
var expandImpl: (() -> Void)?
self.init(context: context, component: TranslateScreenComponent(context: context, text: text, fromLanguage: fromLanguage, toLanguage: toLanguage, copyTranslation: !canCopy ? nil : { text in
self.init(context: context, component: TranslateScreenComponent(context: context, text: text, entities: entities, fromLanguage: fromLanguage, toLanguage: toLanguage, copyTranslation: !canCopy ? nil : { text in
copyTranslationImpl?(text)
}, changeLanguage: { fromLang, toLang, completion in
changeLanguageImpl?(fromLang, toLang, completion)