import Foundation import UIKit import Display import AsyncDisplayKit import Postbox import TelegramCore import SwiftSignalKit public struct ShareControllerAction { let title: String let action: () -> Void } public enum ShareControllerPreferredAction { case `default` case saveToCameraRoll case custom(action: ShareControllerAction) } public enum ShareControllerExternalStatus { case preparing case progress(Float) case done } public enum ShareControllerSubject { case url(String) case text(String) case quote(text: String, url: String) case messages([Message]) case image([ImageRepresentationWithReference]) case media(AnyMediaReference) case mapMedia(TelegramMediaMap) case fromExternal(([PeerId], String, Account) -> Signal) } private enum ExternalShareItem { case text(String) case url(URL) case image(UIImage) case file(URL, String, String) } private enum ExternalShareItemStatus { case progress case done(ExternalShareItem) } private enum ExternalShareResourceStatus { case progress case done(MediaResourceData) } private func collectExternalShareResource(postbox: Postbox, resourceReference: MediaResourceReference, statsCategory: MediaResourceStatsCategory) -> Signal { return Signal { subscriber in let fetched = fetchedMediaResource(postbox: postbox, reference: resourceReference, statsCategory: statsCategory).start() let data = postbox.mediaBox.resourceData(resourceReference.resource, option: .complete(waitUntilFetchStatus: false)).start(next: { value in if value.complete { subscriber.putNext(.done(value)) } else { subscriber.putNext(.progress) } }) return ActionDisposable { fetched.dispose() data.dispose() } } } private enum ExternalShareItemsState { case progress case done([ExternalShareItem]) } private struct CollectableExternalShareItem { let url: String? let text: String let mediaReference: AnyMediaReference? } private func collectExternalShareItems(strings: PresentationStrings, postbox: Postbox, collectableItems: [CollectableExternalShareItem]) -> Signal { var signals: [Signal] = [] for item in collectableItems { if let mediaReference = item.mediaReference, let file = mediaReference.media as? TelegramMediaFile { signals.append(collectExternalShareResource(postbox: postbox, resourceReference: mediaReference.resourceReference(file.resource), statsCategory: statsCategoryForFileWithAttributes(file.attributes)) |> mapToSignal { next -> Signal in switch next { case .progress: return .single(.progress) case let .done(data): if file.isSticker, let dimensions = file.dimensions { return chatMessageSticker(postbox: postbox, file: file, small: false, fetched: true, onlyFullSize: true) |> map { f -> ExternalShareItemStatus in let context = f(TransformImageArguments(corners: ImageCorners(), imageSize: dimensions, boundingSize: dimensions, intrinsicInsets: UIEdgeInsets(), emptyColor: nil, scale: 1.0)) if let image = context?.generateImage() { return .done(.image(image)) } else { return .progress } } } else { let fileName: String if let value = file.fileName { fileName = value } else if file.isVideo { fileName = "telegram_video.mp4" } else { fileName = "file" } let randomDirectory = UUID() let safeFileName = fileName.replacingOccurrences(of: "/", with: "_") let fileDirectory = NSTemporaryDirectory() + "\(randomDirectory)" let _ = try? FileManager.default.createDirectory(at: URL(fileURLWithPath: fileDirectory), withIntermediateDirectories: true, attributes: nil) let filePath = fileDirectory + "/\(safeFileName)" if let _ = try? FileManager.default.copyItem(at: URL(fileURLWithPath: data.path), to: URL(fileURLWithPath: filePath)) { return .single(.done(.file(URL(fileURLWithPath: filePath), fileName, file.mimeType))) } else { return .single(.progress) } } } }) } else if let mediaReference = item.mediaReference, let image = mediaReference.media as? TelegramMediaImage, let largest = largestImageRepresentation(image.representations) { signals.append(collectExternalShareResource(postbox: postbox, resourceReference: mediaReference.resourceReference(largest.resource), statsCategory: .image) |> map { next -> ExternalShareItemStatus in switch next { case .progress: return .progress case let .done(data): if let fileData = try? Data(contentsOf: URL(fileURLWithPath: data.path)), let image = UIImage(data: fileData) { return .done(.image(image)) } else { return .progress } } }) } else if let mediaReference = item.mediaReference, let poll = mediaReference.media as? TelegramMediaPoll { var text = "šŸ“Š \(poll.text)" text.append("\n\(strings.MessagePoll_LabelAnonymous)") for option in poll.options { text.append("\n— \(option.text)") } let totalVoters = poll.results.totalVoters ?? 0 if totalVoters == 0 { text.append("\n\(strings.MessagePoll_NoVotes)") } else { text.append("\n\(strings.MessagePoll_VotedCount(totalVoters))") } signals.append(.single(.done(.text(text)))) } if let url = item.url, let parsedUrl = URL(string: url) { if signals.isEmpty { signals.append(.single(.done(.url(parsedUrl)))) } } if !item.text.isEmpty { if signals.isEmpty { signals.append(.single(.done(.text(item.text)))) } } } return combineLatest(signals) |> map { statuses -> ExternalShareItemsState in var items: [ExternalShareItem] = [] for status in statuses { switch status { case .progress: return .progress case let .done(item): items.append(item) } } return .done(items) } |> distinctUntilChanged(isEqual: { lhs, rhs in if case .progress = lhs, case .progress = rhs { return true } else { return false } }) } public final class ShareController: ViewController { private var controllerNode: ShareControllerNode { return self.displayNode as! ShareControllerNode } private var animatedIn = false private let sharedContext: SharedAccountContext private let currentContext: AccountContext private var currentAccount: Account private var presentationData: PresentationData private var presentationDataDisposable: Disposable? private let externalShare: Bool private let immediateExternalShare: Bool private let subject: ShareControllerSubject private let switchableAccounts: [AccountWithInfo] private let peers = Promise<([(RenderedPeer, PeerPresence?)], Peer)>() private let peersDisposable = MetaDisposable() private let readyDisposable = MetaDisposable() private let acountActiveDisposable = MetaDisposable() private var defaultAction: ShareControllerAction? public var dismissed: ((Bool) -> Void)? public convenience init(context: AccountContext, subject: ShareControllerSubject, preferredAction: ShareControllerPreferredAction = .default, showInChat: ((Message) -> Void)? = nil, externalShare: Bool = true, immediateExternalShare: Bool = false, switchableAccounts: [AccountWithInfo] = []) { self.init(sharedContext: context.sharedContext, currentContext: context, subject: subject, preferredAction: preferredAction, showInChat: showInChat, externalShare: externalShare, immediateExternalShare: immediateExternalShare, switchableAccounts: switchableAccounts) } public init(sharedContext: SharedAccountContext, currentContext: AccountContext, subject: ShareControllerSubject, preferredAction: ShareControllerPreferredAction = .default, showInChat: ((Message) -> Void)? = nil, externalShare: Bool = true, immediateExternalShare: Bool = false, switchableAccounts: [AccountWithInfo] = []) { self.sharedContext = sharedContext self.currentContext = currentContext self.currentAccount = currentContext.account self.subject = subject self.externalShare = externalShare self.immediateExternalShare = immediateExternalShare self.switchableAccounts = switchableAccounts self.presentationData = self.sharedContext.currentPresentationData.with { $0 } super.init(navigationBarPresentationData: nil) switch subject { case let .url(text): self.defaultAction = ShareControllerAction(title: self.presentationData.strings.ShareMenu_CopyShareLink, action: { [weak self] in UIPasteboard.general.string = text self?.controllerNode.cancel?() }) case .text: break case let .mapMedia(media): self.defaultAction = ShareControllerAction(title: self.presentationData.strings.ShareMenu_CopyShareLink, action: { [weak self] in let latLong = "\(media.latitude),\(media.longitude)" let url = "https://maps.apple.com/maps?ll=\(latLong)&q=\(latLong)&t=m" UIPasteboard.general.string = url self?.controllerNode.cancel?() }) break case .quote: break case let .image(representations): if case .saveToCameraRoll = preferredAction { self.defaultAction = ShareControllerAction(title: self.presentationData.strings.Preview_SaveToCameraRoll, action: { [weak self] in self?.saveToCameraRoll(representations: representations) }) } case let .media(mediaReference): var canSave = false if mediaReference.media is TelegramMediaImage { canSave = true } else if mediaReference.media is TelegramMediaFile { canSave = true } if case .saveToCameraRoll = preferredAction, canSave { self.defaultAction = ShareControllerAction(title: self.presentationData.strings.Preview_SaveToCameraRoll, action: { [weak self] in self?.saveToCameraRoll(mediaReference: mediaReference) }) } case let .messages(messages): if case .saveToCameraRoll = preferredAction { self.defaultAction = ShareControllerAction(title: self.presentationData.strings.Preview_SaveToCameraRoll, action: { [weak self] in self?.saveToCameraRoll(messages: messages) }) } else if let message = messages.first { let groupingKey: Int64? = message.groupingKey var sameGroupingKey = groupingKey != nil if sameGroupingKey { for message in messages { if message.groupingKey != groupingKey { sameGroupingKey = false break } } } if let showInChat = showInChat, messages.count == 1 { self.defaultAction = ShareControllerAction(title: self.presentationData.strings.SharedMedia_ViewInChat, action: { [weak self] in self?.controllerNode.cancel?() showInChat(message) }) } else if let chatPeer = message.peers[message.id.peerId] as? TelegramChannel, messages.count == 1 || sameGroupingKey { if message.id.namespace == Namespaces.Message.Cloud { self.defaultAction = ShareControllerAction(title: self.presentationData.strings.ShareMenu_CopyShareLink, action: { [weak self] in guard let strongSelf = self else { return } let _ = (exportMessageLink(account: strongSelf.currentAccount, peerId: chatPeer.id, messageId: message.id) |> map { result -> String? in return result } |> deliverOnMainQueue).start(next: { link in if let link = link { UIPasteboard.general.string = link } }) strongSelf.controllerNode.cancel?() }) } } } case .fromExternal: break } if case let .custom(action) = preferredAction { self.defaultAction = ShareControllerAction(title: action.title, action: { [weak self] in self?.controllerNode.cancel?() action.action() }) } self.presentationDataDisposable = (self.sharedContext.presentationData |> deliverOnMainQueue).start(next: { [weak self] presentationData in if let strongSelf = self, strongSelf.isNodeLoaded { strongSelf.controllerNode.updatePresentationData(presentationData) } }) self.switchToAccount(account: currentAccount, animateIn: false) } required public init(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } deinit { self.peersDisposable.dispose() self.readyDisposable.dispose() self.acountActiveDisposable.dispose() } override public func loadDisplayNode() { self.displayNode = ShareControllerNode(sharedContext: self.sharedContext, defaultAction: self.defaultAction, requestLayout: { [weak self] transition in self?.requestLayout(transition: transition) }, externalShare: self.externalShare, immediateExternalShare: self.immediateExternalShare) self.controllerNode.dismiss = { [weak self] shared in self?.presentingViewController?.dismiss(animated: false, completion: nil) self?.dismissed?(shared) } self.controllerNode.cancel = { [weak self] in self?.controllerNode.view.endEditing(true) self?.controllerNode.animateOut(shared: false, completion: { self?.presentingViewController?.dismiss(animated: false, completion: nil) self?.dismissed?(false) }) } self.controllerNode.share = { [weak self] text, peerIds in if let strongSelf = self { switch strongSelf.subject { case let .url(url): for peerId in peerIds { var messages: [EnqueueMessage] = [] if !text.isEmpty { messages.append(.message(text: text, attributes: [], mediaReference: nil, replyToMessageId: nil, localGroupingKey: nil)) } messages.append(.message(text: url, attributes: [], mediaReference: nil, replyToMessageId: nil, localGroupingKey: nil)) let _ = enqueueMessages(account: strongSelf.currentAccount, peerId: peerId, messages: messages).start() } return .complete() case let .text(string): for peerId in peerIds { var messages: [EnqueueMessage] = [] if !text.isEmpty { messages.append(.message(text: text, attributes: [], mediaReference: nil, replyToMessageId: nil, localGroupingKey: nil)) } messages.append(.message(text: string, attributes: [], mediaReference: nil, replyToMessageId: nil, localGroupingKey: nil)) let _ = enqueueMessages(account: strongSelf.currentAccount, peerId: peerId, messages: messages).start() } return .complete() case let .quote(string, url): for peerId in peerIds { var messages: [EnqueueMessage] = [] if !text.isEmpty { messages.append(.message(text: text, attributes: [], mediaReference: nil, replyToMessageId: nil, localGroupingKey: nil)) } let attributedText = NSMutableAttributedString(string: string, attributes: [ChatTextInputAttributes.italic: true as NSNumber]) attributedText.append(NSAttributedString(string: "\n\n\(url)")) let entities = generateChatInputTextEntities(attributedText) messages.append(.message(text: attributedText.string, attributes: [TextEntitiesMessageAttribute(entities: entities)], mediaReference: nil, replyToMessageId: nil, localGroupingKey: nil)) let _ = enqueueMessages(account: strongSelf.currentAccount, peerId: peerId, messages: messages).start() } return .complete() case let .image(representations): for peerId in peerIds { var messages: [EnqueueMessage] = [] if !text.isEmpty { messages.append(.message(text: text, attributes: [], mediaReference: nil, replyToMessageId: nil, localGroupingKey: nil)) } messages.append(.message(text: "", attributes: [], mediaReference: .standalone(media: TelegramMediaImage(imageId: MediaId(namespace: Namespaces.Media.LocalImage, id: arc4random64()), representations: representations.map({ $0.representation }), immediateThumbnailData: nil, reference: nil, partialReference: nil)), replyToMessageId: nil, localGroupingKey: nil)) let _ = enqueueMessages(account: strongSelf.currentAccount, peerId: peerId, messages: messages).start() } return .complete() case let .media(mediaReference): for peerId in peerIds { var messages: [EnqueueMessage] = [] if !text.isEmpty { messages.append(.message(text: text, attributes: [], mediaReference: nil, replyToMessageId: nil, localGroupingKey: nil)) } messages.append(.message(text: "", attributes: [], mediaReference: mediaReference, replyToMessageId: nil, localGroupingKey: nil)) let _ = enqueueMessages(account: strongSelf.currentAccount, peerId: peerId, messages: messages).start() } return .complete() case let .mapMedia(media): for peerId in peerIds { var messages: [EnqueueMessage] = [] if !text.isEmpty { messages.append(.message(text: text, attributes: [], mediaReference: nil, replyToMessageId: nil, localGroupingKey: nil)) } messages.append(.message(text: "", attributes: [], mediaReference: .standalone(media: media), replyToMessageId: nil, localGroupingKey: nil)) let _ = enqueueMessages(account: strongSelf.currentAccount, peerId: peerId, messages: messages).start() } return .complete() case let .messages(messages): for peerId in peerIds { var messagesToEnqueue: [EnqueueMessage] = [] if !text.isEmpty { messagesToEnqueue.append(.message(text: text, attributes: [], mediaReference: nil, replyToMessageId: nil, localGroupingKey: nil)) } for message in messages { messagesToEnqueue.append(.forward(source: message.id, grouping: .auto)) } let _ = enqueueMessages(account: strongSelf.currentAccount, peerId: peerId, messages: messagesToEnqueue).start() } return .single(.done) case let .fromExternal(f): return f(peerIds, text, strongSelf.currentAccount) |> map { state -> ShareState in switch state { case .preparing: return .preparing case let .progress(value): return .progress(value) case .done: return .done } } } } return .complete() } self.controllerNode.shareExternal = { [weak self] in if let strongSelf = self { var collectableItems: [CollectableExternalShareItem] = [] switch strongSelf.subject { case let .url(text): collectableItems.append(CollectableExternalShareItem(url: text, text: "", mediaReference: nil)) case let .text(string): collectableItems.append(CollectableExternalShareItem(url: "", text: string, mediaReference: nil)) case let .quote(text, url): collectableItems.append(CollectableExternalShareItem(url: "", text: "\"\(text)\"\n\n\(url)", mediaReference: nil)) case let .image(representations): let media = TelegramMediaImage(imageId: MediaId(namespace: Namespaces.Media.LocalImage, id: arc4random64()), representations: representations.map({ $0.representation }), immediateThumbnailData: nil, reference: nil, partialReference: nil) collectableItems.append(CollectableExternalShareItem(url: "", text: "", mediaReference: .standalone(media: media))) case let .media(mediaReference): collectableItems.append(CollectableExternalShareItem(url: "", text: "", mediaReference: mediaReference)) case let .mapMedia(media): let latLong = "\(media.latitude),\(media.longitude)" collectableItems.append(CollectableExternalShareItem(url: "https://maps.apple.com/maps?ll=\(latLong)&q=\(latLong)&t=m", text: "", mediaReference: nil)) case let .messages(messages): for message in messages { var url: String? var selectedMedia: Media? loop: for media in message.media { switch media { case _ as TelegramMediaImage, _ as TelegramMediaFile: selectedMedia = media break loop case let webpage as TelegramMediaWebpage: if case let .Loaded(content) = webpage.content { if let file = content.file { selectedMedia = file } else if let image = content.image { selectedMedia = image } } case _ as TelegramMediaPoll: selectedMedia = media break loop default: break } } if let chatPeer = message.peers[message.id.peerId] as? TelegramChannel { if message.id.namespace == Namespaces.Message.Cloud, let addressName = chatPeer.addressName, !addressName.isEmpty { url = "https://t.me/\(addressName)/\(message.id.id)" } } collectableItems.append(CollectableExternalShareItem(url: url, text: message.text, mediaReference: selectedMedia.flatMap({ AnyMediaReference.message(message: MessageReference(message), media: $0) }))) } case .fromExternal: break } return (collectExternalShareItems(strings: strongSelf.presentationData.strings, postbox: strongSelf.currentAccount.postbox, collectableItems: collectableItems) |> deliverOnMainQueue) |> map { state in switch state { case .progress: return .preparing case let .done(items): if let strongSelf = self, !items.isEmpty { strongSelf.ready.set(.single(true)) var activityItems: [Any] = [] for item in items { switch item { case let .url(url): activityItems.append(url as NSURL) case let .text(text): activityItems.append(text as NSString) case let .image(image): activityItems.append(image) case let .file(url, _, _): activityItems.append(url) } } let activityController = UIActivityViewController(activityItems: activityItems, applicationActivities: nil) if let window = strongSelf.view.window, let rootViewController = window.rootViewController { activityController.popoverPresentationController?.sourceView = window activityController.popoverPresentationController?.sourceRect = CGRect(origin: CGPoint(x: window.bounds.width / 2.0, y: window.bounds.size.height - 1.0), size: CGSize(width: 1.0, height: 1.0)) rootViewController.present(activityController, animated: true, completion: nil) } } return .done } } } else { return .single(.done) } } self.controllerNode.switchToAnotherAccount = { [weak self] in guard let strongSelf = self else { return } strongSelf.controllerNode.animateOut(shared: false, completion: {}) let presentationData = strongSelf.sharedContext.currentPresentationData.with { $0 } let controller = ActionSheetController(presentationTheme: presentationData.theme) controller.dismissed = { [weak self] cancelled in if cancelled { self?.controllerNode.animateIn() } } let dismissAction: () -> Void = { [weak controller] in controller?.dismissAnimated() } var items: [ActionSheetItem] = [] for info in strongSelf.switchableAccounts { items.append(ActionSheetPeerItem(account: info.account, peer: info.peer, title: info.peer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder), isSelected: info.account.id == strongSelf.currentAccount.id, strings: presentationData.strings, theme: presentationData.theme, action: { [weak self] in dismissAction() self?.switchToAccount(account: info.account, animateIn: true) })) } controller.setItemGroups([ ActionSheetItemGroup(items: items) ]) strongSelf.view.endEditing(true) strongSelf.present(controller, in: .window(.root), with: ViewControllerPresentationArguments(presentationAnimation: .modalSheet)) } self.displayNodeDidLoad() self.peersDisposable.set((self.peers.get() |> deliverOnMainQueue).start(next: { [weak self] next in if let strongSelf = self { strongSelf.controllerNode.updatePeers(account: strongSelf.currentAccount, switchableAccounts: strongSelf.switchableAccounts, peers: next.0, accountPeer: next.1, defaultAction: strongSelf.defaultAction) } })) self.ready.set(self.controllerNode.ready.get()) } override public func loadView() { super.loadView() self.statusBar.removeFromSupernode() } override public func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) if !self.animatedIn { self.animatedIn = true self.controllerNode.animateIn() } } override public func dismiss(completion: (() -> Void)? = nil) { self.controllerNode.view.endEditing(true) self.controllerNode.animateOut(shared: false, completion: { [weak self] in self?.presentingViewController?.dismiss(animated: false, completion: nil) completion?() }) } override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { super.containerLayoutUpdated(layout, transition: transition) self.controllerNode.containerLayoutUpdated(layout, navigationBarHeight: self.navigationHeight, transition: transition) } private func saveToCameraRoll(messages: [Message]) { let postbox = self.currentAccount.postbox let signals: [Signal] = messages.compactMap { message -> Signal? in if let media = message.media.first { let context: AccountContext if self.currentContext.account.id == self.currentAccount.id { context = self.currentContext } else { context = AccountContext(sharedContext: self.sharedContext, account: self.currentAccount, limitsConfiguration: .defaultValue) } return TelegramUI.saveToCameraRoll(context: context, postbox: postbox, mediaReference: .message(message: MessageReference(message), media: media)) } else { return nil } } if !signals.isEmpty { let total = combineLatest(signals) |> map { values -> Float? in var total: Float = 0.0 for value in values { total += value } total /= Float(values.count) return total } self.controllerNode.transitionToProgressWithValue(signal: total) } } private func saveToCameraRoll(representations: [ImageRepresentationWithReference]) { let media = TelegramMediaImage(imageId: MediaId(namespace: 0, id: 0), representations: representations.map({ $0.representation }), immediateThumbnailData: nil, reference: nil, partialReference: nil) let context: AccountContext if self.currentContext.account.id == self.currentAccount.id { context = self.currentContext } else { context = AccountContext(sharedContext: self.sharedContext, account: self.currentAccount, limitsConfiguration: .defaultValue) } self.controllerNode.transitionToProgressWithValue(signal: TelegramUI.saveToCameraRoll(context: context, postbox: context.account.postbox, mediaReference: .standalone(media: media)) |> map(Optional.init)) } private func saveToCameraRoll(mediaReference: AnyMediaReference) { let context: AccountContext if self.currentContext.account.id == self.currentAccount.id { context = self.currentContext } else { context = AccountContext(sharedContext: self.sharedContext, account: self.currentAccount, limitsConfiguration: .defaultValue) } self.controllerNode.transitionToProgressWithValue(signal: TelegramUI.saveToCameraRoll(context: context, postbox: context.account.postbox, mediaReference: mediaReference) |> map(Optional.init)) } private func switchToAccount(account: Account, animateIn: Bool) { self.currentAccount = account self.acountActiveDisposable.set(self.sharedContext.setAccountUserInterfaceInUse(account.id)) self.peers.set(combineLatest( self.currentAccount.postbox.loadedPeerWithId(self.currentAccount.peerId) |> take(1), self.currentAccount.viewTracker.tailChatListView(groupId: .root, count: 150) |> take(1) ) |> map { accountPeer, view -> ([(RenderedPeer, PeerPresence?)], Peer) in var peers: [(RenderedPeer, PeerPresence?)] = [] for entry in view.0.entries.reversed() { switch entry { case let .MessageEntry(_, _, _, _, _, renderedPeer, presence, _): if let peer = renderedPeer.peers[renderedPeer.peerId], peer.id != accountPeer.id, canSendMessagesToPeer(peer) { peers.append((renderedPeer, presence)) } default: break } } return (peers, accountPeer) }) self.peersDisposable.set((self.peers.get() |> deliverOnMainQueue).start(next: { [weak self] next in if let strongSelf = self { strongSelf.controllerNode.updatePeers(account: strongSelf.currentAccount, switchableAccounts: strongSelf.switchableAccounts, peers: next.0, accountPeer: next.1, defaultAction: strongSelf.defaultAction) if animateIn { strongSelf.readyDisposable.set((strongSelf.controllerNode.ready.get() |> filter({ $0 }) |> take(1) |> deliverOnMainQueue).start(next: { [weak self] _ in guard let strongSelf = self else { return } strongSelf.controllerNode.animateIn() })) } } })) } }