From a753d71cd7c4e16aec94e23064f8f4c04f58a4e9 Mon Sep 17 00:00:00 2001 From: Ali <> Date: Fri, 13 Oct 2023 15:24:53 +0400 Subject: [PATCH] [WIP] Quotes --- .../Sources/ChatController.swift | 113 +++- .../Sources/AttachmentPanel.swift | 1 + .../Sources/ChatListSearchListPaneNode.swift | 2 +- .../ChatPanelInterfaceInteraction.swift | 4 + submodules/ContextUI/BUILD | 3 + .../ContextUI/Sources/ContextController.swift | 595 +++++++---------- .../ContextControllerActionsStackNode.swift | 23 +- ...tControllerExtractedPresentationNode.swift | 183 +++++- .../Sources/ContextSourceContainer.swift | 621 ++++++++++++++++++ .../Source/ContextMenuController.swift | 13 +- .../Display/Source/ContextMenuNode.swift | 2 +- ...teractiveTransitionGestureRecognizer.swift | 2 +- .../Display/Source/PresentationContext.swift | 2 +- submodules/Display/Source/TextNode.swift | 11 +- .../Sources/InstantPageLayout.swift | 2 +- .../Sources/MediaBoxFileContextV2Impl.swift | 4 + .../Postbox/Sources/PostboxLogging.swift | 8 +- .../Postbox/Sources/SqliteValueBox.swift | 11 + .../AccountManager/AccountManagerImpl.swift | 11 + .../ApiUtils/TelegramMediaWebpage.swift | 2 +- .../SyncCore_TelegramMediaWebpage.swift | 83 ++- .../TelegramCore/Sources/Utils/Log.swift | 10 + .../TelegramCore/Sources/WebpagePreview.swift | 2 +- .../StringForMessageTimestampStatus.swift | 2 +- .../ChatMessageTextBubbleContentNode.swift | 60 +- .../Sources/PeerSelectionControllerNode.swift | 5 +- .../Chat/ChatMessageActionOptions.swift | 384 +++++++++-- .../TelegramUI/Sources/ChatController.swift | 128 ++-- .../Sources/ChatControllerNode.swift | 186 +++++- .../ChatInterfaceStateAccessoryPanels.swift | 3 + .../ChatMessageAttachedContentNode.swift | 75 ++- .../Sources/ChatMessageBubbleItemNode.swift | 51 +- .../ChatMessageContactBubbleContentNode.swift | 2 +- .../ChatMessageFileBubbleContentNode.swift | 2 +- ...ChatMessageGiveawayBubbleContentNode.swift | 2 +- ...MessageInstantVideoBubbleContentNode.swift | 2 +- .../ChatMessageInteractiveFileNode.swift | 2 +- ...atMessageInteractiveInstantVideoNode.swift | 2 +- .../TelegramUI/Sources/ChatMessageItem.swift | 4 +- .../ChatMessageMapBubbleContentNode.swift | 2 +- .../ChatMessageWebpageBubbleContentNode.swift | 6 + .../Sources/ChatRecentActionsController.swift | 1 + .../Sources/PeerInfo/PeerInfoScreen.swift | 1 + .../WebpagePreviewAccessoryPanelNode.swift | 29 +- .../Sources/GenerateTextEntities.swift | 9 +- .../Sources/TextSelectionNode.swift | 16 +- 46 files changed, 2076 insertions(+), 606 deletions(-) create mode 100644 submodules/ContextUI/Sources/ContextSourceContainer.swift diff --git a/submodules/AccountContext/Sources/ChatController.swift b/submodules/AccountContext/Sources/ChatController.swift index 15c9950b57..2bd309c408 100644 --- a/submodules/AccountContext/Sources/ChatController.swift +++ b/submodules/AccountContext/Sources/ChatController.swift @@ -517,30 +517,50 @@ public enum ChatControllerSubject: Equatable { case id(EngineMessage.Id) case timestamp(Int32) } - - public struct ReplyOptions: Equatable { - public var hasQuote: Bool - - public init(hasQuote: Bool) { - self.hasQuote = hasQuote - } - } public struct ForwardOptions: Equatable { public var hideNames: Bool public var hideCaptions: Bool - public var replyOptions: ReplyOptions? - - public init(hideNames: Bool, hideCaptions: Bool, replyOptions: ReplyOptions?) { + public init(hideNames: Bool, hideCaptions: Bool) { self.hideNames = hideNames self.hideCaptions = hideCaptions - self.replyOptions = replyOptions } } - public struct MessageOptionsInfo: Equatable { - public struct ReplyQuote: Equatable { + public struct LinkOptions: Equatable { + public var messageText: String + public var messageEntities: [MessageTextEntity] + public var replyMessageId: EngineMessage.Id? + public var replyQuote: String? + public var url: String + public var webpage: TelegramMediaWebpage + public var linkBelowText: Bool + public var largeMedia: Bool + + public init( + messageText: String, + messageEntities: [MessageTextEntity], + replyMessageId: EngineMessage.Id?, + replyQuote: String?, + url: String, + webpage: TelegramMediaWebpage, + linkBelowText: Bool, + largeMedia: Bool + ) { + self.messageText = messageText + self.messageEntities = messageEntities + self.replyMessageId = replyMessageId + self.replyQuote = replyQuote + self.url = url + self.webpage = webpage + self.linkBelowText = linkBelowText + self.largeMedia = largeMedia + } + } + + public enum MessageOptionsInfo: Equatable { + public struct Quote: Equatable { public let messageId: EngineMessage.Id public let text: String @@ -550,16 +570,61 @@ public enum ChatControllerSubject: Equatable { } } - public enum Kind: Equatable { - case forward - case reply(initialQuote: ReplyQuote?) + public struct SelectionState: Equatable { + public var quote: Quote? + + public init(quote: Quote?) { + self.quote = quote + } } - public let kind: Kind - - public init(kind: Kind) { - self.kind = kind + public struct Reply: Equatable { + public var quote: Quote? + public var selectionState: Promise + + public init(quote: Quote?, selectionState: Promise) { + self.quote = quote + self.selectionState = selectionState + } + + public static func ==(lhs: Reply, rhs: Reply) -> Bool { + if lhs.quote != rhs.quote { + return false + } + if lhs.selectionState !== rhs.selectionState { + return false + } + return true + } } + + public struct Forward: Equatable { + public var options: Signal + + public init(options: Signal) { + self.options = options + } + + public static func ==(lhs: Forward, rhs: Forward) -> Bool { + return true + } + } + + public struct Link: Equatable { + public var options: Signal + + public init(options: Signal) { + self.options = options + } + + public static func ==(lhs: Link, rhs: Link) -> Bool { + return true + } + } + + case reply(Reply) + case forward(Forward) + case link(Link) } public struct MessageHighlight: Equatable { @@ -573,7 +638,7 @@ public enum ChatControllerSubject: Equatable { case message(id: MessageSubject, highlight: MessageHighlight?, timecode: Double?) case scheduledMessages case pinnedMessages(id: EngineMessage.Id?) - case messageOptions(peerIds: [EnginePeer.Id], ids: [EngineMessage.Id], info: MessageOptionsInfo, options: Signal) + case messageOptions(peerIds: [EnginePeer.Id], ids: [EngineMessage.Id], info: MessageOptionsInfo) public static func ==(lhs: ChatControllerSubject, rhs: ChatControllerSubject) -> Bool { switch lhs { @@ -595,8 +660,8 @@ public enum ChatControllerSubject: Equatable { } else { return false } - case let .messageOptions(lhsPeerIds, lhsIds, lhsInfo, _): - if case let .messageOptions(rhsPeerIds, rhsIds, rhsInfo, _) = rhs, lhsPeerIds == rhsPeerIds, lhsIds == rhsIds, lhsInfo == rhsInfo { + case let .messageOptions(lhsPeerIds, lhsIds, lhsInfo): + if case let .messageOptions(rhsPeerIds, rhsIds, rhsInfo) = rhs, lhsPeerIds == rhsPeerIds, lhsIds == rhsIds, lhsInfo == rhsInfo { return true } else { return false diff --git a/submodules/AttachmentUI/Sources/AttachmentPanel.swift b/submodules/AttachmentUI/Sources/AttachmentPanel.swift index d685fea9aa..3c87658c13 100644 --- a/submodules/AttachmentUI/Sources/AttachmentPanel.swift +++ b/submodules/AttachmentUI/Sources/AttachmentPanel.swift @@ -779,6 +779,7 @@ final class AttachmentPanel: ASDisplayNode, UIScrollViewDelegate { } }, presentForwardOptions: { _ in }, presentReplyOptions: { _ in + }, presentLinkOptions: { _ in }, shareSelectedMessages: { }, updateTextInputStateAndMode: { [weak self] f in if let strongSelf = self { diff --git a/submodules/ChatListUI/Sources/ChatListSearchListPaneNode.swift b/submodules/ChatListUI/Sources/ChatListSearchListPaneNode.swift index 4d8b8c8ec6..d1f433827e 100644 --- a/submodules/ChatListUI/Sources/ChatListSearchListPaneNode.swift +++ b/submodules/ChatListUI/Sources/ChatListSearchListPaneNode.swift @@ -3603,7 +3603,7 @@ public final class ChatListSearchShimmerNode: ASDisplayNode { return nil case .links: var media: [EngineMedia] = [] - media.append(.webpage(TelegramMediaWebpage(webpageId: EngineMedia.Id(namespace: 0, id: 0), content: .Loaded(TelegramMediaWebpageLoadedContent(url: "https://telegram.org", displayUrl: "https://telegram.org", hash: 0, type: nil, websiteName: "Telegram", title: "Telegram Telegram", text: "Telegram", embedUrl: nil, embedType: nil, embedSize: nil, duration: nil, author: nil, image: nil, file: nil, story: nil, attributes: [], instantPage: nil))))) + media.append(.webpage(TelegramMediaWebpage(webpageId: EngineMedia.Id(namespace: 0, id: 0), content: .Loaded(TelegramMediaWebpageLoadedContent(url: "https://telegram.org", displayUrl: "https://telegram.org", hash: 0, type: nil, websiteName: "Telegram", title: "Telegram Telegram", text: "Telegram", embedUrl: nil, embedType: nil, embedSize: nil, duration: nil, author: nil, image: nil, file: nil, story: nil, attributes: [], instantPage: nil, displayOptions: .default))))) let message = EngineMessage( stableId: 0, stableVersion: 0, diff --git a/submodules/ChatPresentationInterfaceState/Sources/ChatPanelInterfaceInteraction.swift b/submodules/ChatPresentationInterfaceState/Sources/ChatPanelInterfaceInteraction.swift index eb3c73a555..689e5d8c06 100644 --- a/submodules/ChatPresentationInterfaceState/Sources/ChatPanelInterfaceInteraction.swift +++ b/submodules/ChatPresentationInterfaceState/Sources/ChatPanelInterfaceInteraction.swift @@ -81,6 +81,7 @@ public final class ChatPanelInterfaceInteraction { public let updateForwardOptionsState: ((ChatInterfaceForwardOptionsState) -> ChatInterfaceForwardOptionsState) -> Void public let presentForwardOptions: (ASDisplayNode) -> Void public let presentReplyOptions: (ASDisplayNode) -> Void + public let presentLinkOptions: (ASDisplayNode) -> Void public let shareSelectedMessages: () -> Void public let updateTextInputStateAndMode: (@escaping (ChatTextInputState, ChatInputMode) -> (ChatTextInputState, ChatInputMode)) -> Void public let updateInputModeAndDismissedButtonKeyboardMessageId: ((ChatPresentationInterfaceState) -> (ChatInputMode, MessageId?)) -> Void @@ -186,6 +187,7 @@ public final class ChatPanelInterfaceInteraction { updateForwardOptionsState: @escaping ((ChatInterfaceForwardOptionsState) -> ChatInterfaceForwardOptionsState) -> Void, presentForwardOptions: @escaping (ASDisplayNode) -> Void, presentReplyOptions: @escaping (ASDisplayNode) -> Void, + presentLinkOptions: @escaping (ASDisplayNode) -> Void, shareSelectedMessages: @escaping () -> Void, updateTextInputStateAndMode: @escaping ((ChatTextInputState, ChatInputMode) -> (ChatTextInputState, ChatInputMode)) -> Void, updateInputModeAndDismissedButtonKeyboardMessageId: @escaping ((ChatPresentationInterfaceState) -> (ChatInputMode, MessageId?)) -> Void, @@ -290,6 +292,7 @@ public final class ChatPanelInterfaceInteraction { self.updateForwardOptionsState = updateForwardOptionsState self.presentForwardOptions = presentForwardOptions self.presentReplyOptions = presentReplyOptions + self.presentLinkOptions = presentLinkOptions self.shareSelectedMessages = shareSelectedMessages self.updateTextInputStateAndMode = updateTextInputStateAndMode self.updateInputModeAndDismissedButtonKeyboardMessageId = updateInputModeAndDismissedButtonKeyboardMessageId @@ -402,6 +405,7 @@ public final class ChatPanelInterfaceInteraction { }, updateForwardOptionsState: { _ in }, presentForwardOptions: { _ in }, presentReplyOptions: { _ in + }, presentLinkOptions: { _ in }, shareSelectedMessages: { }, updateTextInputStateAndMode: updateTextInputStateAndMode, updateInputModeAndDismissedButtonKeyboardMessageId: updateInputModeAndDismissedButtonKeyboardMessageId, openStickers: { }, editMessage: { diff --git a/submodules/ContextUI/BUILD b/submodules/ContextUI/BUILD index 6d9b1e33e8..2fa56628e2 100644 --- a/submodules/ContextUI/BUILD +++ b/submodules/ContextUI/BUILD @@ -25,6 +25,9 @@ swift_library( "//submodules/TelegramUI/Components/EntityKeyboard:EntityKeyboard", "//submodules/UndoUI:UndoUI", "//submodules/AnimationUI:AnimationUI", + "//submodules/ComponentFlow", + "//submodules/Components/ComponentDisplayAdapters", + "//submodules/TelegramUI/Components/TabSelectorComponent", ], visibility = [ "//visibility:public", diff --git a/submodules/ContextUI/Sources/ContextController.swift b/submodules/ContextUI/Sources/ContextController.swift index d6f624b30a..239822647b 100644 --- a/submodules/ContextUI/Sources/ContextController.swift +++ b/submodules/ContextUI/Sources/ContextController.swift @@ -232,14 +232,19 @@ func convertFrame(_ frame: CGRect, from fromView: UIView, to toView: UIView) -> return targetWindowFrame } -private final class ContextControllerNode: ViewControllerTracingNode, UIScrollViewDelegate { +final class ContextControllerNode: ViewControllerTracingNode, UIScrollViewDelegate { + private weak var controller: ContextController? private var presentationData: PresentationData - private let source: ContextContentSource - private var items: Signal - private let beginDismiss: (ContextMenuActionResult) -> Void + + private let configuration: ContextController.Configuration + + private let legacySource: ContextContentSource + private var legacyItems: Signal + + let beginDismiss: (ContextMenuActionResult) -> Void private let beganAnimatingOut: () -> Void private let attemptTransitionControllerIntoNavigation: () -> Void - fileprivate var dismissedForCancel: (() -> Void)? + var dismissedForCancel: (() -> Void)? private let getController: () -> ContextControllerProtocol? private weak var gesture: ContextGesture? @@ -260,8 +265,7 @@ private final class ContextControllerNode: ViewControllerTracingNode, UIScrollVi private let dismissNode: ASDisplayNode private let dismissAccessibilityArea: AccessibilityAreaNode - private var presentationNode: ContextControllerPresentationNode? - private var currentPresentationStateTransition: ContextControllerPresentationNodeStateTransition? + private var sourceContainer: ContextSourceContainer? private let clippingNode: ASDisplayNode private let scrollNode: ASScrollNode @@ -288,32 +292,33 @@ private final class ContextControllerNode: ViewControllerTracingNode, UIScrollVi private let blurBackground: Bool var overlayWantsToBeBelowKeyboard: Bool { - if let presentationNode = self.presentationNode { - return presentationNode.wantsDisplayBelowKeyboard() - } else { + guard let sourceContainer = self.sourceContainer else { return false } + return sourceContainer.overlayWantsToBeBelowKeyboard } init( controller: ContextController, presentationData: PresentationData, - source: ContextContentSource, - items: Signal, + configuration: ContextController.Configuration, beginDismiss: @escaping (ContextMenuActionResult) -> Void, recognizer: TapLongTapOrDoubleTapGestureRecognizer?, gesture: ContextGesture?, beganAnimatingOut: @escaping () -> Void, attemptTransitionControllerIntoNavigation: @escaping () -> Void ) { + self.controller = controller self.presentationData = presentationData - self.source = source - self.items = items + self.configuration = configuration self.beginDismiss = beginDismiss self.beganAnimatingOut = beganAnimatingOut self.attemptTransitionControllerIntoNavigation = attemptTransitionControllerIntoNavigation self.gesture = gesture + self.legacySource = configuration.sources[0].source + self.legacyItems = configuration.sources[0].items + self.getController = { [weak controller] in return controller } @@ -359,10 +364,12 @@ private final class ContextControllerNode: ViewControllerTracingNode, UIScrollVi var updateLayout: (() -> Void)? var blurBackground = true - if case .reference = source { - blurBackground = false - } else if case let .extracted(extractedSource) = source, !extractedSource.blurBackground { - blurBackground = false + if let mainSource = configuration.sources.first(where: { $0.id == configuration.initialId }) { + if case .reference = mainSource.source { + blurBackground = false + } else if case let .extracted(extractedSource) = mainSource.source, !extractedSource.blurBackground { + blurBackground = false + } } self.blurBackground = blurBackground @@ -423,9 +430,9 @@ private final class ContextControllerNode: ViewControllerTracingNode, UIScrollVi } } if strongSelf.didMoveFromInitialGesturePoint { - if let presentationNode = strongSelf.presentationNode { - let presentationPoint = strongSelf.view.convert(localPoint, to: presentationNode.view) - presentationNode.highlightGestureMoved(location: presentationPoint, hover: false) + if let sourceContainer = strongSelf.sourceContainer { + let presentationPoint = strongSelf.view.convert(localPoint, to: sourceContainer.view) + sourceContainer.highlightGestureMoved(location: presentationPoint, hover: false) } else { let actionPoint = strongSelf.view.convert(localPoint, to: strongSelf.actionsContainerNode.view) let actionNode = strongSelf.actionsContainerNode.actionNode(at: actionPoint) @@ -447,8 +454,8 @@ private final class ContextControllerNode: ViewControllerTracingNode, UIScrollVi } recognizer.externalUpdated = nil if strongSelf.didMoveFromInitialGesturePoint { - if let presentationNode = strongSelf.presentationNode { - presentationNode.highlightGestureFinished(performAction: viewAndPoint != nil) + if let sourceContainer = strongSelf.sourceContainer { + sourceContainer.highlightGestureFinished(performAction: viewAndPoint != nil) } else { if let (_, _) = viewAndPoint { if let highlightedActionNode = strongSelf.highlightedActionNode { @@ -485,9 +492,9 @@ private final class ContextControllerNode: ViewControllerTracingNode, UIScrollVi } } if strongSelf.didMoveFromInitialGesturePoint { - if let presentationNode = strongSelf.presentationNode { - let presentationPoint = strongSelf.view.convert(localPoint, to: presentationNode.view) - presentationNode.highlightGestureMoved(location: presentationPoint, hover: false) + if let sourceContainer = strongSelf.sourceContainer { + let presentationPoint = strongSelf.view.convert(localPoint, to: sourceContainer.view) + sourceContainer.highlightGestureMoved(location: presentationPoint, hover: false) } else { let actionPoint = strongSelf.view.convert(localPoint, to: strongSelf.actionsContainerNode.view) var actionNode = strongSelf.actionsContainerNode.actionNode(at: actionPoint) @@ -513,8 +520,8 @@ private final class ContextControllerNode: ViewControllerTracingNode, UIScrollVi } gesture.externalUpdated = nil if strongSelf.didMoveFromInitialGesturePoint { - if let presentationNode = strongSelf.presentationNode { - presentationNode.highlightGestureFinished(performAction: viewAndPoint != nil) + if let sourceContainer = strongSelf.sourceContainer { + sourceContainer.highlightGestureFinished(performAction: viewAndPoint != nil) } else { if let (_, _) = viewAndPoint { if let highlightedActionNode = strongSelf.highlightedActionNode { @@ -532,22 +539,8 @@ private final class ContextControllerNode: ViewControllerTracingNode, UIScrollVi } } - switch source { - case .location, .reference, .extracted: - self.contentReady.set(.single(true)) - case let .controller(source): - self.contentReady.set(source.controller.ready.get()) - //TODO: - //self.contentReady.set(.single(true)) - } - self.initializeContent() - self.itemsDisposable.set((items - |> deliverOnMainQueue).start(next: { [weak self] items in - self?.setItems(items: items, minHeight: nil, previousActionsTransition: .scale) - })) - self.dismissAccessibilityArea.activate = { [weak self] in self?.dimNodeTapped() return true @@ -593,9 +586,9 @@ private final class ContextControllerNode: ViewControllerTracingNode, UIScrollVi switch gestureRecognizer.state { case .changed: - if let presentationNode = self.presentationNode { - let presentationPoint = self.view.convert(localPoint, to: presentationNode.view) - presentationNode.highlightGestureMoved(location: presentationPoint, hover: true) + if let sourceContainer = self.sourceContainer { + let presentationPoint = self.view.convert(localPoint, to: sourceContainer.view) + sourceContainer.highlightGestureMoved(location: presentationPoint, hover: true) } else { let actionPoint = self.view.convert(localPoint, to: self.actionsContainerNode.view) let actionNode = self.actionsContainerNode.actionNode(at: actionPoint) @@ -608,8 +601,8 @@ private final class ContextControllerNode: ViewControllerTracingNode, UIScrollVi } } case .ended, .cancelled: - if let presentationNode = self.presentationNode { - presentationNode.highlightGestureMoved(location: CGPoint(x: -1, y: -1), hover: true) + if let sourceContainer = self.sourceContainer { + sourceContainer.highlightGestureMoved(location: CGPoint(x: -1, y: -1), hover: true) } else { if let highlightedActionNode = self.highlightedActionNode { self.highlightedActionNode = nil @@ -622,230 +615,86 @@ private final class ContextControllerNode: ViewControllerTracingNode, UIScrollVi } private func initializeContent() { - switch self.source { - case let .location(source): - let presentationNode = ContextControllerExtractedPresentationNode( - getController: { [weak self] in - return self?.getController() - }, - requestUpdate: { [weak self] transition in - guard let strongSelf = self else { - return + if self.configuration.sources.count == 1 { + switch self.configuration.sources[0].source { + case .location: + break + case let .reference(source): + if let controller = self.getController() as? ContextController, controller.workaroundUseLegacyImplementation { + self.contentReady.set(.single(true)) + + let transitionInfo = source.transitionInfo() + if let transitionInfo = transitionInfo { + let referenceView = transitionInfo.referenceView + self.contentContainerNode.contentNode = .reference(view: referenceView) + self.contentAreaInScreenSpace = transitionInfo.contentAreaInScreenSpace + self.customPosition = transitionInfo.customPosition + var projectedFrame = convertFrame(referenceView.bounds, from: referenceView, to: self.view) + projectedFrame.origin.x += transitionInfo.insets.left + projectedFrame.size.width -= transitionInfo.insets.left + transitionInfo.insets.right + projectedFrame.origin.y += transitionInfo.insets.top + projectedFrame.size.width -= transitionInfo.insets.top + transitionInfo.insets.bottom + self.originalProjectedContentViewFrame = (projectedFrame, projectedFrame) } - if let validLayout = strongSelf.validLayout { - strongSelf.updateLayout( - layout: validLayout, - transition: transition, - previousActionsContainerNode: nil - ) - } - }, - requestUpdateOverlayWantsToBeBelowKeyboard: { [weak self] transition in - guard let strongSelf = self else { - return - } - if let controller = strongSelf.getController() { - controller.overlayWantsToBeBelowKeyboardUpdated(transition: transition) - } - }, - requestDismiss: { [weak self] result in - guard let strongSelf = self else { - return - } - strongSelf.dismissedForCancel?() - strongSelf.beginDismiss(result) - }, - requestAnimateOut: { [weak self] result, completion in - guard let strongSelf = self else { - return - } - strongSelf.animateOut(result: result, completion: completion) - }, - source: .location(source) - ) - self.presentationNode = presentationNode - self.addSubnode(presentationNode) - case let .reference(source): - if let controller = self.getController() as? ContextController, controller.workaroundUseLegacyImplementation { - let transitionInfo = source.transitionInfo() - if let transitionInfo = transitionInfo { - let referenceView = transitionInfo.referenceView - self.contentContainerNode.contentNode = .reference(view: referenceView) - self.contentAreaInScreenSpace = transitionInfo.contentAreaInScreenSpace - self.customPosition = transitionInfo.customPosition - var projectedFrame = convertFrame(referenceView.bounds, from: referenceView, to: self.view) - projectedFrame.origin.x += transitionInfo.insets.left - projectedFrame.size.width -= transitionInfo.insets.left + transitionInfo.insets.right - projectedFrame.origin.y += transitionInfo.insets.top - projectedFrame.size.width -= transitionInfo.insets.top + transitionInfo.insets.bottom - self.originalProjectedContentViewFrame = (projectedFrame, projectedFrame) - } - } else { - let presentationNode = ContextControllerExtractedPresentationNode( - getController: { [weak self] in - return self?.getController() - }, - requestUpdate: { [weak self] transition in - guard let strongSelf = self else { - return - } - if let validLayout = strongSelf.validLayout { - strongSelf.updateLayout( - layout: validLayout, - transition: transition, - previousActionsContainerNode: nil - ) - } - }, - requestUpdateOverlayWantsToBeBelowKeyboard: { [weak self] transition in - guard let strongSelf = self else { - return - } - if let controller = strongSelf.getController() { - controller.overlayWantsToBeBelowKeyboardUpdated(transition: transition) - } - }, - requestDismiss: { [weak self] result in - guard let strongSelf = self else { - return - } - strongSelf.dismissedForCancel?() - strongSelf.beginDismiss(result) - }, - requestAnimateOut: { [weak self] result, completion in - guard let strongSelf = self else { - return - } - strongSelf.animateOut(result: result, completion: completion) - }, - source: .reference(source) - ) - self.presentationNode = presentationNode - self.addSubnode(presentationNode) - } - case let .extracted(source): - let presentationNode = ContextControllerExtractedPresentationNode( - getController: { [weak self] in - return self?.getController() - }, - requestUpdate: { [weak self] transition in - guard let strongSelf = self else { - return - } - if let validLayout = strongSelf.validLayout { - strongSelf.updateLayout( - layout: validLayout, - transition: transition, - previousActionsContainerNode: nil - ) - } - }, - requestUpdateOverlayWantsToBeBelowKeyboard: { [weak self] transition in - guard let strongSelf = self else { - return - } - if let controller = strongSelf.getController() { - controller.overlayWantsToBeBelowKeyboardUpdated(transition: transition) - } - }, - requestDismiss: { [weak self] result in - guard let strongSelf = self else { - return - } - strongSelf.dismissedForCancel?() - strongSelf.beginDismiss(result) - }, - requestAnimateOut: { [weak self] result, completion in - guard let strongSelf = self else { - return - } - strongSelf.animateOut(result: result, completion: completion) - }, - source: .extracted(source) - ) - self.presentationNode = presentationNode - self.addSubnode(presentationNode) - case let .controller(source): - if "".isEmpty { - let transitionInfo = source.transitionInfo() - if let transitionInfo = transitionInfo, let (sourceView, sourceNodeRect) = transitionInfo.sourceNode() { - let contentParentNode = ContextControllerContentNode(sourceView: sourceView, controller: source.controller, tapped: { [weak self] in - self?.attemptTransitionControllerIntoNavigation() - }) - self.contentContainerNode.contentNode = .controller(contentParentNode) - self.scrollNode.addSubnode(self.contentContainerNode) - self.contentContainerNode.clipsToBounds = true - self.contentContainerNode.cornerRadius = 14.0 - self.contentContainerNode.addSubnode(contentParentNode) + self.itemsDisposable.set((self.configuration.sources[0].items + |> deliverOnMainQueue).start(next: { [weak self] items in + self?.setItems(items: items, minHeight: nil, previousActionsTransition: .scale) + })) - let projectedFrame = convertFrame(sourceNodeRect, from: sourceView, to: self.view) - self.originalProjectedContentViewFrame = (projectedFrame, projectedFrame) + return + } + case .extracted: + break + case let .controller(source): + if let controller = self.getController() as? ContextController, controller.workaroundUseLegacyImplementation { + self.contentReady.set(source.controller.ready.get()) + + let transitionInfo = source.transitionInfo() + if let transitionInfo = transitionInfo, let (sourceView, sourceNodeRect) = transitionInfo.sourceNode() { + let contentParentNode = ContextControllerContentNode(sourceView: sourceView, controller: source.controller, tapped: { [weak self] in + self?.attemptTransitionControllerIntoNavigation() + }) + self.contentContainerNode.contentNode = .controller(contentParentNode) + self.scrollNode.addSubnode(self.contentContainerNode) + self.contentContainerNode.clipsToBounds = true + self.contentContainerNode.cornerRadius = 14.0 + self.contentContainerNode.addSubnode(contentParentNode) + + let projectedFrame = convertFrame(sourceNodeRect, from: sourceView, to: self.view) + self.originalProjectedContentViewFrame = (projectedFrame, projectedFrame) + } + + self.itemsDisposable.set((self.configuration.sources[0].items + |> deliverOnMainQueue).start(next: { [weak self] items in + self?.setItems(items: items, minHeight: nil, previousActionsTransition: .scale) + })) + + return } - } else { - let presentationNode = ContextControllerExtractedPresentationNode( - getController: { [weak self] in - return self?.getController() - }, - requestUpdate: { [weak self] transition in - guard let strongSelf = self else { - return - } - if let validLayout = strongSelf.validLayout { - strongSelf.updateLayout( - layout: validLayout, - transition: transition, - previousActionsContainerNode: nil - ) - } - }, - requestUpdateOverlayWantsToBeBelowKeyboard: { [weak self] transition in - guard let strongSelf = self else { - return - } - if let controller = strongSelf.getController() { - controller.overlayWantsToBeBelowKeyboardUpdated(transition: transition) - } - }, - requestDismiss: { [weak self] result in - guard let strongSelf = self else { - return - } - strongSelf.dismissedForCancel?() - strongSelf.beginDismiss(result) - }, - requestAnimateOut: { [weak self] result, completion in - guard let strongSelf = self else { - return - } - strongSelf.animateOut(result: result, completion: completion) - }, - source: .controller(source) - ) - self.presentationNode = presentationNode - self.addSubnode(presentationNode) } } + + if let controller = self.controller { + let sourceContainer = ContextSourceContainer(controller: controller, configuration: self.configuration) + self.contentReady.set(sourceContainer.ready.get()) + self.itemsReady.set(.single(true)) + self.sourceContainer = sourceContainer + self.addSubnode(sourceContainer) + } } func animateIn() { self.gesture?.endPressedAppearance() self.hapticFeedback.impact() - if let _ = self.presentationNode { + if let sourceContainer = self.sourceContainer { self.didCompleteAnimationIn = true - self.currentPresentationStateTransition = .animateIn - if let validLayout = self.validLayout { - self.updateLayout( - layout: validLayout, - transition: .animated(duration: 0.5, curve: .spring), - previousActionsContainerNode: nil - ) - } + sourceContainer.animateIn() return } - switch self.source { + switch self.legacySource { case .location, .reference: break case .extracted: @@ -935,7 +784,7 @@ private final class ContextControllerNode: ViewControllerTracingNode, UIScrollVi case let .extracted(extracted, keepInPlace): let springDuration: Double = 0.42 * animationDurationFactor var springDamping: CGFloat = 104.0 - if case let .extracted(source) = self.source, source.centerVertically { + if case let .extracted(source) = self.legacySource, source.centerVertically { springDamping = 124.0 } @@ -949,7 +798,7 @@ private final class ContextControllerNode: ViewControllerTracingNode, UIScrollVi var actionsDuration = springDuration var actionsOffset: CGFloat = 0.0 var contentDuration = springDuration - if case let .extracted(source) = self.source, source.centerVertically { + if case let .extracted(source) = self.legacySource, source.centerVertically { actionsOffset = -(originalProjectedContentViewFrame.1.height - originalProjectedContentViewFrame.0.height) * 0.57 actionsDuration *= 1.0 contentDuration *= 0.9 @@ -988,7 +837,7 @@ private final class ContextControllerNode: ViewControllerTracingNode, UIScrollVi self.contentContainerNode.layer.animateSpring(from: min(localSourceFrame.width / self.contentContainerNode.frame.width, localSourceFrame.height / self.contentContainerNode.frame.height) as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: springDuration, initialVelocity: 0.0, damping: springDamping) - switch self.source { + switch self.legacySource { case let .controller(controller): controller.animatedIn() default: @@ -1024,28 +873,8 @@ private final class ContextControllerNode: ViewControllerTracingNode, UIScrollVi self.beganAnimatingOut() - if let _ = self.presentationNode { - self.currentPresentationStateTransition = .animateOut(result: initialResult, completion: completion) - if let validLayout = self.validLayout { - if case let .custom(transition) = initialResult { - self.delayLayoutUpdate = true - Queue.mainQueue().after(0.1) { - self.delayLayoutUpdate = false - self.updateLayout( - layout: validLayout, - transition: transition, - previousActionsContainerNode: nil - ) - self.isAnimatingOut = true - } - } else { - self.updateLayout( - layout: validLayout, - transition: .animated(duration: 0.35, curve: .easeInOut), - previousActionsContainerNode: nil - ) - } - } + if let sourceContainer = self.sourceContainer { + sourceContainer.animateOut(result: initialResult, completion: completion) return } @@ -1054,7 +883,7 @@ private final class ContextControllerNode: ViewControllerTracingNode, UIScrollVi var result = initialResult - switch self.source { + switch self.legacySource { case let .location(source): let transitionInfo = source.transitionInfo() if transitionInfo == nil { @@ -1296,7 +1125,7 @@ private final class ContextControllerNode: ViewControllerTracingNode, UIScrollVi } var actionsOffset: CGFloat = 0.0 - if case let .extracted(source) = self.source, source.centerVertically { + if case let .extracted(source) = self.legacySource, source.centerVertically { actionsOffset = -localSourceFrame.width * 0.6 } @@ -1469,24 +1298,23 @@ private final class ContextControllerNode: ViewControllerTracingNode, UIScrollVi } func addRelativeContentOffset(_ offset: CGPoint, transition: ContainedViewLayoutTransition) { - if let presentationNode = self.presentationNode { - presentationNode.addRelativeContentOffset(offset, transition: transition) + if let sourceContainer = self.sourceContainer { + sourceContainer.addRelativeContentOffset(offset, transition: transition) } } func cancelReactionAnimation() { - if let presentationNode = self.presentationNode { - presentationNode.cancelReactionAnimation() + if let sourceContainer = self.sourceContainer { + sourceContainer.cancelReactionAnimation() } } func animateOutToReaction(value: MessageReaction.Reaction, targetView: UIView, hideNode: Bool, animateTargetContainer: UIView?, addStandaloneReactionAnimation: ((StandaloneReactionAnimation) -> Void)?, reducedCurve: Bool, completion: @escaping () -> Void) { - if let presentationNode = self.presentationNode { - presentationNode.animateOutToReaction(value: value, targetView: targetView, hideNode: hideNode, animateTargetContainer: animateTargetContainer, addStandaloneReactionAnimation: addStandaloneReactionAnimation, reducedCurve: reducedCurve, completion: completion) + if let sourceContainer = self.sourceContainer { + sourceContainer.animateOutToReaction(value: value, targetView: targetView, hideNode: hideNode, animateTargetContainer: animateTargetContainer, addStandaloneReactionAnimation: addStandaloneReactionAnimation, reducedCurve: reducedCurve, completion: completion) } } - func getActionsMinHeight() -> ContextController.ActionsHeight? { if !self.actionsContainerNode.bounds.height.isZero { return ContextController.ActionsHeight( @@ -1499,20 +1327,24 @@ private final class ContextControllerNode: ViewControllerTracingNode, UIScrollVi } func setItemsSignal(items: Signal, minHeight: ContextController.ActionsHeight?, previousActionsTransition: ContextController.PreviousActionsTransition) { - self.items = items - self.itemsDisposable.set((items - |> deliverOnMainQueue).start(next: { [weak self] items in - guard let strongSelf = self else { - return - } - strongSelf.setItems(items: items, minHeight: minHeight, previousActionsTransition: previousActionsTransition) - })) + if let sourceContainer = self.sourceContainer { + sourceContainer.setItems(items: items, animated: false) + } else { + self.legacyItems = items + self.itemsDisposable.set((items + |> deliverOnMainQueue).start(next: { [weak self] items in + guard let strongSelf = self else { + return + } + strongSelf.setItems(items: items, minHeight: minHeight, previousActionsTransition: previousActionsTransition) + })) + } } private func setItems(items: ContextController.Items, minHeight: ContextController.ActionsHeight?, previousActionsTransition: ContextController.PreviousActionsTransition) { - if let presentationNode = self.presentationNode { + if let sourceContainer = self.sourceContainer { let disableAnimations = self.getController()?.immediateItemsTransitionAnimation == true - presentationNode.replaceItems(items: items, animated: self.didCompleteAnimationIn && !disableAnimations) + sourceContainer.setItems(items: .single(items), animated: !disableAnimations) if !self.didSetItemsReady { self.didSetItemsReady = true @@ -1554,18 +1386,14 @@ private final class ContextControllerNode: ViewControllerTracingNode, UIScrollVi } func pushItems(items: Signal) { - self.itemsDisposable.set((items - |> deliverOnMainQueue).start(next: { [weak self] items in - guard let strongSelf = self, let presentationNode = strongSelf.presentationNode else { - return - } - presentationNode.pushItems(items: items) - })) + if let sourceContainer = self.sourceContainer { + sourceContainer.pushItems(items: items) + } } func popItems() { - if let presentationNode = self.presentationNode { - presentationNode.popItems() + if let sourceContainer = self.sourceContainer { + sourceContainer.popItems() } } @@ -1593,16 +1421,12 @@ private final class ContextControllerNode: ViewControllerTracingNode, UIScrollVi self.validLayout = layout - let presentationStateTransition = self.currentPresentationStateTransition - self.currentPresentationStateTransition = .none - - if let presentationNode = self.presentationNode { - transition.updateFrame(node: presentationNode, frame: CGRect(origin: CGPoint(), size: layout.size)) - presentationNode.update( + if let sourceContainer = self.sourceContainer { + transition.updateFrame(node: sourceContainer, frame: CGRect(origin: CGPoint(), size: layout.size)) + sourceContainer.update( presentationData: self.presentationData, layout: layout, - transition: transition, - stateTransition: presentationStateTransition + transition: transition ) return } @@ -1618,8 +1442,8 @@ private final class ContextControllerNode: ViewControllerTracingNode, UIScrollVi switch layout.metrics.widthClass { case .compact: - if case .reference = self.source { - } else if case let .extracted(extractedSource) = self.source, !extractedSource.blurBackground { + if case .reference = self.legacySource { + } else if case let .extracted(extractedSource) = self.legacySource, !extractedSource.blurBackground { } else if self.effectView.superview == nil { self.view.insertSubview(self.effectView, at: 0) if #available(iOS 10.0, *) { @@ -1634,8 +1458,8 @@ private final class ContextControllerNode: ViewControllerTracingNode, UIScrollVi self.dimNode.isHidden = false self.withoutBlurDimNode.isHidden = true case .regular: - if case .reference = self.source { - } else if case let .extracted(extractedSource) = self.source, !extractedSource.blurBackground { + if case .reference = self.legacySource { + } else if case let .extracted(extractedSource) = self.legacySource, !extractedSource.blurBackground { } else if self.effectView.superview != nil { self.effectView.removeFromSuperview() self.withoutBlurDimNode.alpha = 1.0 @@ -1744,7 +1568,7 @@ private final class ContextControllerNode: ViewControllerTracingNode, UIScrollVi } case let .extracted(contentParentNode, keepInPlace): var centerVertically = false - if case let .extracted(source) = self.source, source.centerVertically { + if case let .extracted(source) = self.legacySource, source.centerVertically { centerVertically = true } let contentActionsSpacing: CGFloat = keepInPlace ? 16.0 : 8.0 @@ -1896,7 +1720,7 @@ private final class ContextControllerNode: ViewControllerTracingNode, UIScrollVi } case let .controller(contentParentNode): var projectedFrame: CGRect = convertFrame(contentParentNode.sourceView.bounds, from: contentParentNode.sourceView, to: self.view) - switch self.source { + switch self.legacySource { case let .controller(source): let transitionInfo = source.transitionInfo() if let (sourceView, sourceRect) = transitionInfo?.sourceNode() { @@ -2127,8 +1951,8 @@ private final class ContextControllerNode: ViewControllerTracingNode, UIScrollVi } } - if let presentationNode = self.presentationNode { - return presentationNode.hitTest(self.view.convert(point, to: presentationNode.view), with: event) + if let sourceContainer = self.sourceContainer { + return sourceContainer.hitTest(self.view.convert(point, to: sourceContainer.view), with: event) } let mappedPoint = self.view.convert(point, to: self.scrollNode.view) @@ -2140,7 +1964,7 @@ private final class ContextControllerNode: ViewControllerTracingNode, UIScrollVi maybePassthrough = passthroughTouchEvent(self.view, point) } case let .extracted(contentParentNode, _): - if case let .extracted(source) = self.source { + if case let .extracted(source) = self.legacySource { if !source.ignoreContentTouches { let contentPoint = self.view.convert(point, to: contentParentNode.contentNode.view) if let result = contentParentNode.contentNode.customHitTest?(contentPoint) { @@ -2156,7 +1980,7 @@ private final class ContextControllerNode: ViewControllerTracingNode, UIScrollVi } case let .controller(controller): var passthrough = false - switch self.source { + switch self.legacySource { case let .controller(controllerSource): passthrough = controllerSource.passthroughTouches default: @@ -2165,9 +1989,6 @@ private final class ContextControllerNode: ViewControllerTracingNode, UIScrollVi if passthrough { let controllerPoint = self.view.convert(point, to: controller.controller.view) if let result = controller.controller.view.hitTest(controllerPoint, with: event) { - #if DEBUG - //return controller.view - #endif return result } } @@ -2198,15 +2019,15 @@ private final class ContextControllerNode: ViewControllerTracingNode, UIScrollVi } fileprivate func performHighlightedAction() { - self.presentationNode?.highlightGestureFinished(performAction: true) + self.sourceContainer?.performHighlightedAction() } fileprivate func decreaseHighlightedIndex() { - self.presentationNode?.decreaseHighlightedIndex() + self.sourceContainer?.decreaseHighlightedIndex() } fileprivate func increaseHighlightedIndex() { - self.presentationNode?.increaseHighlightedIndex() + self.sourceContainer?.increaseHighlightedIndex() } } @@ -2374,6 +2195,30 @@ public protocol ContextControllerItemsContent: AnyObject { } public final class ContextController: ViewController, StandalonePresentableController, ContextControllerProtocol, KeyShortcutResponder { + public final class Source { + public let id: AnyHashable + public let title: String + public let source: ContextContentSource + public let items: Signal + + public init(id: AnyHashable, title: String, source: ContextContentSource, items: Signal) { + self.id = id + self.title = title + self.source = source + self.items = items + } + } + + public final class Configuration { + public let sources: [Source] + public let initialId: AnyHashable + + public init(sources: [Source], initialId: AnyHashable) { + self.sources = sources + self.initialId = initialId + } + } + public struct Items { public enum Content { case list([ContextMenuItem]) @@ -2390,8 +2235,20 @@ public final class ContextController: ViewController, StandalonePresentableContr public var disablePositionLock: Bool public var tip: Tip? public var tipSignal: Signal? + public var dismissed: (() -> Void)? - public init(content: Content, context: AccountContext? = nil, reactionItems: [ReactionContextItem] = [], selectedReactionItems: Set = Set(), animationCache: AnimationCache? = nil, getEmojiContent: ((AnimationCache, MultiAnimationRenderer) -> Signal)? = nil, disablePositionLock: Bool = false, tip: Tip? = nil, tipSignal: Signal? = nil) { + public init( + content: Content, + context: AccountContext? = nil, + reactionItems: [ReactionContextItem] = [], + selectedReactionItems: Set = Set(), + animationCache: AnimationCache? = nil, + getEmojiContent: ((AnimationCache, MultiAnimationRenderer) -> Signal)? = nil, + disablePositionLock: Bool = false, + tip: Tip? = nil, + tipSignal: Signal? = nil, + dismissed: (() -> Void)? = nil + ) { self.content = content self.context = context self.animationCache = animationCache @@ -2401,6 +2258,7 @@ public final class ContextController: ViewController, StandalonePresentableContr self.disablePositionLock = disablePositionLock self.tip = tip self.tipSignal = tipSignal + self.dismissed = dismissed } public init() { @@ -2412,6 +2270,7 @@ public final class ContextController: ViewController, StandalonePresentableContr self.disablePositionLock = false self.tip = nil self.tipSignal = nil + self.dismissed = nil } } @@ -2487,8 +2346,7 @@ public final class ContextController: ViewController, StandalonePresentableContr } private var presentationData: PresentationData - private let source: ContextContentSource - private var items: Signal + private let configuration: ContextController.Configuration private let _ready = Promise() override public var ready: Promise { @@ -2511,7 +2369,7 @@ public final class ContextController: ViewController, StandalonePresentableContr } } - private var controllerNode: ContextControllerNode { + var controllerNode: ContextControllerNode { return self.displayNode as! ContextControllerNode } @@ -2540,17 +2398,41 @@ public final class ContextController: ViewController, StandalonePresentableContr public var getOverlayViews: (() -> [UIView])? - public init(presentationData: PresentationData, source: ContextContentSource, items: Signal, recognizer: TapLongTapOrDoubleTapGestureRecognizer? = nil, gesture: ContextGesture? = nil, workaroundUseLegacyImplementation: Bool = false) { + convenience public init(presentationData: PresentationData, source: ContextContentSource, items: Signal, recognizer: TapLongTapOrDoubleTapGestureRecognizer? = nil, gesture: ContextGesture? = nil, workaroundUseLegacyImplementation: Bool = false) { + self.init( + presentationData: presentationData, + configuration: ContextController.Configuration( + sources: [ContextController.Source( + id: AnyHashable(0 as Int), + title: "", + source: source, + items: items + )], + initialId: AnyHashable(0 as Int) + ), + recognizer: recognizer, + gesture: gesture, + workaroundUseLegacyImplementation: workaroundUseLegacyImplementation + ) + } + + public init( + presentationData: PresentationData, + configuration: ContextController.Configuration, + recognizer: TapLongTapOrDoubleTapGestureRecognizer? = nil, + gesture: ContextGesture? = nil, + workaroundUseLegacyImplementation: Bool = false + ) { self.presentationData = presentationData - self.source = source - self.items = items + self.configuration = configuration self.recognizer = recognizer self.gesture = gesture self.workaroundUseLegacyImplementation = workaroundUseLegacyImplementation super.init(navigationBarPresentationData: nil) - - switch source { + + if let mainSource = configuration.sources.first(where: { $0.id == configuration.initialId }) { + switch mainSource.source { case let .location(locationSource): self.statusBar.statusBarStyle = .Ignore @@ -2592,6 +2474,7 @@ public final class ContextController: ViewController, StandalonePresentableContr }).strict() case .controller: self.statusBar.statusBarStyle = .Hide + } } self.lockOrientation = true @@ -2607,7 +2490,7 @@ public final class ContextController: ViewController, StandalonePresentableContr } override public func loadDisplayNode() { - self.displayNode = ContextControllerNode(controller: self, presentationData: self.presentationData, source: self.source, items: self.items, beginDismiss: { [weak self] result in + self.displayNode = ContextControllerNode(controller: self, presentationData: self.presentationData, configuration: self.configuration, beginDismiss: { [weak self] result in self?.dismiss(result: result, completion: nil) }, recognizer: self.recognizer, gesture: self.gesture, beganAnimatingOut: { [weak self] in guard let strongSelf = self else { @@ -2622,19 +2505,7 @@ public final class ContextController: ViewController, StandalonePresentableContr } return true } - }, attemptTransitionControllerIntoNavigation: { [weak self] in - guard let strongSelf = self else { - return - } - switch strongSelf.source { - /*case let .controller(controller): - if let navigationController = controller.navigationController { - strongSelf.presentingViewController?.dismiss(animated: false, completion: nil) - navigationController.pushViewController(controller.controller, animated: false) - }*/ - default: - break - } + }, attemptTransitionControllerIntoNavigation: { }) self.controllerNode.dismissedForCancel = self.dismissedForCancel self.displayNodeDidLoad() @@ -2685,17 +2556,23 @@ public final class ContextController: ViewController, StandalonePresentableContr } public func setItems(_ items: Signal, minHeight: ContextController.ActionsHeight?) { - self.items = items + //self.items = items + if self.isNodeLoaded { self.immediateItemsTransitionAnimation = false self.controllerNode.setItemsSignal(items: items, minHeight: minHeight, previousActionsTransition: .scale) + } else { + assertionFailure() } } public func setItems(_ items: Signal, minHeight: ContextController.ActionsHeight?, previousActionsTransition: ContextController.PreviousActionsTransition) { - self.items = items + //self.items = items + if self.isNodeLoaded { self.controllerNode.setItemsSignal(items: items, minHeight: minHeight, previousActionsTransition: previousActionsTransition) + } else { + assertionFailure() } } @@ -2742,6 +2619,10 @@ public final class ContextController: ViewController, StandalonePresentableContr self.dismiss(result: .default, completion: completion) } + public func dismissWithCustomTransition(transition: ContainedViewLayoutTransition, completion: (() -> Void)? = nil) { + self.dismiss(result: .custom(transition), completion: nil) + } + public func dismissWithoutContent() { self.dismiss(result: .dismissWithoutContent, completion: nil) } diff --git a/submodules/ContextUI/Sources/ContextControllerActionsStackNode.swift b/submodules/ContextUI/Sources/ContextControllerActionsStackNode.swift index 8491ae7bc8..9e2c34deb7 100644 --- a/submodules/ContextUI/Sources/ContextControllerActionsStackNode.swift +++ b/submodules/ContextUI/Sources/ContextControllerActionsStackNode.swift @@ -44,6 +44,7 @@ public protocol ContextControllerActionsStackItem: AnyObject { var tip: ContextController.Tip? { get } var tipSignal: Signal? { get } var reactionItems: (context: AccountContext, reactionItems: [ReactionContextItem], selectedReactionItems: Set, animationCache: AnimationCache, getEmojiContent: ((AnimationCache, MultiAnimationRenderer) -> Signal)?)? { get } + var dismissed: (() -> Void)? { get } } protocol ContextControllerActionsListItemNode: ASDisplayNode { @@ -836,17 +837,20 @@ final class ContextControllerActionsListStackItem: ContextControllerActionsStack let reactionItems: (context: AccountContext, reactionItems: [ReactionContextItem], selectedReactionItems: Set, animationCache: AnimationCache, getEmojiContent: ((AnimationCache, MultiAnimationRenderer) -> Signal)?)? let tip: ContextController.Tip? let tipSignal: Signal? + let dismissed: (() -> Void)? init( items: [ContextMenuItem], reactionItems: (context: AccountContext, reactionItems: [ReactionContextItem], selectedReactionItems: Set, animationCache: AnimationCache, getEmojiContent: ((AnimationCache, MultiAnimationRenderer) -> Signal)?)?, tip: ContextController.Tip?, - tipSignal: Signal? + tipSignal: Signal?, + dismissed: (() -> Void)? ) { self.items = items self.reactionItems = reactionItems self.tip = tip self.tipSignal = tipSignal + self.dismissed = dismissed } func node( @@ -928,17 +932,20 @@ final class ContextControllerActionsCustomStackItem: ContextControllerActionsSta let reactionItems: (context: AccountContext, reactionItems: [ReactionContextItem], selectedReactionItems: Set, animationCache: AnimationCache, getEmojiContent: ((AnimationCache, MultiAnimationRenderer) -> Signal)?)? let tip: ContextController.Tip? let tipSignal: Signal? + let dismissed: (() -> Void)? init( content: ContextControllerItemsContent, reactionItems: (context: AccountContext, reactionItems: [ReactionContextItem], selectedReactionItems: Set, animationCache: AnimationCache, getEmojiContent: ((AnimationCache, MultiAnimationRenderer) -> Signal)?)?, tip: ContextController.Tip?, - tipSignal: Signal? + tipSignal: Signal?, + dismissed: (() -> Void)? ) { self.content = content self.reactionItems = reactionItems self.tip = tip self.tipSignal = tipSignal + self.dismissed = dismissed } func node( @@ -963,11 +970,11 @@ func makeContextControllerActionsStackItem(items: ContextController.Items) -> [C } switch items.content { case let .list(listItems): - return [ContextControllerActionsListStackItem(items: listItems, reactionItems: reactionItems, tip: items.tip, tipSignal: items.tipSignal)] + return [ContextControllerActionsListStackItem(items: listItems, reactionItems: reactionItems, tip: items.tip, tipSignal: items.tipSignal, dismissed: items.dismissed)] case let .twoLists(listItems1, listItems2): - return [ContextControllerActionsListStackItem(items: listItems1, reactionItems: nil, tip: nil, tipSignal: nil), ContextControllerActionsListStackItem(items: listItems2, reactionItems: nil, tip: nil, tipSignal: nil)] + return [ContextControllerActionsListStackItem(items: listItems1, reactionItems: nil, tip: nil, tipSignal: nil, dismissed: items.dismissed), ContextControllerActionsListStackItem(items: listItems2, reactionItems: nil, tip: nil, tipSignal: nil, dismissed: nil)] case let .custom(customContent): - return [ContextControllerActionsCustomStackItem(content: customContent, reactionItems: reactionItems, tip: items.tip, tipSignal: items.tipSignal)] + return [ContextControllerActionsCustomStackItem(content: customContent, reactionItems: reactionItems, tip: items.tip, tipSignal: items.tipSignal, dismissed: items.dismissed)] } } @@ -1083,6 +1090,7 @@ final class ContextControllerActionsStackNode: ASDisplayNode { let tipSignal: Signal? var tipNode: InnerTextSelectionTipContainerNode? let reactionItems: (context: AccountContext, reactionItems: [ReactionContextItem], selectedReactionItems: Set, animationCache: AnimationCache, getEmojiContent: ((AnimationCache, MultiAnimationRenderer) -> Signal)?)? + let itemDismissed: (() -> Void)? var storedScrollingState: CGFloat? let positionLock: CGFloat? @@ -1097,6 +1105,7 @@ final class ContextControllerActionsStackNode: ASDisplayNode { tip: ContextController.Tip?, tipSignal: Signal?, reactionItems: (context: AccountContext, reactionItems: [ReactionContextItem], selectedReactionItems: Set, animationCache: AnimationCache, getEmojiContent: ((AnimationCache, MultiAnimationRenderer) -> Signal)?)?, + itemDismissed: (() -> Void)?, positionLock: CGFloat? ) { self.getController = getController @@ -1113,6 +1122,7 @@ final class ContextControllerActionsStackNode: ASDisplayNode { self.dimNode.alpha = 0.0 self.reactionItems = reactionItems + self.itemDismissed = itemDismissed self.positionLock = positionLock self.tip = tip @@ -1339,6 +1349,7 @@ final class ContextControllerActionsStackNode: ASDisplayNode { tip: item.tip, tipSignal: item.tipSignal, reactionItems: item.reactionItems, + itemDismissed: item.dismissed, positionLock: positionLock ) self.itemContainers.append(itemContainer) @@ -1365,6 +1376,8 @@ final class ContextControllerActionsStackNode: ASDisplayNode { let itemContainer = self.itemContainers[self.itemContainers.count - 1] self.itemContainers.remove(at: self.itemContainers.count - 1) self.dismissingItemContainers.append((itemContainer, true)) + + itemContainer.itemDismissed?() } self.navigationContainer.isNavigationEnabled = self.itemContainers.count > 1 diff --git a/submodules/ContextUI/Sources/ContextControllerExtractedPresentationNode.swift b/submodules/ContextUI/Sources/ContextControllerExtractedPresentationNode.swift index 305f5cfc39..a2e2eb3b44 100644 --- a/submodules/ContextUI/Sources/ContextControllerExtractedPresentationNode.swift +++ b/submodules/ContextUI/Sources/ContextControllerExtractedPresentationNode.swift @@ -163,22 +163,50 @@ final class ContextControllerExtractedPresentationNode: ASDisplayNode, ContextCo private final class ControllerContentNode: ASDisplayNode { let controller: ViewController + let passthroughTouches: Bool + var storedContentHeight: CGFloat? - init(controller: ViewController) { + init(controller: ViewController, passthroughTouches: Bool) { self.controller = controller + self.passthroughTouches = passthroughTouches super.init() + self.clipsToBounds = true + self.cornerRadius = 14.0 + self.addSubnode(self.controller.displayNode) } - func update(presentationData: PresentationData, size: CGSize, transition: ContainedViewLayoutTransition) { + func update(presentationData: PresentationData, parentLayout: ContainerViewLayout, size: CGSize, transition: ContainedViewLayoutTransition) { + transition.updateFrame(node: self.controller.displayNode, frame: CGRect(origin: CGPoint(), size: size)) + self.controller.containerLayoutUpdated( + ContainerViewLayout( + size: size, + metrics: LayoutMetrics(widthClass: .compact, heightClass: .compact), + deviceMetrics: parentLayout.deviceMetrics, + intrinsicInsets: UIEdgeInsets(), + safeInsets: UIEdgeInsets(), + additionalInsets: UIEdgeInsets(), + statusBarHeight: nil, + inputHeight: nil, + inputHeightIsInteractivellyChanging: false, + inVoiceOver: false + ), + transition: transition + ) } override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { if !self.bounds.contains(point) { return nil } + if self.passthroughTouches { + let controllerPoint = self.view.convert(point, to: self.controller.view) + if let result = self.controller.view.hitTest(controllerPoint, with: event) { + return result + } + } return self.view } } @@ -476,11 +504,21 @@ final class ContextControllerExtractedPresentationNode: ASDisplayNode, ContextCo if !items.disablePositionLock { positionLock = self.getActionsStackPositionLock() } + if self.actionsStackNode.topPositionLock == nil { + if let contentNode = self.controllerContentNode, contentNode.bounds.height != 0.0 { + contentNode.storedContentHeight = contentNode.bounds.height + } + } self.actionsStackNode.push(item: makeContextControllerActionsStackItem(items: items).first!, currentScrollingState: currentScrollingState, positionLock: positionLock, animated: true) } func popItems() { self.actionsStackNode.pop() + if self.actionsStackNode.topPositionLock == nil { + if let contentNode = self.controllerContentNode { + contentNode.storedContentHeight = nil + } + } } private func getCurrentScrollingState() -> CGFloat { @@ -517,7 +555,7 @@ final class ContextControllerExtractedPresentationNode: ASDisplayNode, ContextCo let contentActionsSpacing: CGFloat = 7.0 let actionsEdgeInset: CGFloat - let actionsSideInset: CGFloat = 6.0 + let actionsSideInset: CGFloat let topInset: CGFloat = layout.insets(options: .statusBar).top + 8.0 let bottomInset: CGFloat = 10.0 @@ -540,7 +578,8 @@ final class ContextControllerExtractedPresentationNode: ASDisplayNode, ContextCo transition: .immediate ) actionsEdgeInset = 16.0 - case .extracted, .controller: + actionsSideInset = 6.0 + case .extracted: self.backgroundNode.updateColor( color: presentationData.theme.contextMenu.dimColor, enableBlur: true, @@ -548,6 +587,16 @@ final class ContextControllerExtractedPresentationNode: ASDisplayNode, ContextCo transition: .immediate ) actionsEdgeInset = 12.0 + actionsSideInset = 6.0 + case .controller: + self.backgroundNode.updateColor( + color: presentationData.theme.contextMenu.dimColor, + enableBlur: true, + forceKeepBlur: true, + transition: .immediate + ) + actionsEdgeInset = 12.0 + actionsSideInset = -2.0 } transition.updateFrame(node: self.backgroundNode, frame: CGRect(origin: CGPoint(), size: layout.size), beginWithCurrentState: true) @@ -583,7 +632,18 @@ final class ContextControllerExtractedPresentationNode: ASDisplayNode, ContextCo } else { switch self.source { case let .controller(source): - controllerContentNode = ControllerContentNode(controller: source.controller) + let controllerContentNodeValue = ControllerContentNode(controller: source.controller, passthroughTouches: source.passthroughTouches) + + //source.controller.viewWillAppear(false) + //source.controller.setIgnoreAppearanceMethodInvocations(true) + + self.scrollNode.insertSubnode(controllerContentNodeValue, aboveSubnode: self.actionsContainerNode) + self.controllerContentNode = controllerContentNodeValue + controllerContentNode = controllerContentNodeValue + contentTransition = .immediate + + //source.controller.setIgnoreAppearanceMethodInvocations(false) + //source.controller.viewDidAppear(false) case .location, .reference, .extracted: controllerContentNode = nil } @@ -688,6 +748,8 @@ final class ContextControllerExtractedPresentationNode: ASDisplayNode, ContextCo let contentParentGlobalFrame: CGRect var contentRect: CGRect + var isContentResizeableVertically: Bool = false + let _ = isContentResizeableVertically switch self.source { case let .location(location): @@ -718,10 +780,21 @@ final class ContextControllerExtractedPresentationNode: ASDisplayNode, ContextCo return } case .controller: - //TODO if let contentNode = controllerContentNode { - let _ = contentNode - contentRect = CGRect(origin: CGPoint(x: layout.size.width * 0.5 - 100.0, y: layout.size.height * 0.5 - 100.0), size: CGSize(width: 200.0, height: 200.0)) + var defaultContentSize = CGSize(width: layout.size.width - 12.0 * 2.0, height: layout.size.height - 12.0 * 2.0 - contentTopInset - layout.safeInsets.bottom) + defaultContentSize.height = min(defaultContentSize.height, 460.0) + + let contentSize: CGSize + if let preferredSize = contentNode.controller.preferredContentSizeForLayout(ContainerViewLayout(size: defaultContentSize, metrics: LayoutMetrics(widthClass: .compact, heightClass: .compact), deviceMetrics: layout.deviceMetrics, intrinsicInsets: UIEdgeInsets(), safeInsets: UIEdgeInsets(), additionalInsets: UIEdgeInsets(), statusBarHeight: nil, inputHeight: nil, inputHeightIsInteractivellyChanging: false, inVoiceOver: false)) { + contentSize = preferredSize + } else if let storedContentHeight = contentNode.storedContentHeight { + contentSize = CGSize(width: defaultContentSize.width, height: storedContentHeight) + } else { + contentSize = defaultContentSize + isContentResizeableVertically = true + } + + contentRect = CGRect(origin: CGPoint(x: floor((layout.size.width - contentSize.width) * 0.5), y: floor((layout.size.height - contentSize.height) * 0.5)), size: contentSize) contentParentGlobalFrame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: layout.size.width, height: layout.size.height)) } else { return @@ -752,14 +825,6 @@ final class ContextControllerExtractedPresentationNode: ASDisplayNode, ContextCo transition: contentTransition ) } - if let contentNode = controllerContentNode { - //TODO - contentNode.update( - presentationData: presentationData, - size: CGSize(), - transition: contentTransition - ) - } let actionsConstrainedHeight: CGFloat if let actionsPositionLock = self.actionsStackNode.topPositionLock { @@ -795,6 +860,14 @@ final class ContextControllerExtractedPresentationNode: ASDisplayNode, ContextCo transition: transition ) + if isContentResizeableVertically && self.actionsStackNode.topPositionLock == nil { + var contentHeight = layout.size.height - contentTopInset - contentActionsSpacing - bottomInset - layout.intrinsicInsets.bottom - actionsSize.height + contentHeight = min(contentHeight, contentRect.height) + contentHeight = max(contentHeight, 200.0) + + contentRect = CGRect(origin: CGPoint(x: 12.0, y: floor((layout.size.height - contentHeight) * 0.5)), size: CGSize(width: layout.size.width - 12.0 * 2.0, height: contentHeight)) + } + var isAnimatingOut = false if case .animateOut = stateTransition { isAnimatingOut = true @@ -950,11 +1023,18 @@ final class ContextControllerExtractedPresentationNode: ASDisplayNode, ContextCo } if let contentNode = controllerContentNode { //TODO: - var contentFrame = CGRect(origin: CGPoint(x: contentRect.minX, y: contentRect.minY + contentVerticalOffset + additionalVisibleOffsetY), size: CGSize(width: 200.0, height: 200.0)) + var contentFrame = CGRect(origin: CGPoint(x: contentRect.minX, y: contentRect.minY + contentVerticalOffset + additionalVisibleOffsetY), size: contentRect.size) if case let .extracted(extracted) = self.source, extracted.centerVertically, contentFrame.midX > layout.size.width / 2.0 { contentFrame.origin.x = layout.size.width - contentFrame.maxX } contentTransition.updateFrame(node: contentNode, frame: contentFrame, beginWithCurrentState: true) + + contentNode.update( + presentationData: presentationData, + parentLayout: layout, + size: contentFrame.size, + transition: contentTransition + ) } let contentHeight: CGFloat @@ -1046,6 +1126,34 @@ final class ContextControllerExtractedPresentationNode: ASDisplayNode, ContextCo damping: springDamping, additive: true ) + } else if let contentNode = controllerContentNode { + if case let .controller(source) = self.source, let transitionInfo = source.transitionInfo(), let (sourceView, sourceRect) = transitionInfo.sourceNode() { + let sourcePoint = sourceView.convert(sourceRect.center, to: self.view) + animationInContentYDistance = contentRect.midY - sourcePoint.y + } else { + animationInContentYDistance = 0.0 + } + currentContentScreenFrame = contentRect + + contentNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15) + contentNode.layer.animateSpring( + from: -animationInContentYDistance as NSNumber, to: 0.0 as NSNumber, + keyPath: "position.y", + duration: duration, + delay: 0.0, + initialVelocity: 0.0, + damping: springDamping, + additive: true + ) + contentNode.layer.animateSpring( + from: 0.01 as NSNumber, to: 1.0 as NSNumber, + keyPath: "transform.scale", + duration: duration, + delay: 0.0, + initialVelocity: 0.0, + damping: springDamping, + additive: false + ) } else { animationInContentYDistance = 0.0 currentContentScreenFrame = contentRect @@ -1200,8 +1308,8 @@ final class ContextControllerExtractedPresentationNode: ASDisplayNode, ContextCo case let .controller(source): if let putBackInfo = source.transitionInfo() { let _ = putBackInfo - self.clippingNode.layer.animateFrame(from: CGRect(origin: CGPoint(), size: layout.size), to: CGRect(origin: CGPoint(x: 0.0, y: putBackInfo.contentAreaInScreenSpace.minY), size: CGSize(width: layout.size.width, height: putBackInfo.contentAreaInScreenSpace.height)), duration: duration, timingFunction: timingFunction, removeOnCompletion: false) - self.clippingNode.layer.animateBoundsOriginYAdditive(from: 0.0, to: putBackInfo.contentAreaInScreenSpace.minY, duration: duration, timingFunction: timingFunction, removeOnCompletion: false) + /*self.clippingNode.layer.animateFrame(from: CGRect(origin: CGPoint(), size: layout.size), to: CGRect(origin: CGPoint(x: 0.0, y: putBackInfo.contentAreaInScreenSpace.minY), size: CGSize(width: layout.size.width, height: putBackInfo.contentAreaInScreenSpace.height)), duration: duration, timingFunction: timingFunction, removeOnCompletion: false) + self.clippingNode.layer.animateBoundsOriginYAdditive(from: 0.0, to: putBackInfo.contentAreaInScreenSpace.minY, duration: duration, timingFunction: timingFunction, removeOnCompletion: false)*/ //TODO: currentContentScreenFrame = CGRect(origin: CGPoint(), size: CGSize(width: 1.0, height: 1.0)) @@ -1216,7 +1324,7 @@ final class ContextControllerExtractedPresentationNode: ASDisplayNode, ContextCo let currentContentLocalFrame = convertFrame(contentRect, from: self.scrollNode.view, to: self.view) - let animationInContentYDistance: CGFloat + var animationInContentYDistance: CGFloat switch result { case .default, .custom: @@ -1295,10 +1403,37 @@ final class ContextControllerExtractedPresentationNode: ASDisplayNode, ContextCo } ) } - if let controllerContentNode { - let _ = controllerContentNode - //TODO - completion() + if let contentNode = controllerContentNode { + if case let .controller(source) = self.source, let transitionInfo = source.transitionInfo(), let (sourceView, sourceRect) = transitionInfo.sourceNode() { + let sourcePoint = sourceView.convert(sourceRect.center, to: self.view) + animationInContentYDistance = contentRect.midY - sourcePoint.y + } else { + animationInContentYDistance = 0.0 + } + + contentNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: duration * 0.8, removeOnCompletion: false, completion: { _ in + completion() + }) + contentNode.layer.animate( + from: 0.0 as NSNumber, + to: -animationInContentYDistance as NSNumber, + keyPath: "position.y", + timingFunction: timingFunction, + duration: duration, + delay: 0.0, + removeOnCompletion: false, + additive: true + ) + contentNode.layer.animate( + from: 1.0 as NSNumber, + to: 0.01 as NSNumber, + keyPath: "transform.scale", + timingFunction: timingFunction, + duration: duration, + delay: 0.0, + removeOnCompletion: false, + additive: false + ) } self.actionsContainerNode.layer.animateAlpha(from: self.actionsContainerNode.alpha, to: 0.0, duration: duration, removeOnCompletion: false) diff --git a/submodules/ContextUI/Sources/ContextSourceContainer.swift b/submodules/ContextUI/Sources/ContextSourceContainer.swift new file mode 100644 index 0000000000..39d1944177 --- /dev/null +++ b/submodules/ContextUI/Sources/ContextSourceContainer.swift @@ -0,0 +1,621 @@ +import Foundation +import AsyncDisplayKit +import Display +import TelegramPresentationData +import SwiftSignalKit +import TelegramCore +import ReactionSelectionNode +import ComponentFlow +import TabSelectorComponent +import ComponentDisplayAdapters + +final class ContextSourceContainer: ASDisplayNode { + final class Source { + weak var controller: ContextController? + + let id: AnyHashable + let title: String + let source: ContextContentSource + + private var _presentationNode: ContextControllerPresentationNode? + var presentationNode: ContextControllerPresentationNode { + return self._presentationNode! + } + + var currentPresentationStateTransition: ContextControllerPresentationNodeStateTransition? + + var validLayout: ContainerViewLayout? + var presentationData: PresentationData? + var delayLayoutUpdate: Bool = false + var isAnimatingOut: Bool = false + + let itemsDisposable = MetaDisposable() + + let ready = Promise() + private let contentReady = Promise() + private let actionsReady = Promise() + + init( + controller: ContextController, + id: AnyHashable, + title: String, + source: ContextContentSource, + items: Signal + ) { + self.controller = controller + self.id = id + self.title = title + self.source = source + + self.ready.set(combineLatest(queue: .mainQueue(), self.contentReady.get(), self.actionsReady.get()) + |> map { a, b -> Bool in + return a && b + } + |> distinctUntilChanged) + + switch source { + case let .location(source): + self.contentReady.set(.single(true)) + + let presentationNode = ContextControllerExtractedPresentationNode( + getController: { [weak self] in + guard let self else { + return nil + } + return self.controller + }, + requestUpdate: { [weak self] transition in + guard let self else { + return + } + self.update(transition: transition) + }, + requestUpdateOverlayWantsToBeBelowKeyboard: { [weak self] transition in + guard let self else { + return + } + if let controller = self.controller { + controller.overlayWantsToBeBelowKeyboardUpdated(transition: transition) + } + }, + requestDismiss: { [weak self] result in + guard let self, let controller = self.controller else { + return + } + controller.controllerNode.dismissedForCancel?() + controller.controllerNode.beginDismiss(result) + }, + requestAnimateOut: { [weak self] result, completion in + guard let self, let controller = self.controller else { + return + } + controller.controllerNode.animateOut(result: result, completion: completion) + }, + source: .location(source) + ) + self._presentationNode = presentationNode + case let .reference(source): + self.contentReady.set(.single(true)) + + let presentationNode = ContextControllerExtractedPresentationNode( + getController: { [weak self] in + guard let self else { + return nil + } + return self.controller + }, + requestUpdate: { [weak self] transition in + guard let self else { + return + } + self.update(transition: transition) + }, + requestUpdateOverlayWantsToBeBelowKeyboard: { [weak self] transition in + guard let self else { + return + } + if let controller = self.controller { + controller.overlayWantsToBeBelowKeyboardUpdated(transition: transition) + } + }, + requestDismiss: { [weak self] result in + guard let self, let controller = self.controller else { + return + } + controller.controllerNode.dismissedForCancel?() + controller.controllerNode.beginDismiss(result) + }, + requestAnimateOut: { [weak self] result, completion in + guard let self, let controller = self.controller else { + return + } + controller.controllerNode.animateOut(result: result, completion: completion) + }, + source: .reference(source) + ) + self._presentationNode = presentationNode + case let .extracted(source): + self.contentReady.set(.single(true)) + + let presentationNode = ContextControllerExtractedPresentationNode( + getController: { [weak self] in + guard let self else { + return nil + } + return self.controller + }, + requestUpdate: { [weak self] transition in + guard let self else { + return + } + self.update(transition: transition) + }, + requestUpdateOverlayWantsToBeBelowKeyboard: { [weak self] transition in + guard let self else { + return + } + if let controller = self.controller { + controller.overlayWantsToBeBelowKeyboardUpdated(transition: transition) + } + }, + requestDismiss: { [weak self] result in + guard let self, let controller = self.controller else { + return + } + controller.controllerNode.dismissedForCancel?() + controller.controllerNode.beginDismiss(result) + }, + requestAnimateOut: { [weak self] result, completion in + guard let self, let controller = self.controller else { + return + } + controller.controllerNode.animateOut(result: result, completion: completion) + }, + source: .extracted(source) + ) + self._presentationNode = presentationNode + case let .controller(source): + self.contentReady.set(source.controller.ready.get()) + + let presentationNode = ContextControllerExtractedPresentationNode( + getController: { [weak self] in + guard let self else { + return nil + } + return self.controller + }, + requestUpdate: { [weak self] transition in + guard let self else { + return + } + self.update(transition: transition) + }, + requestUpdateOverlayWantsToBeBelowKeyboard: { [weak self] transition in + guard let self else { + return + } + if let controller = self.controller { + controller.overlayWantsToBeBelowKeyboardUpdated(transition: transition) + } + }, + requestDismiss: { [weak self] result in + guard let self, let controller = self.controller else { + return + } + controller.controllerNode.dismissedForCancel?() + controller.controllerNode.beginDismiss(result) + }, + requestAnimateOut: { [weak self] result, completion in + guard let self, let controller = self.controller else { + return + } + controller.controllerNode.animateOut(result: result, completion: completion) + }, + source: .controller(source) + ) + self._presentationNode = presentationNode + } + + self.itemsDisposable.set((items |> deliverOnMainQueue).start(next: { [weak self] items in + guard let self else { + return + } + + self.setItems(items: items, animated: false) + self.actionsReady.set(.single(true)) + })) + } + + deinit { + self.itemsDisposable.dispose() + } + + func animateIn() { + self.currentPresentationStateTransition = .animateIn + self.update(transition: .animated(duration: 0.5, curve: .spring)) + } + + func animateOut(result: ContextMenuActionResult, completion: @escaping () -> Void) { + self.currentPresentationStateTransition = .animateOut(result: result, completion: completion) + if let _ = self.validLayout { + if case let .custom(transition) = result { + self.delayLayoutUpdate = true + Queue.mainQueue().after(0.1) { + self.delayLayoutUpdate = false + self.update(transition: transition) + self.isAnimatingOut = true + } + } else { + self.update(transition: .animated(duration: 0.35, curve: .easeInOut)) + } + } + } + + func addRelativeContentOffset(_ offset: CGPoint, transition: ContainedViewLayoutTransition) { + self.presentationNode.addRelativeContentOffset(offset, transition: transition) + } + + func cancelReactionAnimation() { + self.presentationNode.cancelReactionAnimation() + } + + func animateOutToReaction(value: MessageReaction.Reaction, targetView: UIView, hideNode: Bool, animateTargetContainer: UIView?, addStandaloneReactionAnimation: ((StandaloneReactionAnimation) -> Void)?, reducedCurve: Bool, completion: @escaping () -> Void) { + self.presentationNode.animateOutToReaction(value: value, targetView: targetView, hideNode: hideNode, animateTargetContainer: animateTargetContainer, addStandaloneReactionAnimation: addStandaloneReactionAnimation, reducedCurve: reducedCurve, completion: completion) + } + + func setItems(items: Signal, animated: Bool) { + self.itemsDisposable.set((items + |> deliverOnMainQueue).start(next: { [weak self] items in + guard let self else { + return + } + self.setItems(items: items, animated: animated) + })) + } + + func setItems(items: ContextController.Items, animated: Bool) { + self.presentationNode.replaceItems(items: items, animated: animated) + } + + func pushItems(items: Signal) { + self.itemsDisposable.set((items + |> deliverOnMainQueue).start(next: { [weak self] items in + guard let self else { + return + } + self.presentationNode.pushItems(items: items) + })) + } + + func popItems() { + self.presentationNode.popItems() + } + + func update(transition: ContainedViewLayoutTransition) { + guard let validLayout = self.validLayout else { + return + } + guard let presentationData = self.presentationData else { + return + } + self.update(presentationData: presentationData, layout: validLayout, transition: transition) + } + + func update( + presentationData: PresentationData, + layout: ContainerViewLayout, + transition: ContainedViewLayoutTransition + ) { + if self.isAnimatingOut || self.delayLayoutUpdate { + return + } + + self.validLayout = layout + self.presentationData = presentationData + + let presentationStateTransition = self.currentPresentationStateTransition + self.currentPresentationStateTransition = .none + + self.presentationNode.update( + presentationData: presentationData, + layout: layout, + transition: transition, + stateTransition: presentationStateTransition + ) + } + } + + private struct PanState { + var fraction: CGFloat + + init(fraction: CGFloat) { + self.fraction = fraction + } + } + + private weak var controller: ContextController? + + var sources: [Source] = [] + var activeIndex: Int = 0 + + private var tabSelector: ComponentView? + + private var presentationData: PresentationData? + private var validLayout: ContainerViewLayout? + private var panState: PanState? + + let ready = Promise() + + var activeSource: Source? { + if self.activeIndex >= self.sources.count { + return nil + } + return self.sources[self.activeIndex] + } + + var overlayWantsToBeBelowKeyboard: Bool { + return self.activeSource?.presentationNode.wantsDisplayBelowKeyboard() ?? false + } + + init(controller: ContextController, configuration: ContextController.Configuration) { + self.controller = controller + + super.init() + + for i in 0 ..< configuration.sources.count { + let source = configuration.sources[i] + + let mappedSource = Source( + controller: controller, + id: source.id, + title: source.title, + source: source.source, + items: source.items + ) + self.sources.append(mappedSource) + self.addSubnode(mappedSource.presentationNode) + + if source.id == configuration.initialId { + self.activeIndex = i + } + } + + self.ready.set(self.sources[self.activeIndex].ready.get()) + + self.view.addGestureRecognizer(InteractiveTransitionGestureRecognizer(target: self, action: #selector(self.panGesture(_:)), allowedDirections: { [weak self] _ in + guard let self else { + return [] + } + if self.sources.count <= 1 { + return [] + } + return [.left, .right] + })) + } + + @objc private func panGesture(_ recognizer: InteractiveTransitionGestureRecognizer) { + switch recognizer.state { + case .began, .changed: + if let validLayout = self.validLayout { + var translationX = recognizer.translation(in: self.view).x + if self.activeIndex == 0 && translationX > 0.0 { + translationX = scrollingRubberBandingOffset(offset: abs(translationX), bandingStart: 0.0, range: 20.0) + } else if self.activeIndex == self.sources.count - 1 && translationX < 0.0 { + translationX = -scrollingRubberBandingOffset(offset: abs(translationX), bandingStart: 0.0, range: 20.0) + } + + self.panState = PanState(fraction: translationX / validLayout.size.width) + self.update(transition: .immediate) + } + case .cancelled, .ended: + if let panState = self.panState { + self.panState = nil + + let velocity = recognizer.velocity(in: self.view) + + var nextIndex = self.activeIndex + if panState.fraction < -0.4 { + nextIndex += 1 + } else if panState.fraction > 0.4 { + nextIndex -= 1 + } else if abs(velocity.x) >= 200.0 { + if velocity.x < 0.0 { + nextIndex += 1 + } else { + nextIndex -= 1 + } + } + if nextIndex < 0 { + nextIndex = 0 + } + if nextIndex > self.sources.count - 1 { + nextIndex = self.sources.count - 1 + } + if nextIndex != self.activeIndex { + self.activeIndex = nextIndex + } + + self.update(transition: .animated(duration: 0.4, curve: .spring)) + } + default: + break + } + } + + func animateIn() { + if let activeSource = self.activeSource { + activeSource.animateIn() + } + if let tabSelectorView = self.tabSelector?.view { + tabSelectorView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) + } + } + + func animateOut(result: ContextMenuActionResult, completion: @escaping () -> Void) { + if let tabSelectorView = self.tabSelector?.view { + tabSelectorView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false) + } + + if let activeSource = self.activeSource { + activeSource.animateOut(result: result, completion: completion) + } else { + completion() + } + } + + func highlightGestureMoved(location: CGPoint, hover: Bool) { + if self.activeIndex >= self.sources.count { + return + } + self.sources[self.activeIndex].presentationNode.highlightGestureMoved(location: location, hover: hover) + } + + func highlightGestureFinished(performAction: Bool) { + if self.activeIndex >= self.sources.count { + return + } + self.sources[self.activeIndex].presentationNode.highlightGestureFinished(performAction: performAction) + } + + func performHighlightedAction() { + self.activeSource?.presentationNode.highlightGestureFinished(performAction: true) + } + + func decreaseHighlightedIndex() { + self.activeSource?.presentationNode.decreaseHighlightedIndex() + } + + func increaseHighlightedIndex() { + self.activeSource?.presentationNode.increaseHighlightedIndex() + } + + func addRelativeContentOffset(_ offset: CGPoint, transition: ContainedViewLayoutTransition) { + if let activeSource = self.activeSource { + activeSource.addRelativeContentOffset(offset, transition: transition) + } + } + + func cancelReactionAnimation() { + if let activeSource = self.activeSource { + activeSource.cancelReactionAnimation() + } + } + + func animateOutToReaction(value: MessageReaction.Reaction, targetView: UIView, hideNode: Bool, animateTargetContainer: UIView?, addStandaloneReactionAnimation: ((StandaloneReactionAnimation) -> Void)?, reducedCurve: Bool, completion: @escaping () -> Void) { + if let activeSource = self.activeSource { + activeSource.animateOutToReaction(value: value, targetView: targetView, hideNode: hideNode, animateTargetContainer: animateTargetContainer, addStandaloneReactionAnimation: addStandaloneReactionAnimation, reducedCurve: reducedCurve, completion: completion) + } else { + completion() + } + } + + func setItems(items: Signal, animated: Bool) { + if let activeSource = self.activeSource { + activeSource.setItems(items: items, animated: animated) + } + } + + func pushItems(items: Signal) { + if let activeSource = self.activeSource { + activeSource.pushItems(items: items) + } + } + + func popItems() { + if let activeSource = self.activeSource { + activeSource.popItems() + } + } + + private func update(transition: ContainedViewLayoutTransition) { + if let presentationData = self.presentationData, let validLayout = self.validLayout { + self.update(presentationData: presentationData, layout: validLayout, transition: transition) + } + } + + func update( + presentationData: PresentationData, + layout: ContainerViewLayout, + transition: ContainedViewLayoutTransition + ) { + self.presentationData = presentationData + self.validLayout = layout + + var childLayout = layout + + if self.sources.count > 1 { + let tabSelector: ComponentView + if let current = self.tabSelector { + tabSelector = current + } else { + tabSelector = ComponentView() + self.tabSelector = tabSelector + } + let mappedItems = self.sources.map { source -> TabSelectorComponent.Item in + return TabSelectorComponent.Item(id: source.id, title: source.title) + } + let tabSelectorSize = tabSelector.update( + transition: Transition(transition), + component: AnyComponent(TabSelectorComponent( + colors: TabSelectorComponent.Colors( + foreground: presentationData.theme.contextMenu.primaryColor.withMultipliedAlpha(0.8), + selection: presentationData.theme.contextMenu.primaryColor.withMultipliedAlpha(0.1) + ), + items: mappedItems, + selectedId: self.activeSource?.id, + setSelectedId: { [weak self] id in + guard let self else { + return + } + if let index = self.sources.firstIndex(where: { $0.id == id }) { + self.activeIndex = index + self.update(transition: .animated(duration: 0.4, curve: .spring)) + } + } + )), + environment: {}, + containerSize: CGSize(width: layout.size.width, height: 44.0) + ) + childLayout.intrinsicInsets.bottom += 44.0 + + if let tabSelectorView = tabSelector.view { + if tabSelectorView.superview == nil { + self.view.addSubview(tabSelectorView) + } + transition.updateFrame(view: tabSelectorView, frame: CGRect(origin: CGPoint(x: floor((layout.size.width - tabSelectorSize.width) * 0.5), y: layout.size.height - layout.intrinsicInsets.bottom - tabSelectorSize.height), size: tabSelectorSize)) + } + } else if let tabSelector = self.tabSelector { + self.tabSelector = nil + tabSelector.view?.removeFromSuperview() + } + + for i in 0 ..< self.sources.count { + var itemFrame = CGRect(origin: CGPoint(), size: childLayout.size) + itemFrame.origin.x += CGFloat(i - self.activeIndex) * childLayout.size.width + if let panState = self.panState { + itemFrame.origin.x += panState.fraction * childLayout.size.width + } + + let itemTransition = transition + itemTransition.updateFrame(node: self.sources[i].presentationNode, frame: itemFrame) + self.sources[i].update( + presentationData: presentationData, + layout: childLayout, + transition: itemTransition + ) + } + } + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + if let tabSelectorView = self.tabSelector?.view { + if let result = tabSelectorView.hitTest(self.view.convert(point, to: tabSelectorView), with: event) { + return result + } + } + + guard let activeSource = self.activeSource else { + return nil + } + return activeSource.presentationNode.view.hitTest(point, with: event) + } +} diff --git a/submodules/Display/Source/ContextMenuController.swift b/submodules/Display/Source/ContextMenuController.swift index 4241125737..19ce500045 100644 --- a/submodules/Display/Source/ContextMenuController.swift +++ b/submodules/Display/Source/ContextMenuController.swift @@ -28,6 +28,7 @@ public final class ContextMenuController: ViewController, KeyShortcutResponder, private let catchTapsOutside: Bool private let hasHapticFeedback: Bool private let blurred: Bool + private let skipCoordnateConversion: Bool private var layout: ContainerViewLayout? @@ -36,11 +37,12 @@ public final class ContextMenuController: ViewController, KeyShortcutResponder, public var dismissOnTap: ((UIView, CGPoint) -> Bool)? - public init(actions: [ContextMenuAction], catchTapsOutside: Bool = false, hasHapticFeedback: Bool = false, blurred: Bool = false) { + public init(actions: [ContextMenuAction], catchTapsOutside: Bool = false, hasHapticFeedback: Bool = false, blurred: Bool = false, skipCoordnateConversion: Bool = false) { self.actions = actions self.catchTapsOutside = catchTapsOutside self.hasHapticFeedback = hasHapticFeedback self.blurred = blurred + self.skipCoordnateConversion = skipCoordnateConversion super.init(navigationBarPresentationData: nil) @@ -92,8 +94,13 @@ public final class ContextMenuController: ViewController, KeyShortcutResponder, self.layout = layout if let presentationArguments = self.presentationArguments as? ContextMenuControllerPresentationArguments, let (sourceNode, sourceRect, containerNode, containerRect) = presentationArguments.sourceNodeAndRect() { - self.contextMenuNode.sourceRect = sourceNode.view.convert(sourceRect, to: nil) - self.contextMenuNode.containerRect = containerNode.view.convert(containerRect, to: nil) + if self.skipCoordnateConversion { + self.contextMenuNode.sourceRect = sourceRect + self.contextMenuNode.containerRect = containerRect + } else { + self.contextMenuNode.sourceRect = sourceNode.view.convert(sourceRect, to: nil) + self.contextMenuNode.containerRect = containerNode.view.convert(containerRect, to: nil) + } } else { self.contextMenuNode.sourceRect = nil self.contextMenuNode.containerRect = nil diff --git a/submodules/Display/Source/ContextMenuNode.swift b/submodules/Display/Source/ContextMenuNode.swift index b445b67bf1..9f1168ce25 100644 --- a/submodules/Display/Source/ContextMenuNode.swift +++ b/submodules/Display/Source/ContextMenuNode.swift @@ -146,7 +146,7 @@ final class ContextMenuNode: ASDisplayNode { private let feedback: HapticFeedback? - init(actions: [ContextMenuAction], dismiss: @escaping () -> Void, dismissOnTap: @escaping (UIView, CGPoint) -> Bool, catchTapsOutside: Bool, hasHapticFeedback: Bool = false, blurred: Bool = false) { + init(actions: [ContextMenuAction], dismiss: @escaping () -> Void, dismissOnTap: @escaping (UIView, CGPoint) -> Bool, catchTapsOutside: Bool, hasHapticFeedback: Bool, blurred: Bool = false) { self.actions = actions self.dismiss = dismiss self.dismissOnTap = dismissOnTap diff --git a/submodules/Display/Source/InteractiveTransitionGestureRecognizer.swift b/submodules/Display/Source/InteractiveTransitionGestureRecognizer.swift index 6927cbc1b2..8b72c2f7a0 100644 --- a/submodules/Display/Source/InteractiveTransitionGestureRecognizer.swift +++ b/submodules/Display/Source/InteractiveTransitionGestureRecognizer.swift @@ -140,7 +140,7 @@ public class InteractiveTransitionGestureRecognizer: UIPanGestureRecognizer { override public func translation(in view: UIView?) -> CGPoint { let result = super.translation(in: view) - return result.offsetBy(dx: self.ignoreOffset.x, dy: self.ignoreOffset.y) + return result//.offsetBy(dx: self.ignoreOffset.x, dy: self.ignoreOffset.y) } override public func touchesMoved(_ touches: Set, with event: UIEvent) { diff --git a/submodules/Display/Source/PresentationContext.swift b/submodules/Display/Source/PresentationContext.swift index b396a44582..804cabd50a 100644 --- a/submodules/Display/Source/PresentationContext.swift +++ b/submodules/Display/Source/PresentationContext.swift @@ -306,7 +306,7 @@ public final class PresentationContext { UIAccessibility.post(notification: UIAccessibility.Notification.screenChanged, argument: nil) } - func hitTest(view: UIView, point: CGPoint, with event: UIEvent?) -> UIView? { + public func hitTest(view: UIView, point: CGPoint, with event: UIEvent?) -> UIView? { for (controller, _) in self.controllers.reversed() { if controller.isViewLoaded { if let result = controller.view.hitTest(view.convert(point, to: controller.view), with: event) { diff --git a/submodules/Display/Source/TextNode.swift b/submodules/Display/Source/TextNode.swift index de32a60afb..7fad5b1539 100644 --- a/submodules/Display/Source/TextNode.swift +++ b/submodules/Display/Source/TextNode.swift @@ -1199,9 +1199,12 @@ open class TextNode: ASDisplayNode { let rawSubstring = segment.substring.string as NSString let substringLength = rawSubstring.length - let typesetter = CTTypesetterCreateWithAttributedString(segment.substring as CFAttributedString) - var currentLineStartIndex = 0 + let segmentTypesetterString = attributedString.attributedSubstring(from: NSRange(location: 0, length: segment.firstCharacterOffset + substringLength)) + let typesetter = CTTypesetterCreateWithAttributedString(segmentTypesetterString as CFAttributedString) + + var currentLineStartIndex = segment.firstCharacterOffset + let segmentEndIndex = segment.firstCharacterOffset + substringLength var constrainedSegmentWidth = constrainedSize.width var additionalOffsetX: CGFloat = 0.0 @@ -1250,7 +1253,7 @@ open class TextNode: ASDisplayNode { frame: CGRect(origin: CGPoint(x: additionalOffsetX, y: 0.0), size: CGSize(width: lineWidth + additionalSegmentRightInset, height: lineAscent + lineDescent)), ascent: lineAscent, descent: lineDescent, - range: NSRange(location: segment.firstCharacterOffset + currentLineStartIndex, length: lineCharacterCount), + range: NSRange(location: currentLineStartIndex, length: lineCharacterCount), isRTL: false, strikethroughs: [], spoilers: [], @@ -1265,7 +1268,7 @@ open class TextNode: ASDisplayNode { currentLineStartIndex += lineCharacterCount - if currentLineStartIndex >= substringLength { + if currentLineStartIndex >= segmentEndIndex { break } } diff --git a/submodules/InstantPageUI/Sources/InstantPageLayout.swift b/submodules/InstantPageUI/Sources/InstantPageLayout.swift index d6323cd19c..bbc30be749 100644 --- a/submodules/InstantPageUI/Sources/InstantPageLayout.swift +++ b/submodules/InstantPageUI/Sources/InstantPageLayout.swift @@ -632,7 +632,7 @@ public func layoutInstantPageBlock(webpage: TelegramMediaWebpage, userLocation: let frame = CGRect(origin: CGPoint(x: floor((boundingWidth - size.width) / 2.0), y: 0.0), size: size) let item: InstantPageItem if let url = url, let coverId = coverId, case let .image(image) = media[coverId] { - let loadedContent = TelegramMediaWebpageLoadedContent(url: url, displayUrl: url, hash: 0, type: "video", websiteName: nil, title: nil, text: nil, embedUrl: url, embedType: "video", embedSize: PixelDimensions(size), duration: nil, author: nil, image: image, file: nil, story: nil, attributes: [], instantPage: nil) + let loadedContent = TelegramMediaWebpageLoadedContent(url: url, displayUrl: url, hash: 0, type: "video", websiteName: nil, title: nil, text: nil, embedUrl: url, embedType: "video", embedSize: PixelDimensions(size), duration: nil, author: nil, image: image, file: nil, story: nil, attributes: [], instantPage: nil, displayOptions: .default) let content = TelegramMediaWebpageContent.Loaded(loadedContent) item = InstantPageImageItem(frame: frame, webPage: webpage, media: InstantPageMedia(index: embedIndex, media: .webpage(TelegramMediaWebpage(webpageId: EngineMedia.Id(namespace: Namespaces.Media.LocalWebpage, id: -1), content: content)), url: nil, caption: nil, credit: nil), attributes: [], interactive: true, roundCorners: false, fit: false) diff --git a/submodules/Postbox/Sources/MediaBoxFileContextV2Impl.swift b/submodules/Postbox/Sources/MediaBoxFileContextV2Impl.swift index edf765de7f..6bc400325a 100644 --- a/submodules/Postbox/Sources/MediaBoxFileContextV2Impl.swift +++ b/submodules/Postbox/Sources/MediaBoxFileContextV2Impl.swift @@ -158,6 +158,10 @@ final class MediaBoxFileContextV2Impl: MediaBoxFileContext { } } + deinit { + self.pendingFetch?.disposable.dispose() + } + func request( range: Range, isFullRange: Bool, diff --git a/submodules/Postbox/Sources/PostboxLogging.swift b/submodules/Postbox/Sources/PostboxLogging.swift index 6daff29cfa..8faf111fc0 100644 --- a/submodules/Postbox/Sources/PostboxLogging.swift +++ b/submodules/Postbox/Sources/PostboxLogging.swift @@ -1,11 +1,17 @@ import Foundation private var postboxLogger: (String) -> Void = { _ in } +private var postboxLoggerSync: () -> Void = {} -public func setPostboxLogger(_ f: @escaping (String) -> Void) { +public func setPostboxLogger(_ f: @escaping (String) -> Void, sync: @escaping () -> Void) { postboxLogger = f + postboxLoggerSync = sync } public func postboxLog(_ what: @autoclosure () -> String) { postboxLogger(what()) } + +public func postboxLogSync() { + postboxLoggerSync() +} diff --git a/submodules/Postbox/Sources/SqliteValueBox.swift b/submodules/Postbox/Sources/SqliteValueBox.swift index 7b63cd186d..317cdca45d 100644 --- a/submodules/Postbox/Sources/SqliteValueBox.swift +++ b/submodules/Postbox/Sources/SqliteValueBox.swift @@ -60,6 +60,7 @@ struct SqlitePreparedStatement { if let path = pathToRemoveOnError { postboxLog("Corrupted DB at step, dropping") try? FileManager.default.removeItem(atPath: path) + postboxLogSync() preconditionFailure() } } @@ -84,6 +85,7 @@ struct SqlitePreparedStatement { if let path = pathToRemoveOnError { postboxLog("Corrupted DB at step, dropping") try? FileManager.default.removeItem(atPath: path) + postboxLogSync() preconditionFailure() } } @@ -300,12 +302,14 @@ public final class SqliteValueBox: ValueBox { } catch { let _ = try? FileManager.default.removeItem(atPath: tempPath) postboxLog("Don't have write access to database folder") + postboxLogSync() preconditionFailure("Don't have write access to database folder") } if self.removeDatabaseOnError { let _ = try? FileManager.default.removeItem(atPath: path) } + postboxLogSync() preconditionFailure("Couldn't open database") } @@ -577,6 +581,7 @@ public final class SqliteValueBox: ValueBox { try? FileManager.default.removeItem(atPath: databasePath) } + postboxLogSync() preconditionFailure() } }) @@ -1197,6 +1202,7 @@ public final class SqliteValueBox: ValueBox { let status = sqlite3_prepare_v3(self.database.handle, "INSERT INTO t\(table.table.id) (key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value", -1, SQLITE_PREPARE_PERSISTENT, &statement, nil) if status != SQLITE_OK { let errorText = self.database.currentError() ?? "Unknown error" + postboxLogSync() preconditionFailure(errorText) } let preparedStatement = SqlitePreparedStatement(statement: statement) @@ -1211,6 +1217,7 @@ public final class SqliteValueBox: ValueBox { let status = sqlite3_prepare_v3(self.database.handle, "INSERT INTO t\(table.table.id) (key, value) VALUES(?, ?)", -1, SQLITE_PREPARE_PERSISTENT, &statement, nil) if status != SQLITE_OK { let errorText = self.database.currentError() ?? "Unknown error" + postboxLogSync() preconditionFailure(errorText) } let preparedStatement = SqlitePreparedStatement(statement: statement) @@ -1250,6 +1257,7 @@ public final class SqliteValueBox: ValueBox { let status = sqlite3_prepare_v3(self.database.handle, "INSERT INTO t\(table.table.id) (key, value) VALUES(?, ?) ON CONFLICT(key) DO NOTHING", -1, SQLITE_PREPARE_PERSISTENT, &statement, nil) if status != SQLITE_OK { let errorText = self.database.currentError() ?? "Unknown error" + postboxLogSync() preconditionFailure(errorText) } let preparedStatement = SqlitePreparedStatement(statement: statement) @@ -1264,6 +1272,7 @@ public final class SqliteValueBox: ValueBox { let status = sqlite3_prepare_v3(self.database.handle, "INSERT INTO t\(table.table.id) (key, value) VALUES(?, ?)", -1, SQLITE_PREPARE_PERSISTENT, &statement, nil) if status != SQLITE_OK { let errorText = self.database.currentError() ?? "Unknown error" + postboxLogSync() preconditionFailure(errorText) } let preparedStatement = SqlitePreparedStatement(statement: statement) @@ -2297,6 +2306,7 @@ public final class SqliteValueBox: ValueBox { self.clearStatements() if self.isReadOnly { + postboxLogSync() preconditionFailure() } @@ -2346,6 +2356,7 @@ public final class SqliteValueBox: ValueBox { private func reencryptInPlace(database: Database, encryptionParameters: ValueBoxEncryptionParameters) -> Database { if self.isReadOnly { + postboxLogSync() preconditionFailure() } diff --git a/submodules/TelegramCore/Sources/AccountManager/AccountManagerImpl.swift b/submodules/TelegramCore/Sources/AccountManager/AccountManagerImpl.swift index 14e1439d5a..932933000d 100644 --- a/submodules/TelegramCore/Sources/AccountManager/AccountManagerImpl.swift +++ b/submodules/TelegramCore/Sources/AccountManager/AccountManagerImpl.swift @@ -71,6 +71,7 @@ final class AccountManagerImpl { return (atomicState.records.sorted(by: { $0.key.int64 < $1.key.int64 }).map({ $1 }), atomicState.currentRecordId) } catch let e { postboxLog("decode atomic state error: \(e)") + postboxLogSync() preconditionFailure() } } @@ -85,10 +86,16 @@ final class AccountManagerImpl { self.temporarySessionId = temporarySessionId let _ = try? FileManager.default.createDirectory(atPath: basePath, withIntermediateDirectories: true, attributes: nil) guard let guardValueBox = SqliteValueBox(basePath: basePath + "/guard_db", queue: queue, isTemporary: isTemporary, isReadOnly: false, useCaches: useCaches, removeDatabaseOnError: removeDatabaseOnError, encryptionParameters: nil, upgradeProgress: { _ in }) else { + postboxLog("Could not open guard value box at \(basePath + "/guard_db")") + postboxLogSync() + preconditionFailure() return nil } self.guardValueBox = guardValueBox guard let valueBox = SqliteValueBox(basePath: basePath + "/db", queue: queue, isTemporary: isTemporary, isReadOnly: isReadOnly, useCaches: useCaches, removeDatabaseOnError: removeDatabaseOnError, encryptionParameters: nil, upgradeProgress: { _ in }) else { + postboxLog("Could not open value box at \(basePath + "/db")") + postboxLogSync() + preconditionFailure() return nil } self.valueBox = valueBox @@ -106,6 +113,7 @@ final class AccountManagerImpl { } catch let e { postboxLog("decode atomic state error: \(e)") let _ = try? FileManager.default.removeItem(atPath: self.atomicStatePath) + postboxLogSync() preconditionFailure() } } catch let e { @@ -246,9 +254,11 @@ final class AccountManagerImpl { if let data = try? JSONEncoder().encode(self.currentAtomicState) { if let _ = try? data.write(to: URL(fileURLWithPath: self.atomicStatePath), options: [.atomic]) { } else { + postboxLogSync() preconditionFailure() } } else { + postboxLogSync() preconditionFailure() } } @@ -523,6 +533,7 @@ public final class AccountManager { if let value = AccountManagerImpl(queue: queue, basePath: basePath, isTemporary: isTemporary, isReadOnly: isReadOnly, useCaches: useCaches, removeDatabaseOnError: removeDatabaseOnError, temporarySessionId: temporarySessionId) { return value } else { + postboxLogSync() preconditionFailure() } }) diff --git a/submodules/TelegramCore/Sources/ApiUtils/TelegramMediaWebpage.swift b/submodules/TelegramCore/Sources/ApiUtils/TelegramMediaWebpage.swift index 66c30a8751..99ed33e73d 100644 --- a/submodules/TelegramCore/Sources/ApiUtils/TelegramMediaWebpage.swift +++ b/submodules/TelegramCore/Sources/ApiUtils/TelegramMediaWebpage.swift @@ -55,7 +55,7 @@ func telegramMediaWebpageFromApiWebpage(_ webpage: Api.WebPage, url: String?) -> if let cachedPage = cachedPage { instantPage = InstantPage(apiPage: cachedPage) } - return TelegramMediaWebpage(webpageId: MediaId(namespace: Namespaces.Media.CloudWebpage, id: id), content: .Loaded(TelegramMediaWebpageLoadedContent(url: url, displayUrl: displayUrl, hash: hash, type: type, websiteName: siteName, title: title, text: description, embedUrl: embedUrl, embedType: embedType, embedSize: embedSize, duration: webpageDuration, author: author, image: image, file: file, story: story, attributes: webpageAttributes, instantPage: instantPage))) + return TelegramMediaWebpage(webpageId: MediaId(namespace: Namespaces.Media.CloudWebpage, id: id), content: .Loaded(TelegramMediaWebpageLoadedContent(url: url, displayUrl: displayUrl, hash: hash, type: type, websiteName: siteName, title: title, text: description, embedUrl: embedUrl, embedType: embedType, embedSize: embedSize, duration: webpageDuration, author: author, image: image, file: file, story: story, attributes: webpageAttributes, instantPage: instantPage, displayOptions: .default))) case .webPageEmpty: return nil } diff --git a/submodules/TelegramCore/Sources/SyncCore/SyncCore_TelegramMediaWebpage.swift b/submodules/TelegramCore/Sources/SyncCore/SyncCore_TelegramMediaWebpage.swift index f56add72b1..b878d1c729 100644 --- a/submodules/TelegramCore/Sources/SyncCore/SyncCore_TelegramMediaWebpage.swift +++ b/submodules/TelegramCore/Sources/SyncCore/SyncCore_TelegramMediaWebpage.swift @@ -69,6 +69,34 @@ public final class TelegraMediaWebpageThemeAttribute: PostboxCoding, Equatable { } } +public struct TelegramMediaWebpageDisplayOptions: Codable, Equatable { + public enum CodingKeys: String, CodingKey { + case position = "p" + case largeMedia = "lm" + } + + public enum Position: Int32, Codable { + case aboveText = 0 + case belowText = 1 + } + + public var position: Position? + public var largeMedia: Bool? + + public static let `default` = TelegramMediaWebpageDisplayOptions( + position: nil, + largeMedia: nil + ) + + public init( + position: Position?, + largeMedia: Bool? + ) { + self.position = position + self.largeMedia = largeMedia + } +} + public final class TelegramMediaWebpageLoadedContent: PostboxCoding, Equatable { public let url: String public let displayUrl: String @@ -89,7 +117,28 @@ public final class TelegramMediaWebpageLoadedContent: PostboxCoding, Equatable { public let attributes: [TelegramMediaWebpageAttribute] public let instantPage: InstantPage? - public init(url: String, displayUrl: String, hash: Int32, type: String?, websiteName: String?, title: String?, text: String?, embedUrl: String?, embedType: String?, embedSize: PixelDimensions?, duration: Int?, author: String?, image: TelegramMediaImage?, file: TelegramMediaFile?, story: TelegramMediaStory?, attributes: [TelegramMediaWebpageAttribute], instantPage: InstantPage?) { + public let displayOptions: TelegramMediaWebpageDisplayOptions + + public init( + url: String, + displayUrl: String, + hash: Int32, + type: String?, + websiteName: String?, + title: String?, + text: String?, + embedUrl: String?, + embedType: String?, + embedSize: PixelDimensions?, + duration: Int?, + author: String?, + image: TelegramMediaImage?, + file: TelegramMediaFile?, + story: TelegramMediaStory?, + attributes: [TelegramMediaWebpageAttribute], + instantPage: InstantPage?, + displayOptions: TelegramMediaWebpageDisplayOptions + ) { self.url = url self.displayUrl = displayUrl self.hash = hash @@ -107,6 +156,7 @@ public final class TelegramMediaWebpageLoadedContent: PostboxCoding, Equatable { self.story = story self.attributes = attributes self.instantPage = instantPage + self.displayOptions = displayOptions } public init(decoder: PostboxDecoder) { @@ -163,6 +213,8 @@ public final class TelegramMediaWebpageLoadedContent: PostboxCoding, Equatable { } else { self.instantPage = nil } + + self.displayOptions = decoder.decodeCodable(TelegramMediaWebpageDisplayOptions.self, forKey: "do") ?? TelegramMediaWebpageDisplayOptions.default } public func encode(_ encoder: PostboxEncoder) { @@ -239,6 +291,31 @@ public final class TelegramMediaWebpageLoadedContent: PostboxCoding, Equatable { } else { encoder.encodeNil(forKey: "ip") } + + encoder.encodeCodable(self.displayOptions, forKey: "do") + } + + public func withDisplayOptions(_ displayOptions: TelegramMediaWebpageDisplayOptions) -> TelegramMediaWebpageLoadedContent { + return TelegramMediaWebpageLoadedContent( + url: self.url, + displayUrl: self.displayUrl, + hash: self.hash, + type: self.type, + websiteName: self.websiteName, + title: self.title, + text: self.text, + embedUrl: self.embedUrl, + embedType: self.embedType, + embedSize: self.embedSize, + duration: self.duration, + author: self.author, + image: self.image, + file: self.file, + story: self.story, + attributes: self.attributes, + instantPage: self.instantPage, + displayOptions: displayOptions + ) } } @@ -296,6 +373,10 @@ public func ==(lhs: TelegramMediaWebpageLoadedContent, rhs: TelegramMediaWebpage return false } + if lhs.displayOptions != rhs.displayOptions { + return false + } + return true } diff --git a/submodules/TelegramCore/Sources/Utils/Log.swift b/submodules/TelegramCore/Sources/Utils/Log.swift index 3d0410a786..4278b78c02 100644 --- a/submodules/TelegramCore/Sources/Utils/Log.swift +++ b/submodules/TelegramCore/Sources/Utils/Log.swift @@ -108,6 +108,8 @@ public final class Logger { setPostboxLogger({ s in Logger.shared.log("Postbox", s) Logger.shared.shortLog("Postbox", s) + }, sync: { + Logger.shared.sync() }) } @@ -128,6 +130,14 @@ public final class Logger { self.basePath = basePath } + public func sync() { + self.queue.sync { + if let (currentFile, _) = self.file { + let _ = currentFile.sync() + } + } + } + public func collectLogs(prefix: String? = nil) -> Signal<[(String, String)], NoError> { return Signal { subscriber in self.queue.async { diff --git a/submodules/TelegramCore/Sources/WebpagePreview.swift b/submodules/TelegramCore/Sources/WebpagePreview.swift index 6f31de4e6c..2f7a68de23 100644 --- a/submodules/TelegramCore/Sources/WebpagePreview.swift +++ b/submodules/TelegramCore/Sources/WebpagePreview.swift @@ -92,7 +92,7 @@ public func actualizedWebpage(account: Account, webpage: TelegramMediaWebpage) - if let updatedWebpage = telegramMediaWebpageFromApiWebpage(apiWebpage, url: nil), case .Loaded = updatedWebpage.content, updatedWebpage.webpageId == webpage.webpageId { return .single(updatedWebpage) } else if case let .webPageNotModified(_, viewsValue) = apiWebpage, let views = viewsValue, case let .Loaded(content) = webpage.content { - let updatedContent: TelegramMediaWebpageContent = .Loaded(TelegramMediaWebpageLoadedContent(url: content.url, displayUrl: content.displayUrl, hash: content.hash, type: content.type, websiteName: content.websiteName, title: content.title, text: content.text, embedUrl: content.embedUrl, embedType: content.embedType, embedSize: content.embedSize, duration: content.duration, author: content.author, image: content.image, file: content.file, story: content.story, attributes: content.attributes, instantPage: content.instantPage.flatMap({ InstantPage(blocks: $0.blocks, media: $0.media, isComplete: $0.isComplete, rtl: $0.rtl, url: $0.url, views: views) }))) + let updatedContent: TelegramMediaWebpageContent = .Loaded(TelegramMediaWebpageLoadedContent(url: content.url, displayUrl: content.displayUrl, hash: content.hash, type: content.type, websiteName: content.websiteName, title: content.title, text: content.text, embedUrl: content.embedUrl, embedType: content.embedType, embedSize: content.embedSize, duration: content.duration, author: content.author, image: content.image, file: content.file, story: content.story, attributes: content.attributes, instantPage: content.instantPage.flatMap({ InstantPage(blocks: $0.blocks, media: $0.media, isComplete: $0.isComplete, rtl: $0.rtl, url: $0.url, views: views) }), displayOptions: .default)) let updatedWebpage = TelegramMediaWebpage(webpageId: webpage.webpageId, content: updatedContent) updateMessageMedia(transaction: transaction, id: webpage.webpageId, media: updatedWebpage) return .single(updatedWebpage) diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageDateAndStatusNode/Sources/StringForMessageTimestampStatus.swift b/submodules/TelegramUI/Components/Chat/ChatMessageDateAndStatusNode/Sources/StringForMessageTimestampStatus.swift index 23c64b32cc..b62393f58e 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageDateAndStatusNode/Sources/StringForMessageTimestampStatus.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageDateAndStatusNode/Sources/StringForMessageTimestampStatus.swift @@ -87,7 +87,7 @@ public func stringForMessageTimestampStatus(accountPeerId: PeerId, message: Mess } } - if let subject = associatedData.subject, case let .messageOptions(_, _, info, _) = subject, case .forward = info.kind { + if let subject = associatedData.subject, case let .messageOptions(_, _, info) = subject, case .forward = info { authorTitle = nil } diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageTextBubbleContentNode/Sources/ChatMessageTextBubbleContentNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageTextBubbleContentNode/Sources/ChatMessageTextBubbleContentNode.swift index 973930b7a3..35c9fc5d82 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageTextBubbleContentNode/Sources/ChatMessageTextBubbleContentNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageTextBubbleContentNode/Sources/ChatMessageTextBubbleContentNode.swift @@ -67,6 +67,8 @@ public class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { private var cachedChatMessageText: CachedChatMessageText? + private var textSelectionState: Promise? + override public var visibility: ListViewItemNodeVisibility { didSet { if oldValue != self.visibility { @@ -140,7 +142,7 @@ public class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { let message = item.message let incoming: Bool - if let subject = item.associatedData.subject, case let .messageOptions(_, _, info, _) = subject, case .forward = info.kind { + if let subject = item.associatedData.subject, case let .messageOptions(_, _, info) = subject, case .forward = info { incoming = false } else { incoming = item.message.effectivelyIncoming(item.context.account.peerId) @@ -564,16 +566,28 @@ public class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { strongSelf.statusNode.pressed = nil } - if let subject = item.associatedData.subject, case let .messageOptions(_, _, info, _) = subject, case let .reply(initialQuote) = info.kind { + if let subject = item.associatedData.subject, case let .messageOptions(_, _, info) = subject, case let .reply(info) = info { if strongSelf.textSelectionNode == nil { strongSelf.updateIsExtractedToContextPreview(true) - if let initialQuote, item.message.id == initialQuote.messageId, let string = strongSelf.textNode.textNode.cachedLayout?.attributedString { + if let initialQuote = info.quote, item.message.id == initialQuote.messageId, let string = strongSelf.textNode.textNode.cachedLayout?.attributedString { let nsString = string.string as NSString let subRange = nsString.range(of: initialQuote.text) if subRange.location != NSNotFound { strongSelf.beginTextSelection(range: subRange, displayMenu: false) } } + + if strongSelf.textSelectionState == nil { + if let textSelectionNode = strongSelf.textSelectionNode { + let range = textSelectionNode.getSelection() + strongSelf.textSelectionState = Promise(strongSelf.getSelectionState(range: range)) + } else { + strongSelf.textSelectionState = Promise(strongSelf.getSelectionState(range: nil)) + } + } + if let textSelectionState = strongSelf.textSelectionState { + info.selectionState.set(textSelectionState.get()) + } } } } @@ -799,11 +813,11 @@ public class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { return } - /*if let subject = item.associatedData.subject, case let .messageOptions(_, _, info, _) = subject, case .reply = info.kind { + if let subject = item.associatedData.subject, case let .messageOptions(_, _, info) = subject, case .reply = info { item.controllerInteraction.presentControllerInCurrent(c, a) - } else {*/ + } else { item.controllerInteraction.presentGlobalOverlayController(c, a) - //} + } }, rootNode: { [weak rootNode] in return rootNode }, performAction: { [weak self] text, action in @@ -813,7 +827,10 @@ public class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { item.controllerInteraction.performTextSelectionAction(item.message, true, text, action) }) textSelectionNode.updateRange = { [weak self] selectionRange in - if let strongSelf = self, let dustNode = strongSelf.dustNode, !dustNode.isRevealed, let textLayout = strongSelf.textNode.textNode.cachedLayout, !textLayout.spoilers.isEmpty, let selectionRange = selectionRange { + guard let strongSelf = self else { + return + } + if let dustNode = strongSelf.dustNode, !dustNode.isRevealed, let textLayout = strongSelf.textNode.textNode.cachedLayout, !textLayout.spoilers.isEmpty, let selectionRange = selectionRange { for (spoilerRange, _) in textLayout.spoilers { if let intersection = selectionRange.intersection(spoilerRange), intersection.length > 0 { dustNode.update(revealed: true) @@ -821,23 +838,25 @@ public class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { } } } + if let textSelectionState = strongSelf.textSelectionState { + textSelectionState.set(.single(strongSelf.getSelectionState(range: selectionRange))) + } } let enableCopy = !item.associatedData.isCopyProtectionEnabled && !item.message.isCopyProtected() textSelectionNode.enableCopy = enableCopy - var enableQuote = false + let enableQuote = true var enableOtherActions = true - if let subject = item.associatedData.subject, case let .messageOptions(_, _, info, _) = subject, case .reply = info.kind { - enableQuote = true + if let subject = item.associatedData.subject, case let .messageOptions(_, _, info) = subject, case .reply = info { enableOtherActions = false } else if item.controllerInteraction.canSetupReply(item.message) == .reply { - enableQuote = true enableOtherActions = false } textSelectionNode.enableQuote = enableQuote textSelectionNode.enableTranslate = enableOtherActions textSelectionNode.enableShare = enableOtherActions + textSelectionNode.menuSkipCoordnateConversion = !enableOtherActions self.textSelectionNode = textSelectionNode self.addSubnode(textSelectionNode) self.insertSubnode(textSelectionNode.highlightAreaNode, belowSubnode: self.textNode.textNode) @@ -904,11 +923,26 @@ public class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { textSelectionNode.setSelection(range: range, displayMenu: displayMenu) } - public func getCurrentTextSelection() -> (text: String, entities: [MessageTextEntity])? { + public func cancelTextSelection() { + guard let textSelectionNode = self.textSelectionNode else { + return + } + textSelectionNode.cancelSelection() + } + + private func getSelectionState(range: NSRange?) -> ChatControllerSubject.MessageOptionsInfo.SelectionState { + var quote: ChatControllerSubject.MessageOptionsInfo.Quote? + if let item = self.item, let range, let selection = self.getCurrentTextSelection(customRange: range) { + quote = ChatControllerSubject.MessageOptionsInfo.Quote(messageId: item.message.id, text: selection.text) + } + return ChatControllerSubject.MessageOptionsInfo.SelectionState(quote: quote) + } + + public func getCurrentTextSelection(customRange: NSRange? = nil) -> (text: String, entities: [MessageTextEntity])? { guard let textSelectionNode = self.textSelectionNode else { return nil } - guard let range = textSelectionNode.getSelection() else { + guard let range = customRange ?? textSelectionNode.getSelection() else { return nil } guard let item = self.item else { diff --git a/submodules/TelegramUI/Components/PeerSelectionController/Sources/PeerSelectionControllerNode.swift b/submodules/TelegramUI/Components/PeerSelectionController/Sources/PeerSelectionControllerNode.swift index 7046ba6e7f..8ee9d2fb4a 100644 --- a/submodules/TelegramUI/Components/PeerSelectionController/Sources/PeerSelectionControllerNode.swift +++ b/submodules/TelegramUI/Components/PeerSelectionController/Sources/PeerSelectionControllerNode.swift @@ -362,14 +362,14 @@ final class PeerSelectionControllerNode: ASDisplayNode { let forwardOptions: Signal forwardOptions = strongSelf.presentationInterfaceStatePromise.get() |> map { state -> ChatControllerSubject.ForwardOptions in - return ChatControllerSubject.ForwardOptions(hideNames: state.interfaceState.forwardOptionsState?.hideNames ?? false, hideCaptions: state.interfaceState.forwardOptionsState?.hideCaptions ?? false, replyOptions: nil) + return ChatControllerSubject.ForwardOptions(hideNames: state.interfaceState.forwardOptionsState?.hideNames ?? false, hideCaptions: state.interfaceState.forwardOptionsState?.hideCaptions ?? false) } |> distinctUntilChanged let chatController = strongSelf.context.sharedContext.makeChatController( context: strongSelf.context, chatLocation: .peer(id: strongSelf.context.account.peerId), - subject: .messageOptions(peerIds: peerIds, ids: strongSelf.presentationInterfaceState.interfaceState.forwardMessageIds ?? [], info: ChatControllerSubject.MessageOptionsInfo(kind: .forward), options: forwardOptions), + subject: .messageOptions(peerIds: peerIds, ids: strongSelf.presentationInterfaceState.interfaceState.forwardMessageIds ?? [], info: .forward(ChatControllerSubject.MessageOptionsInfo.Forward(options: forwardOptions))), botStart: nil, mode: .standard(previewing: true) ) @@ -544,6 +544,7 @@ final class PeerSelectionControllerNode: ASDisplayNode { contextController.immediateItemsTransitionAnimation = true strongSelf.controller?.presentInGlobalOverlay(contextController) }, presentReplyOptions: { _ in + }, presentLinkOptions: { _ in }, shareSelectedMessages: { }, updateTextInputStateAndMode: { [weak self] f in if let strongSelf = self { diff --git a/submodules/TelegramUI/Sources/Chat/ChatMessageActionOptions.swift b/submodules/TelegramUI/Sources/Chat/ChatMessageActionOptions.swift index 4cfdeb4c8e..b9a1f79a78 100644 --- a/submodules/TelegramUI/Sources/Chat/ChatMessageActionOptions.swift +++ b/submodules/TelegramUI/Sources/Chat/ChatMessageActionOptions.swift @@ -12,11 +12,68 @@ import ContextUI import ChatInterfaceState import PresentationDataUtils import ChatMessageTextBubbleContentNode +import TextFormat -func presentChatForwardOptions(selfController: ChatControllerImpl, sourceNode: ASDisplayNode) { - guard let peerId = selfController.chatLocation.peerId else { +private enum OptionsId: Hashable { + case reply + case forward + case link +} + +private func presentChatInputOptions(selfController: ChatControllerImpl, sourceNode: ASDisplayNode, initialId: OptionsId) { + var getContextController: (() -> ContextController?)? + + var sources: [ContextController.Source] = [] + + let replySelectionState = Promise(ChatControllerSubject.MessageOptionsInfo.SelectionState(quote: nil)) + + if let source = chatForwardOptions(selfController: selfController, sourceNode: sourceNode, getContextController: { + return getContextController?() + }) { + sources.append(source) + } + if let source = chatReplyOptions(selfController: selfController, sourceNode: sourceNode, getContextController: { + return getContextController?() + }, selectionState: replySelectionState) { + sources.append(source) + } + + if let source = chatLinkOptions(selfController: selfController, sourceNode: sourceNode, getContextController: { + return getContextController?() + }, replySelectionState: replySelectionState) { + sources.append(source) + } + + if sources.isEmpty { return } + + selfController.chatDisplayNode.messageTransitionNode.dismissMessageReactionContexts() + + selfController.canReadHistory.set(false) + + let contextController = ContextController( + presentationData: selfController.presentationData, + configuration: ContextController.Configuration( + sources: sources, + initialId: AnyHashable(initialId) + ) + ) + contextController.dismissed = { [weak selfController] in + selfController?.canReadHistory.set(true) + } + + getContextController = { [weak contextController] in + return contextController + } + + selfController.presentInGlobalOverlay(contextController) +} + +private func chatForwardOptions(selfController: ChatControllerImpl, sourceNode: ASDisplayNode, getContextController: @escaping () -> ContextController?) -> ContextController.Source? { + guard let peerId = selfController.chatLocation.peerId else { + return nil + } let presentationData = selfController.presentationData let forwardOptions = selfController.presentationInterfaceStatePromise.get() @@ -25,11 +82,11 @@ func presentChatForwardOptions(selfController: ChatControllerImpl, sourceNode: A if peerId.namespace == Namespaces.Peer.SecretChat { hideNames = true } - return ChatControllerSubject.ForwardOptions(hideNames: hideNames, hideCaptions: state.interfaceState.forwardOptionsState?.hideCaptions ?? false, replyOptions: nil) + return ChatControllerSubject.ForwardOptions(hideNames: hideNames, hideCaptions: state.interfaceState.forwardOptionsState?.hideCaptions ?? false) } |> distinctUntilChanged - let chatController = selfController.context.sharedContext.makeChatController(context: selfController.context, chatLocation: .peer(id: peerId), subject: .messageOptions(peerIds: [peerId], ids: selfController.presentationInterfaceState.interfaceState.forwardMessageIds ?? [], info: ChatControllerSubject.MessageOptionsInfo(kind: .forward), options: forwardOptions), botStart: nil, mode: .standard(previewing: true)) + let chatController = selfController.context.sharedContext.makeChatController(context: selfController.context, chatLocation: .peer(id: peerId), subject: .messageOptions(peerIds: [peerId], ids: selfController.presentationInterfaceState.interfaceState.forwardMessageIds ?? [], info: .forward(ChatControllerSubject.MessageOptionsInfo.Forward(options: forwardOptions))), botStart: nil, mode: .standard(previewing: true)) chatController.canReadHistory.set(false) let messageIds = selfController.presentationInterfaceState.interfaceState.forwardMessageIds ?? [] @@ -201,15 +258,49 @@ func presentChatForwardOptions(selfController: ChatControllerImpl, sourceNode: A return items } + //TODO:localize + return ContextController.Source( + id: AnyHashable(OptionsId.forward), + title: "Forward", + source: .controller(ChatContextControllerContentSourceImpl(controller: chatController, sourceNode: sourceNode, passthroughTouches: true)), + items: items |> map { ContextController.Items(content: .list($0)) } + ) +} + +func presentChatForwardOptions(selfController: ChatControllerImpl, sourceNode: ASDisplayNode) { + if "".isEmpty { + presentChatInputOptions(selfController: selfController, sourceNode: sourceNode, initialId: .forward) + return + } + + var getContextController: (() -> ContextController?)? + + guard let source = chatForwardOptions(selfController: selfController, sourceNode: sourceNode, getContextController: { + return getContextController?() + }) else { + return + } selfController.chatDisplayNode.messageTransitionNode.dismissMessageReactionContexts() selfController.canReadHistory.set(false) - let contextController = ContextController(presentationData: selfController.presentationData, source: .controller(ChatContextControllerContentSourceImpl(controller: chatController, sourceNode: sourceNode, passthroughTouches: true)), items: items |> map { ContextController.Items(content: .list($0)) }) + let contextController = ContextController( + presentationData: selfController.presentationData, + configuration: ContextController.Configuration( + sources: [source], + initialId: source.id + ) + ) contextController.dismissed = { [weak selfController] in selfController?.canReadHistory.set(true) } - contextController.dismissedForCancel = { [weak selfController, weak chatController] in + + getContextController = { [weak contextController] in + return contextController + } + + //TODO:loc + /*contextController.dismissedForCancel = { [weak selfController, weak chatController] in guard let selfController else { return } @@ -218,8 +309,7 @@ func presentChatForwardOptions(selfController: ChatControllerImpl, sourceNode: A forwardMessageIds = forwardMessageIds.filter { selectedMessageIds.contains($0) } selfController.updateChatPresentationInterfaceState(interactive: false, { $0.updatedInterfaceState({ $0.withUpdatedForwardMessageIds(forwardMessageIds) }) }) } - } - contextController.immediateItemsTransitionAnimation = true + }*/ selfController.presentInGlobalOverlay(contextController) } @@ -228,12 +318,9 @@ private func generateChatReplyOptionItems(selfController: ChatControllerImpl, ch return .complete() } - let messageIds: [EngineMessage.Id] = [replySubject.messageId] - let messagesCount: Signal = .single(1) - - let items = combineLatest(selfController.context.account.postbox.messagesAtIds(messageIds), messagesCount) + let items = selfController.context.account.postbox.messagesAtIds([replySubject.messageId]) |> deliverOnMainQueue - |> map { [weak selfController, weak chatController] messages, messagesCount -> [ContextMenuItem] in + |> map { [weak selfController, weak chatController] messages -> [ContextMenuItem] in guard let selfController, let chatController else { return [] } @@ -297,12 +384,8 @@ private func generateChatReplyOptionItems(selfController: ChatControllerImpl, ch subItems.append(.action(ContextMenuActionItem(text: selfController.presentationData.strings.Common_Back, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Back"), color: theme.contextMenu.primaryColor) - }, iconPosition: .left, action: { [weak selfController, weak chatController] c, _ in - guard let selfController, let chatController else { - return - } - c.setItems(generateChatReplyOptionItems(selfController: selfController, chatController: chatController), minHeight: nil, previousActionsTransition: .slide(forward: false)) - //c.popItems() + }, iconPosition: .left, action: { c, _ in + c.popItems() }))) subItems.append(.separator) @@ -322,11 +405,12 @@ private func generateChatReplyOptionItems(selfController: ChatControllerImpl, ch f(.default) }))) - //c.pushItems(items: .single(ContextController.Items(content: .list(subItems)))) - - let minHeight = c.getActionsMinHeight() - c.immediateItemsTransitionAnimation = false - c.setItems(.single(ContextController.Items(content: .list(subItems))), minHeight: minHeight, previousActionsTransition: .slide(forward: true)) + c.pushItems(items: .single(ContextController.Items(content: .list(subItems), dismissed: { [weak contentNode] in + guard let contentNode else { + return + } + contentNode.cancelTextSelection() + }))) break } @@ -371,45 +455,29 @@ private func generateChatReplyOptionItems(selfController: ChatControllerImpl, ch return items |> map { ContextController.Items(content: .list($0), tip: tip) } } -func presentChatReplyOptions(selfController: ChatControllerImpl, sourceNode: ASDisplayNode) { +private func chatReplyOptions(selfController: ChatControllerImpl, sourceNode: ASDisplayNode, getContextController: @escaping () -> ContextController?, selectionState: Promise) -> ContextController.Source? { guard let peerId = selfController.chatLocation.peerId else { - return + return nil } guard let replySubject = selfController.presentationInterfaceState.interfaceState.replyMessageSubject else { - return + return nil } - let replyOptionsSubject = Promise() - replyOptionsSubject.set(.single(ChatControllerSubject.ForwardOptions(hideNames: false, hideCaptions: false, replyOptions: ChatControllerSubject.ReplyOptions(hasQuote: replySubject.quote != nil)))) - - //let presentationData = selfController.presentationData - - var replyQuote: ChatControllerSubject.MessageOptionsInfo.ReplyQuote? + var replyQuote: ChatControllerSubject.MessageOptionsInfo.Quote? if let quote = replySubject.quote { - replyQuote = ChatControllerSubject.MessageOptionsInfo.ReplyQuote(messageId: replySubject.messageId, text: quote.text) + replyQuote = ChatControllerSubject.MessageOptionsInfo.Quote(messageId: replySubject.messageId, text: quote.text) } - guard let chatController = selfController.context.sharedContext.makeChatController(context: selfController.context, chatLocation: .peer(id: peerId), subject: .messageOptions(peerIds: [replySubject.messageId.peerId], ids: [replySubject.messageId], info: ChatControllerSubject.MessageOptionsInfo(kind: .reply(initialQuote: replyQuote)), options: replyOptionsSubject.get()), botStart: nil, mode: .standard(previewing: true)) as? ChatControllerImpl else { - return + selectionState.set(.single(ChatControllerSubject.MessageOptionsInfo.SelectionState(quote: replyQuote))) + + guard let chatController = selfController.context.sharedContext.makeChatController(context: selfController.context, chatLocation: .peer(id: peerId), subject: .messageOptions(peerIds: [replySubject.messageId.peerId], ids: [replySubject.messageId], info: .reply(ChatControllerSubject.MessageOptionsInfo.Reply(quote: replyQuote, selectionState: selectionState))), botStart: nil, mode: .standard(previewing: true)) as? ChatControllerImpl else { + return nil } chatController.canReadHistory.set(false) let items = generateChatReplyOptionItems(selfController: selfController, chatController: chatController) - selfController.chatDisplayNode.messageTransitionNode.dismissMessageReactionContexts() - - selfController.canReadHistory.set(false) - - let contextController = ContextController(presentationData: selfController.presentationData, source: .controller(ChatContextControllerContentSourceImpl(controller: chatController, sourceNode: sourceNode, passthroughTouches: true)), items: items) - contextController.dismissed = { [weak selfController] in - selfController?.canReadHistory.set(true) - } - contextController.dismissedForCancel = { - } - contextController.immediateItemsTransitionAnimation = true - selfController.presentInGlobalOverlay(contextController) - - chatController.performTextSelectionAction = { [weak selfController, weak contextController] message, canCopy, text, action in - guard let selfController, let contextController else { + chatController.performTextSelectionAction = { [weak selfController] message, canCopy, text, action in + guard let selfController, let contextController = getContextController() else { return } @@ -417,6 +485,18 @@ func presentChatReplyOptions(selfController: ChatControllerImpl, sourceNode: ASD selfController.controllerInteraction?.performTextSelectionAction(message, canCopy, text, action) } + + //TODO:localize + return ContextController.Source( + id: AnyHashable(OptionsId.reply), + title: "Reply", + source: .controller(ChatContextControllerContentSourceImpl(controller: chatController, sourceNode: sourceNode, passthroughTouches: true)), + items: items + ) +} + +func presentChatReplyOptions(selfController: ChatControllerImpl, sourceNode: ASDisplayNode) { + presentChatInputOptions(selfController: selfController, sourceNode: sourceNode, initialId: .reply) } func moveReplyMessageToAnotherChat(selfController: ChatControllerImpl, replySubject: ChatInterfaceState.ReplyMessageSubject) { @@ -537,3 +617,205 @@ func moveReplyMessageToAnotherChat(selfController: ChatControllerImpl, replySubj selfController.effectiveNavigationController?.pushViewController(controller) }) } + +private func chatLinkOptions(selfController: ChatControllerImpl, sourceNode: ASDisplayNode, getContextController: @escaping () -> ContextController?, replySelectionState: Promise) -> ContextController.Source? { + guard let peerId = selfController.chatLocation.peerId else { + return nil + } + guard let initialUrlPreview = selfController.presentationInterfaceState.urlPreview else { + return nil + } + + let linkOptions = combineLatest(queue: .mainQueue(), + selfController.presentationInterfaceStatePromise.get(), + replySelectionState.get() + ) + |> map { state, replySelectionState -> ChatControllerSubject.LinkOptions in + let urlPreview = state.urlPreview ?? initialUrlPreview + + var webpageOptions: TelegramMediaWebpageDisplayOptions = .default + + if let (_, webpage) = state.urlPreview, case let .Loaded(content) = webpage.content { + webpageOptions = content.displayOptions + } + + return ChatControllerSubject.LinkOptions( + messageText: state.interfaceState.composeInputState.inputText.string, + messageEntities: generateChatInputTextEntities(state.interfaceState.composeInputState.inputText, generateLinks: true), + replyMessageId: state.interfaceState.replyMessageSubject?.messageId, + replyQuote: replySelectionState.quote?.text, + url: urlPreview.0, + webpage: urlPreview.1, + linkBelowText: webpageOptions.position != .aboveText, + largeMedia: webpageOptions.largeMedia != false + ) + } + |> distinctUntilChanged + + guard let chatController = selfController.context.sharedContext.makeChatController(context: selfController.context, chatLocation: .peer(id: peerId), subject: .messageOptions(peerIds: [peerId], ids: selfController.presentationInterfaceState.interfaceState.forwardMessageIds ?? [], info: .link(ChatControllerSubject.MessageOptionsInfo.Link(options: linkOptions))), botStart: nil, mode: .standard(previewing: true)) as? ChatControllerImpl else { + return nil + } + chatController.canReadHistory.set(false) + + let items = linkOptions + |> deliverOnMainQueue + |> map { [weak selfController] linkOptions -> [ContextMenuItem] in + guard let selfController else { + return [] + } + var items: [ContextMenuItem] = [] + + if "".isEmpty { + //TODO:localize + + items.append(.action(ContextMenuActionItem(text: "Above the Message", icon: { theme in + if linkOptions.linkBelowText { + return nil + } else { + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Check"), color: theme.contextMenu.primaryColor) + } + }, action: { [weak selfController] _, f in + selfController?.updateChatPresentationInterfaceState(animated: true, interactive: true, { state in + guard var urlPreview = state.urlPreview else { + return state + } + if case let .Loaded(content) = urlPreview.1.content { + var displayOptions = content.displayOptions + displayOptions.position = .aboveText + urlPreview = (urlPreview.0, TelegramMediaWebpage(webpageId: urlPreview.1.webpageId, content: .Loaded(content.withDisplayOptions(displayOptions)))) + } + return state.updatedUrlPreview(urlPreview) + }) + }))) + + items.append(.action(ContextMenuActionItem(text: "Below the Message", icon: { theme in + if !linkOptions.linkBelowText { + return nil + } else { + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Check"), color: theme.contextMenu.primaryColor) + } + }, action: { [weak selfController] _, f in + selfController?.updateChatPresentationInterfaceState(animated: true, interactive: true, { state in + guard var urlPreview = state.urlPreview else { + return state + } + if case let .Loaded(content) = urlPreview.1.content { + var displayOptions = content.displayOptions + displayOptions.position = .belowText + urlPreview = (urlPreview.0, TelegramMediaWebpage(webpageId: urlPreview.1.webpageId, content: .Loaded(content.withDisplayOptions(displayOptions)))) + } + return state.updatedUrlPreview(urlPreview) + }) + }))) + } + + if "".isEmpty { + if !items.isEmpty { + items.append(.separator) + } + + //TODO:localize + + items.append(.action(ContextMenuActionItem(text: "Smaller Media", icon: { theme in + if linkOptions.largeMedia { + return nil + } else { + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Check"), color: theme.contextMenu.primaryColor) + } + }, action: { [weak selfController] _, f in + selfController?.updateChatPresentationInterfaceState(animated: true, interactive: true, { state in + guard var urlPreview = state.urlPreview else { + return state + } + if case let .Loaded(content) = urlPreview.1.content { + var displayOptions = content.displayOptions + displayOptions.largeMedia = false + urlPreview = (urlPreview.0, TelegramMediaWebpage(webpageId: urlPreview.1.webpageId, content: .Loaded(content.withDisplayOptions(displayOptions)))) + } + return state.updatedUrlPreview(urlPreview) + }) + }))) + + items.append(.action(ContextMenuActionItem(text: "Larger Media", icon: { theme in + if !linkOptions.largeMedia { + return nil + } else { + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Check"), color: theme.contextMenu.primaryColor) + } + }, action: { [weak selfController] _, f in + selfController?.updateChatPresentationInterfaceState(animated: true, interactive: true, { state in + guard var urlPreview = state.urlPreview else { + return state + } + if case let .Loaded(content) = urlPreview.1.content { + var displayOptions = content.displayOptions + displayOptions.largeMedia = true + urlPreview = (urlPreview.0, TelegramMediaWebpage(webpageId: urlPreview.1.webpageId, content: .Loaded(content.withDisplayOptions(displayOptions)))) + } + return state.updatedUrlPreview(urlPreview) + }) + }))) + } + + if !items.isEmpty { + items.append(.separator) + } + + //TODO:localize + items.append(.action(ContextMenuActionItem(text: "Remove Link Preview", textColor: .destructive, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Delete"), color: theme.contextMenu.destructiveColor) }, action: { [weak selfController, weak chatController] c, f in + guard let selfController else { + return + } + //selfController.updateChatPresentationInterfaceState(interactive: false, { $0.updatedInterfaceState({ $0.withUpdatedForwardMessageIds(forwardMessageIds) }) }) + //selfController.controllerInteraction?.sendCurrentMessage(false) + + let _ = selfController + let _ = chatController + + f(.default) + }))) + + return items + } + + chatController.performOpenURL = { [weak selfController] message, url in + guard let selfController else { + return + } + + //TODO: + //func urlPreviewStateForInputText(_ inputText: NSAttributedString?, context: AccountContext, currentQuery: String?) -> (String?, Signal<(TelegramMediaWebpage?) -> TelegramMediaWebpage?, NoError>)? { + if let (updatedUrlPreviewUrl, signal) = urlPreviewStateForInputText(NSAttributedString(string: url), context: selfController.context, currentQuery: nil), let updatedUrlPreviewUrl { + let _ = (signal + |> deliverOnMainQueue).start(next: { [weak selfController] result in + guard let selfController else { + return + } + + selfController.updateChatPresentationInterfaceState(animated: true, interactive: false, { state in + if let webpage = result(nil), var urlPreview = state.urlPreview { + if case let .Loaded(content) = urlPreview.1.content, case let .Loaded(newContent) = webpage.content { + urlPreview = (updatedUrlPreviewUrl, TelegramMediaWebpage(webpageId: webpage.webpageId, content: .Loaded(newContent.withDisplayOptions(content.displayOptions)))) + } + + return state.updatedUrlPreview(urlPreview) + } else { + return state + } + }) + }) + } + } + + //TODO:localize + return ContextController.Source( + id: AnyHashable(OptionsId.link), + title: "Link", + source: .controller(ChatContextControllerContentSourceImpl(controller: chatController, sourceNode: sourceNode, passthroughTouches: true)), + items: items |> map { ContextController.Items(content: .list($0)) } + ) +} + +func presentChatLinkOptions(selfController: ChatControllerImpl, sourceNode: ASDisplayNode) { + presentChatInputOptions(selfController: selfController, sourceNode: sourceNode, initialId: .link) +} diff --git a/submodules/TelegramUI/Sources/ChatController.swift b/submodules/TelegramUI/Sources/ChatController.swift index 2835094022..936f1a025c 100644 --- a/submodules/TelegramUI/Sources/ChatController.swift +++ b/submodules/TelegramUI/Sources/ChatController.swift @@ -545,6 +545,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G var storyStats: PeerStoryStats? var performTextSelectionAction: ((Message?, Bool, NSAttributedString, TextSelectionAction) -> Void)? + var performOpenURL: ((Message?, String) -> Void)? public init(context: AccountContext, chatLocation: ChatLocation, chatLocationContextHolder: Atomic = Atomic(value: nil), subject: ChatControllerSubject? = nil, botStart: ChatControllerInitialBotStart? = nil, attachBotStart: ChatControllerInitialAttachBotStart? = nil, botAppStart: ChatControllerInitialBotAppStart? = nil, mode: ChatControllerPresentationMode = .standard(previewing: false), peekData: ChatPeekTimeout? = nil, peerNearbyData: ChatPeerNearbyData? = nil, chatListFilter: Int32? = nil, chatNavigationStack: [ChatNavigationStackItem] = []) { let _ = ChatControllerCount.modify { value in @@ -2756,7 +2757,11 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G strongSelf.chatDisplayNode.historyNode.adMessagesContext?.markAction(opaqueId: adAttribute.opaqueId) } - strongSelf.openUrl(url, concealed: concealed, skipConcealedAlert: skipConcealedAlert, message: message) + if let performOpenURL = strongSelf.performOpenURL { + performOpenURL(message, url) + } else { + strongSelf.openUrl(url, concealed: concealed, skipConcealedAlert: skipConcealedAlert, message: message) + } } }, shareCurrentLocation: { [weak self] in if let strongSelf = self { @@ -3948,35 +3953,42 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G f() } case let .quote(range): - if let currentContextController = strongSelf.currentContextController { - currentContextController.dismiss(completion: { - }) + let completion: (ContainedViewLayoutTransition?) -> Void = { transition in + guard let self else { + return + } + if let currentContextController = self.currentContextController { + self.currentContextController = nil + + if let transition { + currentContextController.dismissWithCustomTransition(transition: transition) + } else { + currentContextController.dismiss(completion: {}) + } + } } - - let completion: (ContainedViewLayoutTransition) -> Void = { _ in } - if let messageId = message?.id { + if let messageId = message?.id, let message = strongSelf.chatDisplayNode.historyNode.messageInCurrentHistoryView(messageId) { + var quoteData: EngineMessageReplyQuote? + + let quoteText = (message.text as NSString).substring(with: NSRange(location: range.lowerBound, length: range.upperBound - range.lowerBound)) + quoteData = EngineMessageReplyQuote(text: quoteText, entities: []) + + let replySubject = ChatInterfaceState.ReplyMessageSubject( + messageId: message.id, + quote: quoteData + ) + if canSendMessagesToChat(strongSelf.presentationInterfaceState) { let _ = strongSelf.presentVoiceMessageDiscardAlert(action: { - if let message = strongSelf.chatDisplayNode.historyNode.messageInCurrentHistoryView(messageId) { - var quoteData: EngineMessageReplyQuote? - - let quoteText = (message.text as NSString).substring(with: NSRange(location: range.lowerBound, length: range.upperBound - range.lowerBound)) - quoteData = EngineMessageReplyQuote(text: quoteText, entities: []) - - strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: true, { $0.updatedInterfaceState({ $0.withUpdatedReplyMessageSubject(ChatInterfaceState.ReplyMessageSubject( - messageId: message.id, - quote: quoteData - )) }).updatedSearch(nil).updatedShowCommands(false) }, completion: completion) - strongSelf.updateItemNodesSearchTextHighlightStates() - strongSelf.chatDisplayNode.ensureInputViewFocused() - } else { - completion(.immediate) - } + strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: true, { $0.updatedInterfaceState({ $0.withUpdatedReplyMessageSubject(replySubject) }).updatedSearch(nil).updatedShowCommands(false) }, completion: completion) + strongSelf.updateItemNodesSearchTextHighlightStates() + strongSelf.chatDisplayNode.ensureInputViewFocused() }, alertAction: { - completion(.immediate) + completion(nil) }, delay: true) } else { - completion(.immediate) + moveReplyMessageToAnotherChat(selfController: strongSelf, replySubject: replySubject) + completion(nil) } } else { strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: true, { $0.updatedInterfaceState({ $0.withUpdatedReplyMessageSubject(nil) }) }, completion: completion) @@ -5008,6 +5020,11 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G //} self.chatTitleView = ChatTitleView(context: self.context, theme: self.presentationData.theme, strings: self.presentationData.strings, dateTimeFormat: self.presentationData.dateTimeFormat, nameDisplayOrder: self.presentationData.nameDisplayOrder, animationCache: controllerInteraction.presentationContext.animationCache, animationRenderer: controllerInteraction.presentationContext.animationRenderer) + + if case .messageOptions = self.subject { + self.chatTitleView?.disableAnimations = true + } + self.navigationItem.titleView = self.chatTitleView self.chatTitleView?.longPressed = { [weak self] in if let strongSelf = self, let peerView = strongSelf.peerView, let peer = peerView.peers[peerView.peerId], peer.restrictionText(platform: "ios", contentSettings: strongSelf.context.currentContentSettings.with { $0 }) == nil && !strongSelf.presentationInterfaceState.isNotAccessible { @@ -5208,7 +5225,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G return message?.totalCount } |> distinctUntilChanged - } else if case let .messageOptions(peerIds, messageIds, info, options) = subject { + } else if case let .messageOptions(peerIds, messageIds, info) = subject { displayedCountSignal = self.presentationInterfaceStatePromise.get() |> map { state -> Int? in if let selectionState = state.interfaceState.selectionState { @@ -5224,9 +5241,9 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G let presentationData = self.presentationData - switch info.kind { - case .forward: - subtitleTextSignal = combineLatest(peers, options, displayedCountSignal) + switch info { + case let .forward(forward): + subtitleTextSignal = combineLatest(peers, forward.options, displayedCountSignal) |> map { peersView, options, count in let peers = peersView.peers.values if !peers.isEmpty { @@ -5297,6 +5314,9 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G case .reply: //TODO:localize subtitleTextSignal = .single("You can select a specific part to quote") + case .link: + //TODO:localize + subtitleTextSignal = .single("Tap on a link to generate its preview") } } @@ -5309,9 +5329,31 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } else { hasPeerInfo = .single(true) } + + enum MessageOptionsTitleInfo { + case reply(hasQuote: Bool) + } + let messageOptionsTitleInfo: Signal + if case let .messageOptions(_, _, info) = self.subject { + switch info { + case .forward, .link: + messageOptionsTitleInfo = .single(nil) + case let .reply(reply): + messageOptionsTitleInfo = reply.selectionState.get() + |> map { selectionState -> Bool in + return selectionState.quote != nil + } + |> distinctUntilChanged + |> map { hasQuote -> MessageOptionsTitleInfo in + return .reply(hasQuote: hasQuote) + } + } + } else { + messageOptionsTitleInfo = .single(nil) + } - self.titleDisposable.set((combineLatest(queue: Queue.mainQueue(), peerView.get(), onlineMemberCount, displayedCountSignal, subtitleTextSignal, self.presentationInterfaceStatePromise.get(), hasPeerInfo) - |> deliverOnMainQueue).startStrict(next: { [weak self] peerView, onlineMemberCount, displayedCount, subtitleText, presentationInterfaceState, hasPeerInfo in + self.titleDisposable.set((combineLatest(queue: Queue.mainQueue(), peerView.get(), onlineMemberCount, displayedCountSignal, subtitleTextSignal, self.presentationInterfaceStatePromise.get(), hasPeerInfo, messageOptionsTitleInfo) + |> deliverOnMainQueue).startStrict(next: { [weak self] peerView, onlineMemberCount, displayedCount, subtitleText, presentationInterfaceState, hasPeerInfo, messageOptionsTitleInfo in if let strongSelf = self { var isScheduledMessages = false if case .scheduledMessages = presentationInterfaceState.subject { @@ -5319,14 +5361,17 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } if let peer = peerViewMainPeer(peerView) { - if case let .messageOptions(_, _, info, _) = presentationInterfaceState.subject { - if case let .reply(initialQuote) = info.kind { + if case let .messageOptions(_, _, info) = presentationInterfaceState.subject { + if case .reply = info { //TODO:localize - if initialQuote != nil { + if case let .reply(hasQuote) = messageOptionsTitleInfo, hasQuote { strongSelf.chatTitleView?.titleContent = .custom("Reply to Quote", subtitleText, false) } else { strongSelf.chatTitleView?.titleContent = .custom("Reply to Message", subtitleText, false) } + } else if case .link = info { + //TODO:localize + strongSelf.chatTitleView?.titleContent = .custom("Link Preview Settings", subtitleText, false) } else if displayedCount == 1 { strongSelf.chatTitleView?.titleContent = .custom(presentationInterfaceState.strings.Conversation_ForwardOptions_ForwardTitleSingle, subtitleText, false) } else { @@ -6661,7 +6706,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } }) - if case let .messageOptions(_, messageIds, _, _) = self.subject, messageIds.count > 1 { + if case let .messageOptions(_, messageIds, _) = self.subject, messageIds.count > 1 { self.updateChatPresentationInterfaceState(interactive: false, { state in return state.updatedInterfaceState({ $0.withUpdatedSelectedMessages(messageIds) }) }) @@ -8852,6 +8897,11 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G return } presentChatReplyOptions(selfController: self, sourceNode: sourceNode) + }, presentLinkOptions: { [weak self] sourceNode in + guard let self else { + return + } + presentChatLinkOptions(selfController: self, sourceNode: sourceNode) }, shareSelectedMessages: { [weak self] in if let strongSelf = self, let selectedIds = strongSelf.presentationInterfaceState.interfaceState.selectionState?.selectedIds, !selectedIds.isEmpty { strongSelf.commitPurposefulAction() @@ -11924,16 +11974,6 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } override public func preferredContentSizeForLayout(_ layout: ContainerViewLayout) -> CGSize? { - switch self.presentationInterfaceState.mode { - case let .standard(previewing): - if previewing { - if let subject = self.subject, case let .messageOptions(_, _, info, _) = subject, case .reply = info.kind { - return self.chatDisplayNode.preferredContentSizeForLayout(layout) - } - } - default: - break - } return nil } diff --git a/submodules/TelegramUI/Sources/ChatControllerNode.swift b/submodules/TelegramUI/Sources/ChatControllerNode.swift index 4bb9b5f6e1..e8d1873a0a 100644 --- a/submodules/TelegramUI/Sources/ChatControllerNode.swift +++ b/submodules/TelegramUI/Sources/ChatControllerNode.swift @@ -394,27 +394,28 @@ class ChatControllerNode: ASDisplayNode, UIScrollViewDelegate { self.inputContextOverTextPanelContainer = ChatControllerTitlePanelNodeContainer() var source: ChatHistoryListSource - if case let .messageOptions(_, messageIds, info, options) = subject { - let messages = combineLatest(context.account.postbox.messagesAtIds(messageIds), context.account.postbox.loadedPeerWithId(context.account.peerId), options) - |> map { messages, accountPeer, options -> ([Message], Int32, Bool) in - var messages = messages - let forwardedMessageIds = Set(messages.map { $0.id }) - messages.sort(by: { lhsMessage, rhsMessage in - return lhsMessage.timestamp > rhsMessage.timestamp - }) - messages = messages.map { message in - var flags = message.flags - flags.remove(.Incoming) - flags.remove(.IsIncomingMask) - - var hideNames = options.hideNames - if message.id.peerId == accountPeer.id && message.forwardInfo == nil { - hideNames = true - } - - var attributes = message.attributes - attributes = attributes.filter({ attribute in - if case .forward = info.kind { + if case let .messageOptions(_, messageIds, info) = subject { + switch info { + case let .forward(forward): + let messages = combineLatest(context.account.postbox.messagesAtIds(messageIds), context.account.postbox.loadedPeerWithId(context.account.peerId), forward.options) + |> map { messages, accountPeer, options -> ([Message], Int32, Bool) in + var messages = messages + let forwardedMessageIds = Set(messages.map { $0.id }) + messages.sort(by: { lhsMessage, rhsMessage in + return lhsMessage.timestamp > rhsMessage.timestamp + }) + messages = messages.map { message in + var flags = message.flags + flags.remove(.Incoming) + flags.remove(.IsIncomingMask) + + var hideNames = options.hideNames + if message.id.peerId == accountPeer.id && message.forwardInfo == nil { + hideNames = true + } + + var attributes = message.attributes + attributes = attributes.filter({ attribute in if attribute is EditedMessageAttribute { return false } @@ -438,15 +439,13 @@ class ChatControllerNode: ASDisplayNode, UIScrollViewDelegate { if attribute is ReactionsMessageAttribute { return false } - } - return true - }) - - var messageText = message.text - var messageMedia = message.media - var hasDice = false - - if case .forward = info.kind { + return true + }) + + var messageText = message.text + var messageMedia = message.media + var hasDice = false + if hideNames { for media in message.media { if options.hideCaptions { @@ -478,14 +477,112 @@ class ChatControllerNode: ASDisplayNode, UIScrollViewDelegate { } return message.withUpdatedFlags(flags).withUpdatedText(messageText).withUpdatedMedia(messageMedia).withUpdatedTimestamp(Int32(context.account.network.context.globalTime())).withUpdatedAttributes(attributes).withUpdatedAuthor(accountPeer).withUpdatedForwardInfo(forwardInfo) - } else { + } + + return (messages, Int32(messages.count), false) + } + source = .custom(messages: messages, messageId: MessageId(peerId: PeerId(0), namespace: 0, id: 0), loadMore: nil) + case .reply: + let messages = combineLatest(context.account.postbox.messagesAtIds(messageIds), context.account.postbox.loadedPeerWithId(context.account.peerId)) + |> map { messages, accountPeer -> ([Message], Int32, Bool) in + var messages = messages + messages.sort(by: { lhsMessage, rhsMessage in + return lhsMessage.timestamp > rhsMessage.timestamp + }) + messages = messages.map { message in return message } + + return (messages, Int32(messages.count), false) } - - return (messages, Int32(messages.count), false) + source = .custom(messages: messages, messageId: MessageId(peerId: PeerId(0), namespace: 0, id: 0), loadMore: nil) + case let .link(link): + let messages = link.options + |> mapToSignal { options -> Signal<(ChatControllerSubject.LinkOptions, Peer, Message?), NoError> in + if let replyMessageId = options.replyMessageId { + return combineLatest( + context.account.postbox.messagesAtIds([replyMessageId]), + context.account.postbox.loadedPeerWithId(context.account.peerId) + ) + |> map { messages, peer -> (ChatControllerSubject.LinkOptions, Peer, Message?) in + return (options, peer, messages.first) + } + } else { + return context.account.postbox.loadedPeerWithId(context.account.peerId) + |> map { peer -> (ChatControllerSubject.LinkOptions, Peer, Message?) in + return (options, peer, nil) + } + } + } + |> map { options, accountPeer, replyMessage -> ([Message], Int32, Bool) in + var peers = SimpleDictionary() + peers[accountPeer.id] = accountPeer + + var associatedMessages = SimpleDictionary() + + var media: [Media] = [] + if case let .Loaded(content) = options.webpage.content { + var displayOptions: TelegramMediaWebpageDisplayOptions = .default + + if options.linkBelowText { + displayOptions.position = .belowText + } else { + displayOptions.position = .aboveText + } + + if options.largeMedia { + displayOptions.largeMedia = true + } else { + displayOptions.largeMedia = false + } + + media.append(TelegramMediaWebpage(webpageId: options.webpage.webpageId, content: .Loaded(content.withDisplayOptions(displayOptions)))) + } + + var attributes: [MessageAttribute] = [] + attributes.append(TextEntitiesMessageAttribute(entities: options.messageEntities)) + + if let replyMessage { + associatedMessages[replyMessage.id] = replyMessage + + var mappedQuote: EngineMessageReplyQuote? + if let quote = options.replyQuote { + mappedQuote = EngineMessageReplyQuote(text: quote, entities: []) + } + + attributes.append(ReplyMessageAttribute(messageId: replyMessage.id, threadMessageId: nil, quote: mappedQuote)) + } + + let message = Message( + stableId: 1, + stableVersion: 1, + id: MessageId(peerId: accountPeer.id, namespace: 0, id: 1), + globallyUniqueId: nil, + groupingKey: nil, + groupInfo: nil, + threadId: nil, + timestamp: Int32(Date().timeIntervalSince1970), + flags: [], + tags: [], + globalTags: [], + localTags: [], + forwardInfo: nil, + author: accountPeer, + text: options.messageText, + attributes: attributes, + media: media, + peers: peers, + associatedMessages: associatedMessages, + associatedMessageIds: [], + associatedMedia: [:], + associatedThreadInfo: nil, + associatedStories: [:] + ) + + return ([message], 1, false) + } + source = .custom(messages: messages, messageId: MessageId(peerId: PeerId(0), namespace: 0, id: 0), loadMore: nil) } - source = .custom(messages: messages, messageId: MessageId(peerId: PeerId(0), namespace: 0, id: 0), loadMore: nil) } else { source = .default } @@ -2981,12 +3078,31 @@ class ChatControllerNode: ASDisplayNode, UIScrollViewDelegate { override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { switch self.chatPresentationInterfaceState.mode { case .standard(previewing: true): - if let subject = self.controller?.subject, case let .messageOptions(_, _, info, _) = subject, case .reply = info.kind { + if let subject = self.controller?.subject, case let .messageOptions(_, _, info) = subject, case .reply = info { + if let controller = self.controller { + if let result = controller.presentationContext.hitTest(view: self.view, point: point, with: event) { + return result + } + } + if let result = self.historyNode.view.hitTest(self.view.convert(point, to: self.historyNode.view), with: event), let node = result.asyncdisplaykit_node { if node is TextSelectionNode { return result } } + } else if let subject = self.controller?.subject, case let .messageOptions(_, _, info) = subject, case .link = info { + if let controller = self.controller { + if let result = controller.presentationContext.hitTest(view: self.view, point: point, with: event) { + return result + } + } + + if let result = self.historyNode.view.hitTest(self.view.convert(point, to: self.historyNode.view), with: event), let node = result.asyncdisplaykit_node { + if let textNode = node as? TextAccessibilityOverlayNode { + let _ = textNode + return result + } + } } if let result = self.historyNode.view.hitTest(self.view.convert(point, to: self.historyNode.view), with: event), let node = result.asyncdisplaykit_node, node is ChatMessageSelectionNode || node is GridMessageSelectionNode { diff --git a/submodules/TelegramUI/Sources/ChatInterfaceStateAccessoryPanels.swift b/submodules/TelegramUI/Sources/ChatInterfaceStateAccessoryPanels.swift index 63b3f78d9f..51e5a56758 100644 --- a/submodules/TelegramUI/Sources/ChatInterfaceStateAccessoryPanels.swift +++ b/submodules/TelegramUI/Sources/ChatInterfaceStateAccessoryPanels.swift @@ -10,6 +10,9 @@ import ForwardAccessoryPanelNode import ReplyAccessoryPanelNode func accessoryPanelForChatPresentationIntefaceState(_ chatPresentationInterfaceState: ChatPresentationInterfaceState, context: AccountContext, currentPanel: AccessoryPanelNode?, chatControllerInteraction: ChatControllerInteraction?, interfaceInteraction: ChatPanelInterfaceInteraction?) -> AccessoryPanelNode? { + if case .standard(previewing: true) = chatPresentationInterfaceState.mode { + return nil + } if let _ = chatPresentationInterfaceState.interfaceState.selectionState { return nil } diff --git a/submodules/TelegramUI/Sources/ChatMessageAttachedContentNode.swift b/submodules/TelegramUI/Sources/ChatMessageAttachedContentNode.swift index 5e9dccc19d..70da7ec605 100644 --- a/submodules/TelegramUI/Sources/ChatMessageAttachedContentNode.swift +++ b/submodules/TelegramUI/Sources/ChatMessageAttachedContentNode.swift @@ -271,7 +271,7 @@ final class ChatMessageAttachedContentButtonNode: HighlightTrackingButtonNode { } final class ChatMessageAttachedContentNode: ASDisplayNode { - private let lineNode: ASImageNode + private var backgroundView: UIImageView? private let topTitleNode: TextNode private let textNode: TextNodeWithEntities private let inlineImageNode: TransformImageNode @@ -313,11 +313,6 @@ final class ChatMessageAttachedContentNode: ASDisplayNode { } override init() { - self.lineNode = ASImageNode() - self.lineNode.isLayerBacked = true - self.lineNode.displaysAsynchronously = false - self.lineNode.displayWithoutProcessing = true - self.topTitleNode = TextNode() self.topTitleNode.isUserInteractionEnabled = false self.topTitleNode.displaysAsynchronously = false @@ -339,7 +334,6 @@ final class ChatMessageAttachedContentNode: ASDisplayNode { super.init() - self.addSubnode(self.lineNode) self.addSubnode(self.topTitleNode) self.addSubnode(self.textNode.textNode) @@ -350,6 +344,7 @@ final class ChatMessageAttachedContentNode: ASDisplayNode { let topTitleAsyncLayout = TextNode.asyncLayout(self.topTitleNode) let textAsyncLayout = TextNodeWithEntities.asyncLayout(self.textNode) let currentImage = self.media as? TelegramMediaImage + let currentMediaIsInline = self.inlineImageNode.supernode != nil let imageLayout = self.inlineImageNode.asyncLayout() let statusLayout = self.statusNode.asyncLayout() let contentImageLayout = ChatMessageInteractiveMediaNode.asyncLayout(self.contentImageNode) @@ -378,13 +373,14 @@ final class ChatMessageAttachedContentNode: ASDisplayNode { let textBlockQuoteFont = Font.regular(fontSize) var incoming = message.effectivelyIncoming(context.account.peerId) - if let subject = associatedData.subject, case let .messageOptions(_, _, info, _) = subject, case .forward = info.kind { + if let subject = associatedData.subject, case let .messageOptions(_, _, info) = subject, case .forward = info { incoming = false } var horizontalInsets = UIEdgeInsets(top: 0.0, left: 10.0, bottom: 0.0, right: 10.0) if displayLine { horizontalInsets.left += 12.0 + horizontalInsets.right += 12.0 } var titleBeforeMedia = false @@ -698,7 +694,7 @@ final class ChatMessageAttachedContentNode: ASDisplayNode { } else if let dimensions = largestImageRepresentation(image.representations)?.dimensions { inlineImageDimensions = dimensions.cgSize - if image != currentImage { + if image != currentImage || !currentMediaIsInline { updateInlineImageSignal = chatWebpageSnippetPhoto(account: context.account, userLocation: .peer(message.id.peerId), photoReference: .message(message: MessageReference(message), media: image)) } } @@ -742,10 +738,15 @@ final class ChatMessageAttachedContentNode: ASDisplayNode { return (initialWidth, { constrainedSize, position in var insets = UIEdgeInsets(top: 0.0, left: horizontalInsets.left, bottom: 5.0, right: horizontalInsets.right) var lineInsets = insets + + //insets.top += 4.0 + //insets.bottom += 4.0 + switch position { case .linear(.None, _): - insets.top += 8.0 - lineInsets.top += 8.0 + 8.0 + insets.top += 10.0 + insets.bottom += 8.0 + lineInsets.top += 10.0 + 8.0 default: break } @@ -798,7 +799,38 @@ final class ChatMessageAttachedContentNode: ASDisplayNode { textFrame = textFrame.offsetBy(dx: insets.left, dy: insets.top) - let lineImage = incoming ? PresentationResourcesChat.chatBubbleVerticalLineIncomingImage(presentationData.theme.theme) : PresentationResourcesChat.chatBubbleVerticalLineOutgoingImage(presentationData.theme.theme) + let mainColor: UIColor + if !incoming { + mainColor = presentationData.theme.theme.chat.message.outgoing.accentTextColor + } else { + var authorNameColor: UIColor? + let author = message.author + if [Namespaces.Peer.CloudGroup, Namespaces.Peer.CloudChannel].contains(message.id.peerId.namespace), author?.id.namespace == Namespaces.Peer.CloudUser { + authorNameColor = author.flatMap { chatMessagePeerIdColors[Int(clamping: $0.id.id._internalGetInt64Value() % 7)] } + if let rawAuthorNameColor = authorNameColor { + var dimColors = false + switch presentationData.theme.theme.name { + case .builtin(.nightAccent), .builtin(.night): + dimColors = true + default: + break + } + if dimColors { + var hue: CGFloat = 0.0 + var saturation: CGFloat = 0.0 + var brightness: CGFloat = 0.0 + rawAuthorNameColor.getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: nil) + authorNameColor = UIColor(hue: hue, saturation: saturation * 0.7, brightness: min(1.0, brightness * 1.2), alpha: 1.0) + } + } + } + + if let authorNameColor { + mainColor = authorNameColor + } else { + mainColor = presentationData.theme.theme.chat.message.incoming.accentTextColor + } + } var boundingSize = textFrame.size var lineHeight = textLayout.rawTextSize.height @@ -1020,9 +1052,22 @@ final class ChatMessageAttachedContentNode: ASDisplayNode { strongSelf.media = mediaAndFlags?.0 strongSelf.theme = presentationData.theme - strongSelf.lineNode.image = lineImage - animation.animator.updateFrame(layer: strongSelf.lineNode.layer, frame: CGRect(origin: CGPoint(x: 13.0, y: insets.top), size: CGSize(width: 2.0, height: adjustedLineHeight - insets.top - insets.bottom - 2.0)), completion: nil) - strongSelf.lineNode.isHidden = !displayLine + let backgroundView: UIImageView + if let current = strongSelf.backgroundView { + backgroundView = current + } else { + backgroundView = UIImageView() + strongSelf.backgroundView = backgroundView + strongSelf.view.insertSubview(backgroundView, at: 0) + } + + if backgroundView.image == nil { + backgroundView.image = PresentationResourcesChat.chatReplyBackgroundTemplateImage(presentationData.theme.theme) + } + backgroundView.tintColor = mainColor + + animation.animator.updateFrame(layer: backgroundView.layer, frame: CGRect(origin: CGPoint(x: 11.0, y: insets.top), size: CGSize(width: adjustedBoundingSize.width - 1.0 - insets.right, height: adjustedLineHeight - insets.top - insets.bottom - 2.0)), completion: nil) + backgroundView.isHidden = !displayLine strongSelf.textNode.textNode.displaysAsynchronously = !isPreview diff --git a/submodules/TelegramUI/Sources/ChatMessageBubbleItemNode.swift b/submodules/TelegramUI/Sources/ChatMessageBubbleItemNode.swift index 66d5d23b98..90d9e3007a 100644 --- a/submodules/TelegramUI/Sources/ChatMessageBubbleItemNode.swift +++ b/submodules/TelegramUI/Sources/ChatMessageBubbleItemNode.swift @@ -273,7 +273,11 @@ private func contentNodeMessagesAndClassesForItem(_ item: ChatMessageItem) -> ([ } } - result.append((message, ChatMessageWebpageBubbleContentNode.self, itemAttributes, BubbleItemAttributes(isAttachment: false, neighborType: .freeform, neighborSpacing: .default))) + if content.displayOptions.position == .aboveText { + result.insert((message, ChatMessageWebpageBubbleContentNode.self, itemAttributes, BubbleItemAttributes(isAttachment: false, neighborType: .freeform, neighborSpacing: .default)), at: 0) + } else { + result.append((message, ChatMessageWebpageBubbleContentNode.self, itemAttributes, BubbleItemAttributes(isAttachment: false, neighborType: .freeform, neighborSpacing: .default))) + } needReactions = false } break inner @@ -975,6 +979,27 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode let recognizer = TapLongTapOrDoubleTapGestureRecognizer(target: self, action: #selector(self.tapLongTapOrDoubleTapGesture(_:))) recognizer.tapActionAtPoint = { [weak self] point in if let strongSelf = self { + if let item = strongSelf.item, let subject = item.associatedData.subject, case let .messageOptions(_, _, info) = subject { + if case .link = info { + for contentNode in strongSelf.contentNodes { + let contentNodePoint = strongSelf.view.convert(point, to: contentNode.view) + let tapAction = contentNode.tapActionAtPoint(contentNodePoint, gesture: .tap, isEstimating: true) + switch tapAction { + case .none: + break + case .ignore: + return .fail + case .url: + return .waitForSingleTap + default: + break + } + } + } + + return .fail + } + if let shareButtonNode = strongSelf.shareButtonNode, shareButtonNode.frame.contains(point) { return .fail } @@ -1125,7 +1150,7 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode self.view.addGestureRecognizer(replyRecognizer) if let item = self.item, let subject = item.associatedData.subject, case .messageOptions = subject { - self.tapRecognizer?.isEnabled = false + //self.tapRecognizer?.isEnabled = false self.replyRecognizer?.isEnabled = false } } @@ -1239,7 +1264,7 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode do { let peerId = chatLocationPeerId - if let subject = item.associatedData.subject, case let .messageOptions(_, _, info, _) = subject, case .forward = info.kind { + if let subject = item.associatedData.subject, case let .messageOptions(_, _, info) = subject, case .forward = info { displayAuthorInfo = false } else if item.message.id.peerId.isRepliesOrSavedMessages(accountPeerId: item.context.account.peerId) { if let forwardInfo = item.content.firstMessage.forwardInfo { @@ -1913,7 +1938,7 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode } let dateFormat: MessageTimestampStatusFormat - if let subject = item.associatedData.subject, case let .messageOptions(_, _, info, _) = subject, case .forward = info.kind { + if let subject = item.associatedData.subject, case let .messageOptions(_, _, info) = subject, case .forward = info { dateFormat = .minimal } else { dateFormat = .regular @@ -2215,6 +2240,18 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode } } + var updatedContentNodeOrder = false + if currentContentClassesPropertiesAndLayouts.count == contentNodeMessagesAndClasses.count { + for i in 0 ..< currentContentClassesPropertiesAndLayouts.count { + let currentClass: AnyClass = currentContentClassesPropertiesAndLayouts[i].1 + let contentItem = contentNodeMessagesAndClasses[i] as (message: Message, type: AnyClass, ChatMessageEntryAttributes, BubbleItemAttributes) + if currentClass != contentItem.type { + updatedContentNodeOrder = true + break + } + } + } + var contentNodePropertiesAndFinalize: [(ChatMessageBubbleContentProperties, ChatMessageBubbleContentPosition?, (CGFloat) -> (CGSize, (ListViewItemUpdateAnimation, Bool, ListViewItemApply?) -> Void), UInt32?, Bool?)] = [] var maxContentWidth: CGFloat = headerSize.width @@ -2617,6 +2654,7 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode replyInfoSizeApply: replyInfoSizeApply, replyInfoOriginY: replyInfoOriginY, removedContentNodeIndices: removedContentNodeIndices, + updatedContentNodeOrder: updatedContentNodeOrder, addedContentNodes: addedContentNodes, contentNodeMessagesAndClasses: contentNodeMessagesAndClasses, contentNodeFramesPropertiesAndApply: contentNodeFramesPropertiesAndApply, @@ -2667,6 +2705,7 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode replyInfoSizeApply: (CGSize, (CGSize, Bool) -> ChatMessageReplyInfoNode?), replyInfoOriginY: CGFloat, removedContentNodeIndices: [Int]?, + updatedContentNodeOrder: Bool, addedContentNodes: [(Message, Bool, ChatMessageBubbleContentNode)]?, contentNodeMessagesAndClasses: [(Message, AnyClass, ChatMessageEntryAttributes, BubbleItemAttributes)], contentNodeFramesPropertiesAndApply: [(CGRect, ChatMessageBubbleContentProperties, Bool, (ListViewItemUpdateAnimation, Bool, ListViewItemApply?) -> Void)], @@ -3169,7 +3208,7 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode strongSelf.contentContainersWrapperNode.view.mask = nil } - if removedContentNodeIndices?.count ?? 0 != 0 || addedContentNodes?.count ?? 0 != 0 { + if removedContentNodeIndices?.count ?? 0 != 0 || addedContentNodes?.count ?? 0 != 0 || updatedContentNodeOrder { var updatedContentNodes = strongSelf.contentNodes if let removedContentNodeIndices = removedContentNodeIndices { @@ -3536,7 +3575,7 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode } if let subject = item.associatedData.subject, case .messageOptions = subject { - strongSelf.tapRecognizer?.isEnabled = false + //strongSelf.tapRecognizer?.isEnabled = false strongSelf.replyRecognizer?.isEnabled = false strongSelf.mainContainerNode.isGestureEnabled = false for contentContainer in strongSelf.contentContainers { diff --git a/submodules/TelegramUI/Sources/ChatMessageContactBubbleContentNode.swift b/submodules/TelegramUI/Sources/ChatMessageContactBubbleContentNode.swift index 75cac2f2e5..36900662ef 100644 --- a/submodules/TelegramUI/Sources/ChatMessageContactBubbleContentNode.swift +++ b/submodules/TelegramUI/Sources/ChatMessageContactBubbleContentNode.swift @@ -96,7 +96,7 @@ class ChatMessageContactBubbleContentNode: ChatMessageBubbleContentNode { } var incoming = item.message.effectivelyIncoming(item.context.account.peerId) - if let subject = item.associatedData.subject, case let .messageOptions(_, _, info, _) = subject, case .forward = info.kind { + if let subject = item.associatedData.subject, case let .messageOptions(_, _, info) = subject, case .forward = info { incoming = false } diff --git a/submodules/TelegramUI/Sources/ChatMessageFileBubbleContentNode.swift b/submodules/TelegramUI/Sources/ChatMessageFileBubbleContentNode.swift index dd588ca48e..c326156b5d 100644 --- a/submodules/TelegramUI/Sources/ChatMessageFileBubbleContentNode.swift +++ b/submodules/TelegramUI/Sources/ChatMessageFileBubbleContentNode.swift @@ -106,7 +106,7 @@ class ChatMessageFileBubbleContentNode: ChatMessageBubbleContentNode { } var incoming = item.message.effectivelyIncoming(item.context.account.peerId) - if let subject = item.associatedData.subject, case let .messageOptions(_, _, info, _) = subject, case .forward = info.kind { + if let subject = item.associatedData.subject, case let .messageOptions(_, _, info) = subject, case .forward = info { incoming = false } let statusType: ChatMessageDateAndStatusType? diff --git a/submodules/TelegramUI/Sources/ChatMessageGiveawayBubbleContentNode.swift b/submodules/TelegramUI/Sources/ChatMessageGiveawayBubbleContentNode.swift index 9fa3364cab..aaabaf02a2 100644 --- a/submodules/TelegramUI/Sources/ChatMessageGiveawayBubbleContentNode.swift +++ b/submodules/TelegramUI/Sources/ChatMessageGiveawayBubbleContentNode.swift @@ -185,7 +185,7 @@ class ChatMessageGiveawayBubbleContentNode: ChatMessageBubbleContentNode { } var incoming = item.message.effectivelyIncoming(item.context.account.peerId) - if let subject = item.associatedData.subject, case let .messageOptions(_, _, info, _) = subject, case .forward = info.kind { + if let subject = item.associatedData.subject, case let .messageOptions(_, _, info) = subject, case .forward = info { incoming = false } diff --git a/submodules/TelegramUI/Sources/ChatMessageInstantVideoBubbleContentNode.swift b/submodules/TelegramUI/Sources/ChatMessageInstantVideoBubbleContentNode.swift index 0c0ef13528..4fab7b9957 100644 --- a/submodules/TelegramUI/Sources/ChatMessageInstantVideoBubbleContentNode.swift +++ b/submodules/TelegramUI/Sources/ChatMessageInstantVideoBubbleContentNode.swift @@ -176,7 +176,7 @@ class ChatMessageInstantVideoBubbleContentNode: ChatMessageBubbleContentNode { } var incoming = item.message.effectivelyIncoming(item.context.account.peerId) - if let subject = item.associatedData.subject, case let .messageOptions(_, _, info, _) = subject, case .forward = info.kind { + if let subject = item.associatedData.subject, case let .messageOptions(_, _, info) = subject, case .forward = info { incoming = false } diff --git a/submodules/TelegramUI/Sources/ChatMessageInteractiveFileNode.swift b/submodules/TelegramUI/Sources/ChatMessageInteractiveFileNode.swift index 9c86bb318d..74faf76db4 100644 --- a/submodules/TelegramUI/Sources/ChatMessageInteractiveFileNode.swift +++ b/submodules/TelegramUI/Sources/ChatMessageInteractiveFileNode.swift @@ -1805,7 +1805,7 @@ final class ChatMessageInteractiveFileNode: ASDisplayNode { } item.controllerInteraction.performTextSelectionAction(item.message, true, text, action) }) - textSelectionNode.enableQuote = item.controllerInteraction.canSetupReply(item.message) == .reply + textSelectionNode.enableQuote = true self.textSelectionNode = textSelectionNode self.textClippingNode.addSubnode(textSelectionNode) self.textClippingNode.insertSubnode(textSelectionNode.highlightAreaNode, belowSubnode: self.textNode) diff --git a/submodules/TelegramUI/Sources/ChatMessageInteractiveInstantVideoNode.swift b/submodules/TelegramUI/Sources/ChatMessageInteractiveInstantVideoNode.swift index 720cc6df06..eb38703034 100644 --- a/submodules/TelegramUI/Sources/ChatMessageInteractiveInstantVideoNode.swift +++ b/submodules/TelegramUI/Sources/ChatMessageInteractiveInstantVideoNode.swift @@ -235,7 +235,7 @@ class ChatMessageInteractiveInstantVideoNode: ASDisplayNode { var updatedMuteIconImage: UIImage? var incoming = item.message.effectivelyIncoming(item.context.account.peerId) - if let subject = item.associatedData.subject, case let .messageOptions(_, _, info, _) = subject, case .forward = info.kind { + if let subject = item.associatedData.subject, case let .messageOptions(_, _, info) = subject, case .forward = info { incoming = false } diff --git a/submodules/TelegramUI/Sources/ChatMessageItem.swift b/submodules/TelegramUI/Sources/ChatMessageItem.swift index e89c64e6d2..7a9e691554 100644 --- a/submodules/TelegramUI/Sources/ChatMessageItem.swift +++ b/submodules/TelegramUI/Sources/ChatMessageItem.swift @@ -18,7 +18,7 @@ public enum ChatMessageItemContent: Sequence { case group(messages: [(Message, Bool, ChatHistoryMessageSelection, ChatMessageEntryAttributes, MessageHistoryEntryLocation?)]) func effectivelyIncoming(_ accountPeerId: PeerId, associatedData: ChatMessageItemAssociatedData? = nil) -> Bool { - if let subject = associatedData?.subject, case let .messageOptions(_, _, info, _) = subject, case .forward = info.kind { + if let subject = associatedData?.subject, case let .messageOptions(_, _, info) = subject, case .forward = info { return false } switch self { @@ -511,7 +511,7 @@ public final class ChatMessageItem: ListViewItem, CustomStringConvertible { let (top, bottom, dateAtBottom) = self.mergedWithItems(top: previousItem, bottom: nextItem) var disableDate = self.disableDate - if let subject = self.associatedData.subject, case let .messageOptions(_, _, info, _) = subject, case .reply = info.kind { + if let subject = self.associatedData.subject, case let .messageOptions(_, _, info) = subject, case .reply = info { disableDate = true } diff --git a/submodules/TelegramUI/Sources/ChatMessageMapBubbleContentNode.swift b/submodules/TelegramUI/Sources/ChatMessageMapBubbleContentNode.swift index 0551335876..2825083079 100644 --- a/submodules/TelegramUI/Sources/ChatMessageMapBubbleContentNode.swift +++ b/submodules/TelegramUI/Sources/ChatMessageMapBubbleContentNode.swift @@ -92,7 +92,7 @@ class ChatMessageMapBubbleContentNode: ChatMessageBubbleContentNode { } var incoming = item.message.effectivelyIncoming(item.context.account.peerId) - if let subject = item.associatedData.subject, case let .messageOptions(_, _, info, _) = subject, case .forward = info.kind { + if let subject = item.associatedData.subject, case let .messageOptions(_, _, info) = subject, case .forward = info { incoming = false } diff --git a/submodules/TelegramUI/Sources/ChatMessageWebpageBubbleContentNode.swift b/submodules/TelegramUI/Sources/ChatMessageWebpageBubbleContentNode.swift index 3ca477a4e4..e4667301dd 100644 --- a/submodules/TelegramUI/Sources/ChatMessageWebpageBubbleContentNode.swift +++ b/submodules/TelegramUI/Sources/ChatMessageWebpageBubbleContentNode.swift @@ -347,6 +347,12 @@ final class ChatMessageWebpageBubbleContentNode: ChatMessageBubbleContentNode { break } } + + if webpage.displayOptions.largeMedia == false { + mediaAndFlags?.1.insert(.preferMediaInline) + } else { + mediaAndFlags?.1.remove(.preferMediaInline) + } } else if let adAttribute = item.message.adAttribute { title = nil subtitle = nil diff --git a/submodules/TelegramUI/Sources/ChatRecentActionsController.swift b/submodules/TelegramUI/Sources/ChatRecentActionsController.swift index ce27ec1222..95c9098a72 100644 --- a/submodules/TelegramUI/Sources/ChatRecentActionsController.swift +++ b/submodules/TelegramUI/Sources/ChatRecentActionsController.swift @@ -74,6 +74,7 @@ final class ChatRecentActionsController: TelegramBaseController { }, updateForwardOptionsState: { _ in }, presentForwardOptions: { _ in }, presentReplyOptions: { _ in + }, presentLinkOptions: { _ in }, shareSelectedMessages: { }, updateTextInputStateAndMode: { _ in }, updateInputModeAndDismissedButtonKeyboardMessageId: { _ in diff --git a/submodules/TelegramUI/Sources/PeerInfo/PeerInfoScreen.swift b/submodules/TelegramUI/Sources/PeerInfo/PeerInfoScreen.swift index 23c942821a..f8b32cecfd 100644 --- a/submodules/TelegramUI/Sources/PeerInfo/PeerInfoScreen.swift +++ b/submodules/TelegramUI/Sources/PeerInfo/PeerInfoScreen.swift @@ -311,6 +311,7 @@ final class PeerInfoSelectionPanelNode: ASDisplayNode { }, updateForwardOptionsState: { _ in }, presentForwardOptions: { _ in }, presentReplyOptions: { _ in + }, presentLinkOptions: { _ in }, shareSelectedMessages: { shareMessages() }, updateTextInputStateAndMode: { _ in diff --git a/submodules/TelegramUI/Sources/WebpagePreviewAccessoryPanelNode.swift b/submodules/TelegramUI/Sources/WebpagePreviewAccessoryPanelNode.swift index ba7fb6ee99..87270c2649 100644 --- a/submodules/TelegramUI/Sources/WebpagePreviewAccessoryPanelNode.swift +++ b/submodules/TelegramUI/Sources/WebpagePreviewAccessoryPanelNode.swift @@ -82,8 +82,18 @@ final class WebpagePreviewAccessoryPanelNode: AccessoryPanelNode { self.iconNode.layer.animateScale(from: 1.0, to: 0.001, duration: 0.2, removeOnCompletion: false) } + override public func didLoad() { + super.didLoad() + + self.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:)))) + } + override func updateThemeAndStrings(theme: PresentationTheme, strings: PresentationStrings) { - if self.theme !== theme || self.strings !== strings { + self.updateThemeAndStrings(theme: theme, strings: strings, force: false) + } + + func updateThemeAndStrings(theme: PresentationTheme, strings: PresentationStrings, force: Bool) { + if self.theme !== theme || self.strings !== strings || force { self.strings = strings if self.theme !== theme { @@ -209,4 +219,21 @@ final class WebpagePreviewAccessoryPanelNode: AccessoryPanelNode { dismiss() } } + + private var previousTapTimestamp: Double? + @objc private func tapGesture(_ recognizer: UITapGestureRecognizer) { + if case .ended = recognizer.state { + let timestamp = CFAbsoluteTimeGetCurrent() + if let previousTapTimestamp = self.previousTapTimestamp, previousTapTimestamp + 1.0 > timestamp { + return + } + self.previousTapTimestamp = CFAbsoluteTimeGetCurrent() + self.interfaceInteraction?.presentLinkOptions(self) + Queue.mainQueue().after(1.5) { + self.updateThemeAndStrings(theme: self.theme, strings: self.strings, force: true) + } + + //let _ = ApplicationSpecificNotice.incrementChatReplyOptionsTip(accountManager: self.context.sharedContext.accountManager, count: 3).start() + } + } } diff --git a/submodules/TextFormat/Sources/GenerateTextEntities.swift b/submodules/TextFormat/Sources/GenerateTextEntities.swift index 095371186f..feacfe990d 100644 --- a/submodules/TextFormat/Sources/GenerateTextEntities.swift +++ b/submodules/TextFormat/Sources/GenerateTextEntities.swift @@ -144,7 +144,7 @@ private func commitEntity(_ utf16: String.UTF16View, _ type: CurrentEntityType, } } -public func generateChatInputTextEntities(_ text: NSAttributedString, maxAnimatedEmojisInText: Int? = nil) -> [MessageTextEntity] { +public func generateChatInputTextEntities(_ text: NSAttributedString, maxAnimatedEmojisInText: Int? = nil, generateLinks: Bool = false) -> [MessageTextEntity] { var entities: [MessageTextEntity] = [] text.enumerateAttributes(in: NSRange(location: 0, length: text.length), options: [], using: { attributes, range, _ in @@ -174,6 +174,13 @@ public func generateChatInputTextEntities(_ text: NSAttributedString, maxAnimate } } }) + + for entity in generateTextEntities(text.string, enabledTypes: .allUrl) { + if case .Url = entity.type { + entities.append(entity) + } + } + return entities } diff --git a/submodules/TextSelectionNode/Sources/TextSelectionNode.swift b/submodules/TextSelectionNode/Sources/TextSelectionNode.swift index c857636578..9247e0794c 100644 --- a/submodules/TextSelectionNode/Sources/TextSelectionNode.swift +++ b/submodules/TextSelectionNode/Sources/TextSelectionNode.swift @@ -241,6 +241,8 @@ public final class TextSelectionNode: ASDisplayNode { public var enableTranslate: Bool = true public var enableShare: Bool = true + public var menuSkipCoordnateConversion: Bool = false + public var didRecognizeTap: Bool { return self.recognizer?.didRecognizeTap ?? false } @@ -549,6 +551,9 @@ public final class TextSelectionNode: ASDisplayNode { self.currentRange = nil self.recognizer?.isSelecting = false self.updateSelection(range: nil, animateIn: false) + + self.contextMenu?.dismiss() + self.contextMenu = nil } public func cancelSelection() { @@ -642,7 +647,9 @@ public final class TextSelectionNode: ASDisplayNode { })) } - let contextMenu = ContextMenuController(actions: actions, catchTapsOutside: false, hasHapticFeedback: false) + self.contextMenu?.dismiss() + + let contextMenu = ContextMenuController(actions: actions, catchTapsOutside: false, hasHapticFeedback: false, skipCoordnateConversion: self.menuSkipCoordnateConversion) contextMenu.dismissOnTap = { [weak self] view, point in guard let self else { return true @@ -658,7 +665,12 @@ public final class TextSelectionNode: ASDisplayNode { guard let strongSelf = self, let rootNode = strongSelf.rootNode() else { return nil } - return (strongSelf, completeRect, rootNode, rootNode.bounds.insetBy(dx: 0.0, dy: -100.0)) + + if strongSelf.menuSkipCoordnateConversion { + return (strongSelf, strongSelf.view.convert(completeRect, to: rootNode.view), rootNode, rootNode.bounds) + } else { + return (strongSelf, completeRect, rootNode, rootNode.bounds) + } }, bounce: false)) }