From 1ca5546adc38920629379b1d438a1b0a91b06264 Mon Sep 17 00:00:00 2001 From: Ali <> Date: Fri, 5 May 2023 21:08:04 +0400 Subject: [PATCH 1/2] [WIP] Stories --- .../Telegram-iOS/en.lproj/Localizable.strings | 2 + submodules/ChatListUI/BUILD | 1 + .../Sources/ChatListController.swift | 176 ++++++-- .../Source/Base/Transition.swift | 82 ++++ submodules/Display/Source/NavigationBar.swift | 12 +- .../Display/Source/ViewController.swift | 4 +- .../PrivacyAndSecurityController.swift | 18 +- .../SelectivePrivacySettingsController.swift | 56 ++- submodules/TelegramApi/Sources/Api0.swift | 13 +- submodules/TelegramApi/Sources/Api17.swift | 24 + submodules/TelegramApi/Sources/Api21.swift | 138 +++++- submodules/TelegramApi/Sources/Api29.swift | 56 +++ submodules/TelegramApi/Sources/Api30.swift | 86 +++- submodules/TelegramApi/Sources/Api9.swift | 24 + .../Account/AccountIntermediateState.swift | 7 +- .../Sources/Settings/PrivacySettings.swift | 28 +- .../State/AccountStateManagementUtils.swift | 26 +- .../Sources/State/AccountStateManager.swift | 8 +- .../TelegramEngine/Messages/Stories.swift | 23 +- .../Messages/StoryListContext.swift | 100 ++++- .../Messages/TelegramEngineMessages.swift | 6 +- .../UpdatedAccountPrivacySettings.swift | 5 +- .../Sources/StoryContainerScreen.swift | 335 ++++++++++++-- .../Sources/StoryContent.swift | 6 + .../StoryItemSetContainerComponent.swift | 147 +++++- .../Sources/StoryAvatarInfoComponent.swift | 3 +- .../Sources/StoryChatContent.swift | 96 +--- .../StoryMessageContentComponent.swift | 421 ------------------ .../Stories/StoryFooterPanelComponent/BUILD | 3 + .../Sources/StoryFooterPanelComponent.swift | 56 ++- .../Stories/StoryPeerListComponent/BUILD | 26 ++ .../Sources/StoryPeerListComponent.swift | 259 +++++++++++ .../Sources/StoryPeerListItemComponent.swift | 188 ++++++++ 33 files changed, 1761 insertions(+), 674 deletions(-) delete mode 100644 submodules/TelegramUI/Components/Stories/StoryContentComponent/Sources/StoryMessageContentComponent.swift create mode 100644 submodules/TelegramUI/Components/Stories/StoryPeerListComponent/BUILD create mode 100644 submodules/TelegramUI/Components/Stories/StoryPeerListComponent/Sources/StoryPeerListComponent.swift create mode 100644 submodules/TelegramUI/Components/Stories/StoryPeerListComponent/Sources/StoryPeerListItemComponent.swift diff --git a/Telegram/Telegram-iOS/en.lproj/Localizable.strings b/Telegram/Telegram-iOS/en.lproj/Localizable.strings index 9f6cbd776e..fce5eef02e 100644 --- a/Telegram/Telegram-iOS/en.lproj/Localizable.strings +++ b/Telegram/Telegram-iOS/en.lproj/Localizable.strings @@ -963,6 +963,8 @@ "PrivacySettings.LastSeenContactsMinus" = "My Contacts (-%@)"; "PrivacySettings.LastSeenContactsMinusPlus" = "My Contacts (-%@, +%@)"; "PrivacySettings.LastSeenNobodyPlus" = "Nobody (+%@)"; +"PrivacySettings.LastSeenCloseFriendsPlus" = "Close Friends (+%@)"; +"PrivacySettings.LastSeenCloseFriends" = "Close Friends"; "PrivacySettings.SecurityTitle" = "SECURITY"; diff --git a/submodules/ChatListUI/BUILD b/submodules/ChatListUI/BUILD index cb23442c95..171957e021 100644 --- a/submodules/ChatListUI/BUILD +++ b/submodules/ChatListUI/BUILD @@ -95,6 +95,7 @@ swift_library( "//submodules/TelegramUI/Components/ActionPanelComponent", "//submodules/TelegramUI/Components/Stories/StoryContainerScreen", "//submodules/TelegramUI/Components/Stories/StoryContentComponent", + "//submodules/TelegramUI/Components/Stories/StoryPeerListComponent", ], visibility = [ "//visibility:public", diff --git a/submodules/ChatListUI/Sources/ChatListController.swift b/submodules/ChatListUI/Sources/ChatListController.swift index 68c2f5cc2b..e1da129844 100644 --- a/submodules/ChatListUI/Sources/ChatListController.swift +++ b/submodules/ChatListUI/Sources/ChatListController.swift @@ -47,6 +47,7 @@ import InviteLinksUI import ChatFolderLinkPreviewScreen import StoryContainerScreen import StoryContentComponent +import StoryPeerListComponent private func fixListNodeScrolling(_ listNode: ListView, searchNode: NavigationBarSearchContentNode) -> Bool { if listNode.scroller.isDragging { @@ -183,6 +184,8 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController private var searchContentNode: NavigationBarSearchContentNode? + private let navigationSecondaryContentNode: ASDisplayNode + private var storyPeerListView: ComponentView? private let tabContainerNode: ChatListFilterTabContainerNode private var tabContainerData: ([ChatListFilterTabEntry], Bool, Int32?)? @@ -208,8 +211,11 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController private var powerSavingMonitoringDisposable: Disposable? private var storyListContext: StoryListContext? + private var storyListState: StoryListContext.State? private var storyListStateDisposable: Disposable? + private var storyListHeight: CGFloat + public override func updateNavigationCustomData(_ data: Any?, progress: CGFloat, transition: ContainedViewLayoutTransition) { if self.isNodeLoaded { self.chatListDisplayNode.effectiveContainerNode.updateSelectedChatLocation(data: data as? ChatLocation, progress: progress, transition: transition) @@ -238,7 +244,11 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController groupCallPanelSource = .peer(peerId) } + self.navigationSecondaryContentNode = SparseNode() self.tabContainerNode = ChatListFilterTabContainerNode() + self.navigationSecondaryContentNode.addSubnode(self.tabContainerNode) + + self.storyListHeight = 0.0 super.init(context: context, navigationBarPresentationData: NavigationBarPresentationData(presentationData: self.presentationData), mediaAccessoryPanelVisibility: .always, locationBroadcastPanelSource: .summary, groupCallPanelSource: groupCallPanelSource) @@ -427,6 +437,15 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController self.searchContentNode?.updateExpansionProgress(0.0) self.navigationBar?.setContentNode(self.searchContentNode, animated: false) + let tabsIsEmpty: Bool + if let (resolvedItems, displayTabsAtBottom, _) = self.tabContainerData { + tabsIsEmpty = resolvedItems.count <= 1 || displayTabsAtBottom + } else { + tabsIsEmpty = true + } + + self.navigationBar?.secondaryContentHeight = (!tabsIsEmpty ? NavigationBar.defaultSecondaryContentHeight : 0.0) + self.storyListHeight + enum State: Equatable { case empty(hasDownloads: Bool) case downloading(progress: Double) @@ -1338,7 +1357,8 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController let storyContainerScreen = StoryContainerScreen( context: self.context, initialFocusedId: AnyHashable(peerId), - initialContent: initialContent + initialContent: initialContent, + transitionIn: nil ) self.push(storyContainerScreen) }) @@ -2067,20 +2087,46 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController guard let self else { return } - let _ = self - let _ = state + var wasEmpty = true + if let storyListState = self.storyListState, !storyListState.itemSets.isEmpty { + wasEmpty = false + } + self.storyListState = state + let isEmpty = state.itemSets.isEmpty self.chatListDisplayNode.mainContainerNode.currentItemNode.updateState { chatListState in var chatListState = chatListState var peersWithNewStories = Set() for itemSet in state.itemSets { - peersWithNewStories.insert(itemSet.peerId) + if itemSet.peerId == self.context.account.peerId { + continue + } + if itemSet.items.contains(where: { !$0.isSeen }) { + peersWithNewStories.insert(itemSet.peerId) + } } chatListState.peersWithNewStories = peersWithNewStories return chatListState } + + self.storyListHeight = isEmpty ? 0.0 : 94.0 + + let tabsIsEmpty: Bool + if let (resolvedItems, displayTabsAtBottom, _) = self.tabContainerData { + tabsIsEmpty = resolvedItems.count <= 1 || displayTabsAtBottom + } else { + tabsIsEmpty = true + } + + self.navigationBar?.secondaryContentHeight = (!tabsIsEmpty ? NavigationBar.defaultSecondaryContentHeight : 0.0) + self.storyListHeight + + if wasEmpty != isEmpty || self.storyPeerListView == nil { + self.requestLayout(transition: .animated(duration: 0.4, curve: .spring)) + } else if let componentView = self.storyPeerListView?.view, !componentView.bounds.isEmpty { + self.updateStoryPeerListView(size: componentView.bounds.size, transition: Transition(animation: .curve(duration: 0.4, curve: .spring))) + } }) } @@ -2304,20 +2350,9 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } override public func updateNavigationBarLayout(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { - /*if self.chatListDisplayNode.searchDisplayController?.contentNode != nil { - self.navigationBar?.secondaryContentNodeDisplayFraction = 1.0 - } else { - self.navigationBar?.secondaryContentNodeDisplayFraction = 1.0 - self.chatListDisplayNode.inlineStackContainerTransitionFraction - }*/ - self.updateHeaderContent(layout: layout, transition: transition) super.updateNavigationBarLayout(layout, transition: transition) - - if let inlineStackContainerNode = self.chatListDisplayNode.inlineStackContainerNode { - let _ = inlineStackContainerNode - } else { - } } override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { @@ -2335,6 +2370,59 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } } + private func updateStoryPeerListView(size: CGSize, transition: Transition) { + guard let storyPeerListView = self.storyPeerListView else { + return + } + let _ = storyPeerListView.update( + transition: transition, + component: AnyComponent(StoryPeerListComponent( + context: self.context, + theme: self.presentationData.theme, + strings: self.presentationData.strings, + state: self.storyListState, + peerAction: { [weak self] peer in + guard let self, let storyListContext = self.storyListContext else { + return + } + + let _ = (StoryChatContent.stories( + context: self.context, + storyList: storyListContext, + focusItem: nil + ) + |> take(1) + |> deliverOnMainQueue).start(next: { [weak self] initialContent in + guard let self else { + return + } + + var transitionIn: StoryContainerScreen.TransitionIn? + if let storyPeerListView = self.storyPeerListView?.view as? StoryPeerListComponent.View { + if let transitionView = storyPeerListView.transitionViewForItem(peerId: peer.id) { + transitionIn = StoryContainerScreen.TransitionIn( + sourceView: transitionView, + sourceRect: transitionView.bounds, + sourceCornerRadius: transitionView.bounds.height * 0.5 + ) + } + } + + let storyContainerScreen = StoryContainerScreen( + context: self.context, + initialFocusedId: AnyHashable(peer.id), + initialContent: initialContent, + transitionIn: transitionIn + ) + self.push(storyContainerScreen) + }) + } + )), + environment: {}, + containerSize: size + ) + } + private func updateLayout(layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { var tabContainerOffset: CGFloat = 0.0 if !self.displayNavigationBar { @@ -2343,10 +2431,45 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } let navigationBarHeight = self.navigationBar?.frame.maxY ?? 0.0 - transition.updateFrame(node: self.tabContainerNode, frame: CGRect(origin: CGPoint(x: 0.0, y: navigationBarHeight - self.additionalNavigationBarHeight - 46.0 + tabContainerOffset), size: CGSize(width: layout.size.width, height: 46.0))) + let secondaryContentHeight = self.navigationBar?.secondaryContentHeight ?? 0.0 + + transition.updateFrame(node: self.navigationSecondaryContentNode, frame: CGRect(origin: CGPoint(x: 0.0, y: navigationBarHeight - self.additionalNavigationBarHeight - secondaryContentHeight + tabContainerOffset), size: CGSize(width: layout.size.width, height: secondaryContentHeight))) + + transition.updateFrame(node: self.tabContainerNode, frame: CGRect(origin: CGPoint(x: 0.0, y: -8.0), size: CGSize(width: layout.size.width, height: 46.0))) + + if let storyListState = self.storyListState, !storyListState.itemSets.isEmpty { + var storyPeerListTransition = Transition(transition) + let storyPeerListView: ComponentView + if let current = self.storyPeerListView { + storyPeerListView = current + } else { + storyPeerListTransition = .immediate + storyPeerListView = ComponentView() + self.storyPeerListView = storyPeerListView + } + let storyListFrame = CGRect(origin: CGPoint(x: 0.0, y: 46.0 - 0.0), size: CGSize(width: layout.size.width, height: self.storyListHeight + 0.0)) + self.updateStoryPeerListView(size: storyListFrame.size, transition: storyPeerListTransition) + if let componentView = storyPeerListView.view { + if componentView.superview == nil { + componentView.alpha = 0.0 + self.navigationSecondaryContentNode.view.addSubview(componentView) + } + storyPeerListTransition.setFrame(view: componentView, frame: storyListFrame) + transition.updateAlpha(layer: componentView.layer, alpha: 1.0) + } + } else if let storyPeerListView = self.storyPeerListView { + self.storyPeerListView = nil + if let componentView = storyPeerListView.view { + transition.updateAlpha(layer: componentView.layer, alpha: 0.0, completion: { [weak componentView] _ in + componentView?.removeFromSuperview() + }) + } + } + if !skipTabContainerUpdate { self.tabContainerNode.update(size: CGSize(width: layout.size.width, height: 46.0), sideInset: layout.safeInsets.left, filters: self.tabContainerData?.0 ?? [], selectedFilter: self.chatListDisplayNode.mainContainerNode.currentItemFilter, isReordering: self.chatListDisplayNode.isReorderingFilters || (self.chatListDisplayNode.effectiveContainerNode.currentItemNode.currentState.editing && !self.chatListDisplayNode.didBeginSelectingChatsWhileEditing), isEditing: self.chatListDisplayNode.effectiveContainerNode.currentItemNode.currentState.editing, canReorderAllChats: self.isPremium, filtersLimit: self.tabContainerData?.2, transitionFraction: self.chatListDisplayNode.effectiveContainerNode.transitionFraction, presentationData: self.presentationData, transition: .animated(duration: 0.4, curve: .spring)) } + self.chatListDisplayNode.containerLayoutUpdated(layout, navigationBarHeight: self.cleanNavigationHeight, visualNavigationHeight: navigationBarHeight, cleanNavigationBarHeight: self.cleanNavigationHeight, transition: transition) } @@ -2786,16 +2909,15 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController let animated = strongSelf.didSetupTabs strongSelf.didSetupTabs = true - if wasEmpty != isEmpty, strongSelf.displayNavigationBar { - strongSelf.navigationBar?.setSecondaryContentNode(isEmpty ? nil : strongSelf.tabContainerNode, animated: false) - if let parentController = strongSelf.parent as? TabBarController { - parentController.navigationBar?.setSecondaryContentNode(isEmpty ? nil : strongSelf.tabContainerNode, animated: animated) - } + if strongSelf.displayNavigationBar { + strongSelf.navigationBar?.secondaryContentHeight = (!isEmpty ? NavigationBar.defaultSecondaryContentHeight : 0.0) + strongSelf.storyListHeight + strongSelf.navigationBar?.setSecondaryContentNode(strongSelf.navigationSecondaryContentNode, animated: false) } if let layout = strongSelf.validLayout { if wasEmpty != isEmpty { let transition: ContainedViewLayoutTransition = animated ? .animated(duration: 0.2, curve: .easeInOut) : .immediate + transition.updateAlpha(node: strongSelf.tabContainerNode, alpha: isEmpty ? 0.0 : 1.0) strongSelf.containerLayoutUpdated(layout, transition: transition) (strongSelf.parent as? TabBarController)?.updateLayout(transition: transition) } else { @@ -3164,10 +3286,8 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController if let filterContainerNodeAndActivate = strongSelf.chatListDisplayNode.activateSearch(placeholderNode: searchContentNode.placeholderNode, displaySearchFilters: displaySearchFilters, hasDownloads: strongSelf.hasDownloads, initialFilter: filter, navigationController: strongSelf.navigationController as? NavigationController) { let (filterContainerNode, activate) = filterContainerNodeAndActivate if displaySearchFilters { + strongSelf.navigationBar?.secondaryContentHeight = NavigationBar.defaultSecondaryContentHeight strongSelf.navigationBar?.setSecondaryContentNode(filterContainerNode, animated: false) - if let parentController = strongSelf.parent as? TabBarController { - parentController.navigationBar?.setSecondaryContentNode(filterContainerNode, animated: true) - } } activate(filter != .downloads) @@ -3242,13 +3362,11 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController completion = self.chatListDisplayNode.deactivateSearch(placeholderNode: searchContentNode.placeholderNode, animated: animated) } - - self.navigationBar?.setSecondaryContentNode(tabsIsEmpty ? nil : self.tabContainerNode, animated: false) - if let parentController = self.parent as? TabBarController { - parentController.navigationBar?.setSecondaryContentNode(tabsIsEmpty ? nil : self.tabContainerNode, animated: animated) - } + self.navigationBar?.secondaryContentHeight = (!tabsIsEmpty ? NavigationBar.defaultSecondaryContentHeight : 0.0) + self.storyListHeight + self.navigationBar?.setSecondaryContentNode(self.navigationSecondaryContentNode, animated: false) let transition: ContainedViewLayoutTransition = animated ? .animated(duration: 0.4, curve: .spring) : .immediate + transition.updateAlpha(node: self.tabContainerNode, alpha: tabsIsEmpty ? 0.0 : 1.0) self.setDisplayNavigationBar(true, transition: transition) completion?() diff --git a/submodules/ComponentFlow/Source/Base/Transition.swift b/submodules/ComponentFlow/Source/Base/Transition.swift index 16dbadebcb..a5804ce4dc 100644 --- a/submodules/ComponentFlow/Source/Base/Transition.swift +++ b/submodules/ComponentFlow/Source/Base/Transition.swift @@ -73,6 +73,17 @@ public struct Transition { case easeInOut case spring case custom(Float, Float, Float, Float) + + public func solve(at offset: CGFloat) -> CGFloat { + switch self { + case .easeInOut: + return listViewAnimationCurveEaseInOut(offset) + case .spring: + return listViewAnimationCurveSystem(offset) + case let .custom(c1x, c1y, c2x, c2y): + return bezierPoint(CGFloat(c1x), CGFloat(c1y), CGFloat(c2x), CGFloat(c2y), offset) + } + } } case none @@ -421,7 +432,24 @@ public struct Transition { self.setTransform(layer: view.layer, transform: transform, completion: completion) } + public func setTransformAsKeyframes(view: UIView, transform: (CGFloat) -> CATransform3D, completion: ((Bool) -> Void)? = nil) { + self.setTransformAsKeyframes(layer: view.layer, transform: transform, completion: completion) + } + public func setTransform(layer: CALayer, transform: CATransform3D, completion: ((Bool) -> Void)? = nil) { + let t = layer.presentation()?.transform ?? layer.transform + if CATransform3DEqualToTransform(t, transform) { + if let animation = layer.animation(forKey: "transform") as? CABasicAnimation, let toValue = animation.toValue as? NSValue { + if CATransform3DEqualToTransform(toValue.caTransform3DValue, transform) { + completion?(true) + return + } + } else { + completion?(true) + return + } + } + switch self.animation { case .none: layer.transform = transform @@ -433,6 +461,7 @@ public struct Transition { } else { previousValue = layer.transform } + layer.transform = transform layer.animate( from: NSValue(caTransform3D: previousValue), @@ -448,6 +477,59 @@ public struct Transition { } } + public func setTransformAsKeyframes(layer: CALayer, transform: (CGFloat) -> CATransform3D, completion: ((Bool) -> Void)? = nil) { + let finalTransform = transform(1.0) + + let t = layer.presentation()?.transform ?? layer.transform + if CATransform3DEqualToTransform(t, finalTransform) { + if let animation = layer.animation(forKey: "transform") as? CABasicAnimation, let toValue = animation.toValue as? NSValue { + if CATransform3DEqualToTransform(toValue.caTransform3DValue, finalTransform) { + completion?(true) + return + } + } else { + completion?(true) + return + } + } + + switch self.animation { + case .none: + layer.transform = transform(1.0) + completion?(true) + case let .curve(duration, curve): + let framesPerSecond: CGFloat + if #available(iOS 15.0, *) { + framesPerSecond = duration * CGFloat(UIScreen.main.maximumFramesPerSecond) + } else { + framesPerSecond = 60.0 + } + + let numValues = Int(framesPerSecond * duration) + if numValues == 0 { + layer.transform = transform(1.0) + completion?(true) + return + } + + var values: [AnyObject] = [] + + for i in 0 ... numValues { + let t = curve.solve(at: CGFloat(i) / CGFloat(numValues)) + values.append(NSValue(caTransform3D: transform(t))) + } + + layer.transform = transform(1.0) + layer.animateKeyframes( + values: values, + duration: duration, + keyPath: "transform", + removeOnCompletion: true, + completion: completion + ) + } + } + public func setSublayerTransform(view: UIView, transform: CATransform3D, completion: ((Bool) -> Void)? = nil) { self.setSublayerTransform(layer: view.layer, transform: transform, completion: completion) } diff --git a/submodules/Display/Source/NavigationBar.swift b/submodules/Display/Source/NavigationBar.swift index d033d54219..daa31364b2 100644 --- a/submodules/Display/Source/NavigationBar.swift +++ b/submodules/Display/Source/NavigationBar.swift @@ -1066,6 +1066,8 @@ open class NavigationBar: ASDisplayNode { private var transitionBackArrowNode: ASDisplayNode? private var transitionBadgeNode: ASDisplayNode? + public var secondaryContentHeight: CGFloat + public init(presentationData: NavigationBarPresentationData) { self.presentationData = presentationData self.stripeNode = ASDisplayNode() @@ -1111,6 +1113,8 @@ open class NavigationBar: ASDisplayNode { self.backgroundNode = NavigationBackgroundNode(color: self.presentationData.theme.backgroundColor, enableBlur: self.presentationData.theme.enableBackgroundBlur) self.additionalContentNode = SparseNode() + self.secondaryContentHeight = NavigationBar.defaultSecondaryContentHeight + super.init() self.addSubnode(self.backgroundNode) @@ -1235,7 +1239,7 @@ open class NavigationBar: ASDisplayNode { self.backgroundNode.update(size: backgroundFrame.size, transition: transition) } - let apparentAdditionalHeight: CGFloat = self.secondaryContentNode != nil ? (NavigationBar.defaultSecondaryContentHeight * self.secondaryContentNodeDisplayFraction) : 0.0 + let apparentAdditionalHeight: CGFloat = self.secondaryContentNode != nil ? (self.secondaryContentHeight * self.secondaryContentNodeDisplayFraction) : 0.0 let leftButtonInset: CGFloat = leftInset + 16.0 let backButtonInset: CGFloat = leftInset + 27.0 @@ -1253,11 +1257,11 @@ open class NavigationBar: ASDisplayNode { case .expansion: expansionHeight = contentNode.height - let additionalExpansionHeight: CGFloat = self.secondaryContentNode != nil && appearsHidden ? (NavigationBar.defaultSecondaryContentHeight * self.secondaryContentNodeDisplayFraction) : 0.0 + let additionalExpansionHeight: CGFloat = self.secondaryContentNode != nil && appearsHidden ? (self.secondaryContentHeight * self.secondaryContentNodeDisplayFraction) : 0.0 contentNodeFrame = CGRect(origin: CGPoint(x: 0.0, y: size.height - (appearsHidden ? 0.0 : additionalContentHeight) - expansionHeight - apparentAdditionalHeight - additionalExpansionHeight), size: CGSize(width: size.width, height: expansionHeight)) if appearsHidden { if self.secondaryContentNode != nil { - contentNodeFrame.origin.y += NavigationBar.defaultSecondaryContentHeight * self.secondaryContentNodeDisplayFraction + contentNodeFrame.origin.y += self.secondaryContentHeight * self.secondaryContentNodeDisplayFraction } } } @@ -1619,7 +1623,7 @@ open class NavigationBar: ASDisplayNode { } if let _ = self.secondaryContentNode { - result += NavigationBar.defaultSecondaryContentHeight * self.secondaryContentNodeDisplayFraction + result += self.secondaryContentHeight * self.secondaryContentNodeDisplayFraction } return result diff --git a/submodules/Display/Source/ViewController.swift b/submodules/Display/Source/ViewController.swift index 4145a747e9..1444cb8bcf 100644 --- a/submodules/Display/Source/ViewController.swift +++ b/submodules/Display/Source/ViewController.swift @@ -416,11 +416,9 @@ public protocol CustomViewControllerNavigationDataSummary: AnyObject { if let contentNode = navigationBar.contentNode, case .expansion = contentNode.mode, !self.displayNavigationBar { navigationBarFrame.origin.y -= navigationLayout.defaultContentHeight navigationBarFrame.size.height += contentNode.height + navigationLayout.defaultContentHeight + statusBarHeight - //navigationBarFrame.origin.y += contentNode.height + statusBarHeight } if let _ = navigationBar.contentNode, let _ = navigationBar.secondaryContentNode, !self.displayNavigationBar { - navigationBarFrame.size.height += NavigationBar.defaultSecondaryContentHeight - //navigationBarFrame.origin.y += NavigationBar.defaultSecondaryContentHeight + navigationBarFrame.size.height += navigationBar.secondaryContentHeight } navigationBar.updateLayout(size: navigationBarFrame.size, defaultHeight: navigationLayout.defaultContentHeight, additionalTopHeight: statusBarHeight, additionalContentHeight: self.additionalNavigationBarHeight, additionalBackgroundHeight: additionalBackgroundHeight, leftInset: layout.safeInsets.left, rightInset: layout.safeInsets.right, appearsHidden: !self.displayNavigationBar, isLandscape: isLandscape, transition: transition) diff --git a/submodules/SettingsUI/Sources/Privacy and Security/PrivacyAndSecurityController.swift b/submodules/SettingsUI/Sources/Privacy and Security/PrivacyAndSecurityController.swift index 0b2baf2d02..52bda10ef1 100644 --- a/submodules/SettingsUI/Sources/Privacy and Security/PrivacyAndSecurityController.swift +++ b/submodules/SettingsUI/Sources/Privacy and Security/PrivacyAndSecurityController.swift @@ -444,11 +444,23 @@ private func countForSelectivePeers(_ peers: [PeerId: SelectivePrivacyPeer]) -> private func stringForSelectiveSettings(strings: PresentationStrings, settings: SelectivePrivacySettings) -> String { switch settings { - case let .disableEveryone(enableFor): - if enableFor.isEmpty { + case let .disableEveryone(enableFor, enableForCloseFriends): + if enableFor.isEmpty && !enableForCloseFriends { return strings.PrivacySettings_LastSeenNobody } else { - return strings.PrivacySettings_LastSeenNobodyPlus("\(countForSelectivePeers(enableFor))").string + if enableForCloseFriends { + if enableFor.isEmpty { + return strings.PrivacySettings_LastSeenCloseFriendsPlus("\(countForSelectivePeers(enableFor))").string + } else { + return strings.PrivacySettings_LastSeenCloseFriends + } + } else { + if enableFor.isEmpty { + return strings.PrivacySettings_LastSeenNobody + } else { + return strings.PrivacySettings_LastSeenNobodyPlus("\(countForSelectivePeers(enableFor))").string + } + } } case let .enableEveryone(disableFor): if disableFor.isEmpty { diff --git a/submodules/SettingsUI/Sources/Privacy and Security/SelectivePrivacySettingsController.swift b/submodules/SettingsUI/Sources/Privacy and Security/SelectivePrivacySettingsController.swift index f1287de315..d011fc453d 100644 --- a/submodules/SettingsUI/Sources/Privacy and Security/SelectivePrivacySettingsController.swift +++ b/submodules/SettingsUI/Sources/Privacy and Security/SelectivePrivacySettingsController.swift @@ -487,6 +487,7 @@ private struct SelectivePrivacySettingsControllerState: Equatable { let setting: SelectivePrivacySettingType let enableFor: [EnginePeer.Id: SelectivePrivacyPeer] let disableFor: [EnginePeer.Id: SelectivePrivacyPeer] + let enableForCloseFriends: Bool let saving: Bool @@ -494,21 +495,24 @@ private struct SelectivePrivacySettingsControllerState: Equatable { let callP2PMode: SelectivePrivacySettingType? let callP2PEnableFor: [EnginePeer.Id: SelectivePrivacyPeer]? let callP2PDisableFor: [EnginePeer.Id: SelectivePrivacyPeer]? + let callP2PEnableForCloseFriends: Bool? let callIntegrationAvailable: Bool? let callIntegrationEnabled: Bool? let phoneDiscoveryEnabled: Bool? let uploadedPhoto: UIImage? - init(setting: SelectivePrivacySettingType, enableFor: [EnginePeer.Id: SelectivePrivacyPeer], disableFor: [EnginePeer.Id: SelectivePrivacyPeer], saving: Bool, callDataSaving: VoiceCallDataSaving?, callP2PMode: SelectivePrivacySettingType?, callP2PEnableFor: [EnginePeer.Id: SelectivePrivacyPeer]?, callP2PDisableFor: [EnginePeer.Id: SelectivePrivacyPeer]?, callIntegrationAvailable: Bool?, callIntegrationEnabled: Bool?, phoneDiscoveryEnabled: Bool?, uploadedPhoto: UIImage?) { + init(setting: SelectivePrivacySettingType, enableFor: [EnginePeer.Id: SelectivePrivacyPeer], disableFor: [EnginePeer.Id: SelectivePrivacyPeer], enableForCloseFriends: Bool, saving: Bool, callDataSaving: VoiceCallDataSaving?, callP2PMode: SelectivePrivacySettingType?, callP2PEnableFor: [EnginePeer.Id: SelectivePrivacyPeer]?, callP2PDisableFor: [EnginePeer.Id: SelectivePrivacyPeer]?, callP2PEnableForCloseFriends: Bool?, callIntegrationAvailable: Bool?, callIntegrationEnabled: Bool?, phoneDiscoveryEnabled: Bool?, uploadedPhoto: UIImage?) { self.setting = setting self.enableFor = enableFor self.disableFor = disableFor + self.enableForCloseFriends = enableForCloseFriends self.saving = saving self.callDataSaving = callDataSaving self.callP2PMode = callP2PMode self.callP2PEnableFor = callP2PEnableFor self.callP2PDisableFor = callP2PDisableFor + self.callP2PEnableForCloseFriends = callP2PEnableForCloseFriends self.callIntegrationAvailable = callIntegrationAvailable self.callIntegrationEnabled = callIntegrationEnabled self.phoneDiscoveryEnabled = phoneDiscoveryEnabled @@ -525,6 +529,9 @@ private struct SelectivePrivacySettingsControllerState: Equatable { if lhs.disableFor != rhs.disableFor { return false } + if lhs.enableForCloseFriends != rhs.enableForCloseFriends { + return false + } if lhs.saving != rhs.saving { return false } @@ -540,6 +547,9 @@ private struct SelectivePrivacySettingsControllerState: Equatable { if lhs.callP2PDisableFor != rhs.callP2PDisableFor { return false } + if lhs.callP2PEnableForCloseFriends != rhs.callP2PEnableForCloseFriends { + return false + } if lhs.callIntegrationAvailable != rhs.callIntegrationAvailable { return false } @@ -557,43 +567,51 @@ private struct SelectivePrivacySettingsControllerState: Equatable { } func withUpdatedSetting(_ setting: SelectivePrivacySettingType) -> SelectivePrivacySettingsControllerState { - return SelectivePrivacySettingsControllerState(setting: setting, enableFor: self.enableFor, disableFor: self.disableFor, saving: self.saving, callDataSaving: self.callDataSaving, callP2PMode: self.callP2PMode, callP2PEnableFor: self.callP2PEnableFor, callP2PDisableFor: self.callP2PDisableFor, callIntegrationAvailable: self.callIntegrationAvailable, callIntegrationEnabled: self.callIntegrationEnabled, phoneDiscoveryEnabled: self.phoneDiscoveryEnabled, uploadedPhoto: self.uploadedPhoto) + return SelectivePrivacySettingsControllerState(setting: setting, enableFor: self.enableFor, disableFor: self.disableFor, enableForCloseFriends: self.enableForCloseFriends, saving: self.saving, callDataSaving: self.callDataSaving, callP2PMode: self.callP2PMode, callP2PEnableFor: self.callP2PEnableFor, callP2PDisableFor: self.callP2PDisableFor, callP2PEnableForCloseFriends: self.callP2PEnableForCloseFriends, callIntegrationAvailable: self.callIntegrationAvailable, callIntegrationEnabled: self.callIntegrationEnabled, phoneDiscoveryEnabled: self.phoneDiscoveryEnabled, uploadedPhoto: self.uploadedPhoto) } func withUpdatedEnableFor(_ enableFor: [EnginePeer.Id: SelectivePrivacyPeer]) -> SelectivePrivacySettingsControllerState { - return SelectivePrivacySettingsControllerState(setting: self.setting, enableFor: enableFor, disableFor: self.disableFor, saving: self.saving, callDataSaving: self.callDataSaving, callP2PMode: self.callP2PMode, callP2PEnableFor: self.callP2PEnableFor, callP2PDisableFor: self.callP2PDisableFor, callIntegrationAvailable: self.callIntegrationAvailable, callIntegrationEnabled: self.callIntegrationEnabled, phoneDiscoveryEnabled: self.phoneDiscoveryEnabled, uploadedPhoto: self.uploadedPhoto) + return SelectivePrivacySettingsControllerState(setting: self.setting, enableFor: enableFor, disableFor: self.disableFor, enableForCloseFriends: self.enableForCloseFriends, saving: self.saving, callDataSaving: self.callDataSaving, callP2PMode: self.callP2PMode, callP2PEnableFor: self.callP2PEnableFor, callP2PDisableFor: self.callP2PDisableFor, callP2PEnableForCloseFriends: self.callP2PEnableForCloseFriends, callIntegrationAvailable: self.callIntegrationAvailable, callIntegrationEnabled: self.callIntegrationEnabled, phoneDiscoveryEnabled: self.phoneDiscoveryEnabled, uploadedPhoto: self.uploadedPhoto) } func withUpdatedDisableFor(_ disableFor: [EnginePeer.Id: SelectivePrivacyPeer]) -> SelectivePrivacySettingsControllerState { - return SelectivePrivacySettingsControllerState(setting: self.setting, enableFor: self.enableFor, disableFor: disableFor, saving: self.saving, callDataSaving: self.callDataSaving, callP2PMode: self.callP2PMode, callP2PEnableFor: self.callP2PEnableFor, callP2PDisableFor: self.callP2PDisableFor, callIntegrationAvailable: self.callIntegrationAvailable, callIntegrationEnabled: self.callIntegrationEnabled, phoneDiscoveryEnabled: self.phoneDiscoveryEnabled, uploadedPhoto: self.uploadedPhoto) + return SelectivePrivacySettingsControllerState(setting: self.setting, enableFor: self.enableFor, disableFor: disableFor, enableForCloseFriends: self.enableForCloseFriends, saving: self.saving, callDataSaving: self.callDataSaving, callP2PMode: self.callP2PMode, callP2PEnableFor: self.callP2PEnableFor, callP2PDisableFor: self.callP2PDisableFor, callP2PEnableForCloseFriends: self.callP2PEnableForCloseFriends, callIntegrationAvailable: self.callIntegrationAvailable, callIntegrationEnabled: self.callIntegrationEnabled, phoneDiscoveryEnabled: self.phoneDiscoveryEnabled, uploadedPhoto: self.uploadedPhoto) + } + + func withUpdatedEnableForCloseFriends(_ enableForCloseFriends: Bool) -> SelectivePrivacySettingsControllerState { + return SelectivePrivacySettingsControllerState(setting: self.setting, enableFor: self.enableFor, disableFor: self.disableFor, enableForCloseFriends: enableForCloseFriends, saving: self.saving, callDataSaving: self.callDataSaving, callP2PMode: self.callP2PMode, callP2PEnableFor: self.callP2PEnableFor, callP2PDisableFor: self.callP2PDisableFor, callP2PEnableForCloseFriends: self.callP2PEnableForCloseFriends, callIntegrationAvailable: self.callIntegrationAvailable, callIntegrationEnabled: self.callIntegrationEnabled, phoneDiscoveryEnabled: self.phoneDiscoveryEnabled, uploadedPhoto: self.uploadedPhoto) } func withUpdatedSaving(_ saving: Bool) -> SelectivePrivacySettingsControllerState { - return SelectivePrivacySettingsControllerState(setting: self.setting, enableFor: self.enableFor, disableFor: self.disableFor, saving: saving, callDataSaving: self.callDataSaving, callP2PMode: self.callP2PMode, callP2PEnableFor: self.callP2PEnableFor, callP2PDisableFor: self.callP2PDisableFor, callIntegrationAvailable: self.callIntegrationAvailable, callIntegrationEnabled: self.callIntegrationEnabled, phoneDiscoveryEnabled: self.phoneDiscoveryEnabled, uploadedPhoto: self.uploadedPhoto) + return SelectivePrivacySettingsControllerState(setting: self.setting, enableFor: self.enableFor, disableFor: self.disableFor, enableForCloseFriends: self.enableForCloseFriends, saving: saving, callDataSaving: self.callDataSaving, callP2PMode: self.callP2PMode, callP2PEnableFor: self.callP2PEnableFor, callP2PDisableFor: self.callP2PDisableFor, callP2PEnableForCloseFriends: self.callP2PEnableForCloseFriends, callIntegrationAvailable: self.callIntegrationAvailable, callIntegrationEnabled: self.callIntegrationEnabled, phoneDiscoveryEnabled: self.phoneDiscoveryEnabled, uploadedPhoto: self.uploadedPhoto) } func withUpdatedCallP2PMode(_ mode: SelectivePrivacySettingType) -> SelectivePrivacySettingsControllerState { - return SelectivePrivacySettingsControllerState(setting: self.setting, enableFor: self.enableFor, disableFor: self.disableFor, saving: self.saving, callDataSaving: self.callDataSaving, callP2PMode: mode, callP2PEnableFor: self.callP2PEnableFor, callP2PDisableFor: self.callP2PDisableFor, callIntegrationAvailable: self.callIntegrationAvailable, callIntegrationEnabled: self.callIntegrationEnabled, phoneDiscoveryEnabled: self.phoneDiscoveryEnabled, uploadedPhoto: self.uploadedPhoto) + return SelectivePrivacySettingsControllerState(setting: self.setting, enableFor: self.enableFor, disableFor: self.disableFor, enableForCloseFriends: self.enableForCloseFriends, saving: self.saving, callDataSaving: self.callDataSaving, callP2PMode: mode, callP2PEnableFor: self.callP2PEnableFor, callP2PDisableFor: self.callP2PDisableFor, callP2PEnableForCloseFriends: self.callP2PEnableForCloseFriends, callIntegrationAvailable: self.callIntegrationAvailable, callIntegrationEnabled: self.callIntegrationEnabled, phoneDiscoveryEnabled: self.phoneDiscoveryEnabled, uploadedPhoto: self.uploadedPhoto) } func withUpdatedCallP2PEnableFor(_ enableFor: [EnginePeer.Id: SelectivePrivacyPeer]) -> SelectivePrivacySettingsControllerState { - return SelectivePrivacySettingsControllerState(setting: self.setting, enableFor: self.enableFor, disableFor: self.disableFor, saving: self.saving, callDataSaving: self.callDataSaving, callP2PMode: self.callP2PMode, callP2PEnableFor: enableFor, callP2PDisableFor: self.callP2PDisableFor, callIntegrationAvailable: self.callIntegrationAvailable, callIntegrationEnabled: self.callIntegrationEnabled, phoneDiscoveryEnabled: self.phoneDiscoveryEnabled, uploadedPhoto: self.uploadedPhoto) + return SelectivePrivacySettingsControllerState(setting: self.setting, enableFor: self.enableFor, disableFor: self.disableFor, enableForCloseFriends: self.enableForCloseFriends, saving: self.saving, callDataSaving: self.callDataSaving, callP2PMode: self.callP2PMode, callP2PEnableFor: enableFor, callP2PDisableFor: self.callP2PDisableFor, callP2PEnableForCloseFriends: self.callP2PEnableForCloseFriends, callIntegrationAvailable: self.callIntegrationAvailable, callIntegrationEnabled: self.callIntegrationEnabled, phoneDiscoveryEnabled: self.phoneDiscoveryEnabled, uploadedPhoto: self.uploadedPhoto) } func withUpdatedCallP2PDisableFor(_ disableFor: [EnginePeer.Id: SelectivePrivacyPeer]) -> SelectivePrivacySettingsControllerState { - return SelectivePrivacySettingsControllerState(setting: self.setting, enableFor: self.enableFor, disableFor: self.disableFor, saving: self.saving, callDataSaving: self.callDataSaving, callP2PMode: self.callP2PMode, callP2PEnableFor: self.callP2PEnableFor, callP2PDisableFor: disableFor, callIntegrationAvailable: self.callIntegrationAvailable, callIntegrationEnabled: self.callIntegrationEnabled, phoneDiscoveryEnabled: self.phoneDiscoveryEnabled, uploadedPhoto: self.uploadedPhoto) + return SelectivePrivacySettingsControllerState(setting: self.setting, enableFor: self.enableFor, disableFor: self.disableFor, enableForCloseFriends: self.enableForCloseFriends, saving: self.saving, callDataSaving: self.callDataSaving, callP2PMode: self.callP2PMode, callP2PEnableFor: self.callP2PEnableFor, callP2PDisableFor: disableFor, callP2PEnableForCloseFriends: self.callP2PEnableForCloseFriends, callIntegrationAvailable: self.callIntegrationAvailable, callIntegrationEnabled: self.callIntegrationEnabled, phoneDiscoveryEnabled: self.phoneDiscoveryEnabled, uploadedPhoto: self.uploadedPhoto) + } + + func withUpdatedCallP2PEnableForCloseFriends(_ callP2PEnableForCloseFriends: Bool) -> SelectivePrivacySettingsControllerState { + return SelectivePrivacySettingsControllerState(setting: self.setting, enableFor: self.enableFor, disableFor: self.disableFor, enableForCloseFriends: self.enableForCloseFriends, saving: self.saving, callDataSaving: self.callDataSaving, callP2PMode: self.callP2PMode, callP2PEnableFor: self.callP2PEnableFor, callP2PDisableFor: self.callP2PDisableFor, callP2PEnableForCloseFriends: callP2PEnableForCloseFriends, callIntegrationAvailable: self.callIntegrationAvailable, callIntegrationEnabled: self.callIntegrationEnabled, phoneDiscoveryEnabled: self.phoneDiscoveryEnabled, uploadedPhoto: self.uploadedPhoto) } func withUpdatedCallsIntegrationEnabled(_ enabled: Bool) -> SelectivePrivacySettingsControllerState { - return SelectivePrivacySettingsControllerState(setting: self.setting, enableFor: self.enableFor, disableFor: self.disableFor, saving: self.saving, callDataSaving: self.callDataSaving, callP2PMode: self.callP2PMode, callP2PEnableFor: self.callP2PEnableFor, callP2PDisableFor: self.callP2PDisableFor, callIntegrationAvailable: self.callIntegrationAvailable, callIntegrationEnabled: enabled, phoneDiscoveryEnabled: self.phoneDiscoveryEnabled, uploadedPhoto: self.uploadedPhoto) + return SelectivePrivacySettingsControllerState(setting: self.setting, enableFor: self.enableFor, disableFor: self.disableFor, enableForCloseFriends: self.enableForCloseFriends, saving: self.saving, callDataSaving: self.callDataSaving, callP2PMode: self.callP2PMode, callP2PEnableFor: self.callP2PEnableFor, callP2PDisableFor: self.callP2PDisableFor, callP2PEnableForCloseFriends: self.callP2PEnableForCloseFriends, callIntegrationAvailable: self.callIntegrationAvailable, callIntegrationEnabled: enabled, phoneDiscoveryEnabled: self.phoneDiscoveryEnabled, uploadedPhoto: self.uploadedPhoto) } func withUpdatedPhoneDiscoveryEnabled(_ phoneDiscoveryEnabled: Bool) -> SelectivePrivacySettingsControllerState { - return SelectivePrivacySettingsControllerState(setting: self.setting, enableFor: self.enableFor, disableFor: self.disableFor, saving: self.saving, callDataSaving: self.callDataSaving, callP2PMode: self.callP2PMode, callP2PEnableFor: self.callP2PEnableFor, callP2PDisableFor: self.callP2PDisableFor, callIntegrationAvailable: self.callIntegrationAvailable, callIntegrationEnabled: self.callIntegrationEnabled, phoneDiscoveryEnabled: phoneDiscoveryEnabled, uploadedPhoto: self.uploadedPhoto) + return SelectivePrivacySettingsControllerState(setting: self.setting, enableFor: self.enableFor, disableFor: self.disableFor, enableForCloseFriends: self.enableForCloseFriends, saving: self.saving, callDataSaving: self.callDataSaving, callP2PMode: self.callP2PMode, callP2PEnableFor: self.callP2PEnableFor, callP2PDisableFor: self.callP2PDisableFor, callP2PEnableForCloseFriends: self.callP2PEnableForCloseFriends, callIntegrationAvailable: self.callIntegrationAvailable, callIntegrationEnabled: self.callIntegrationEnabled, phoneDiscoveryEnabled: phoneDiscoveryEnabled, uploadedPhoto: self.uploadedPhoto) } func withUpdatedUploadedPhoto(_ uploadedPhoto: UIImage?) -> SelectivePrivacySettingsControllerState { - return SelectivePrivacySettingsControllerState(setting: self.setting, enableFor: self.enableFor, disableFor: self.disableFor, saving: self.saving, callDataSaving: self.callDataSaving, callP2PMode: self.callP2PMode, callP2PEnableFor: self.callP2PEnableFor, callP2PDisableFor: self.callP2PDisableFor, callIntegrationAvailable: self.callIntegrationAvailable, callIntegrationEnabled: self.callIntegrationEnabled, phoneDiscoveryEnabled: self.phoneDiscoveryEnabled, uploadedPhoto: uploadedPhoto) + return SelectivePrivacySettingsControllerState(setting: self.setting, enableFor: self.enableFor, disableFor: self.disableFor, enableForCloseFriends: self.enableForCloseFriends, saving: self.saving, callDataSaving: self.callDataSaving, callP2PMode: self.callP2PMode, callP2PEnableFor: self.callP2PEnableFor, callP2PDisableFor: self.callP2PDisableFor, callP2PEnableForCloseFriends: self.callP2PEnableForCloseFriends, callIntegrationAvailable: self.callIntegrationAvailable, callIntegrationEnabled: self.callIntegrationEnabled, phoneDiscoveryEnabled: self.phoneDiscoveryEnabled, uploadedPhoto: uploadedPhoto) } } @@ -764,9 +782,11 @@ func selectivePrivacySettingsController( var initialEnableFor: [EnginePeer.Id: SelectivePrivacyPeer] = [:] var initialDisableFor: [EnginePeer.Id: SelectivePrivacyPeer] = [:] + var initialEnableForCloseFriends = false switch current { - case let .disableEveryone(enableFor): + case let .disableEveryone(enableFor, enableForCloseFriends): initialEnableFor = enableFor + initialEnableForCloseFriends = enableForCloseFriends case let .enableContacts(enableFor, disableFor): initialEnableFor = enableFor initialDisableFor = disableFor @@ -775,11 +795,13 @@ func selectivePrivacySettingsController( } var initialCallP2PEnableFor: [EnginePeer.Id: SelectivePrivacyPeer]? var initialCallP2PDisableFor: [EnginePeer.Id: SelectivePrivacyPeer]? + var initialCallEnableForCloseFriends = false if let callCurrent = callSettings?.0 { switch callCurrent { - case let .disableEveryone(enableFor): + case let .disableEveryone(enableFor, enableForCloseFriends): initialCallP2PEnableFor = enableFor initialCallP2PDisableFor = [:] + initialCallEnableForCloseFriends = enableForCloseFriends case let .enableContacts(enableFor, disableFor): initialCallP2PEnableFor = enableFor initialCallP2PDisableFor = disableFor @@ -789,7 +811,7 @@ func selectivePrivacySettingsController( } } - let initialState = SelectivePrivacySettingsControllerState(setting: SelectivePrivacySettingType(current), enableFor: initialEnableFor, disableFor: initialDisableFor, saving: false, callDataSaving: callSettings?.1.dataSaving, callP2PMode: callSettings != nil ? SelectivePrivacySettingType(callSettings!.0) : nil, callP2PEnableFor: initialCallP2PEnableFor, callP2PDisableFor: initialCallP2PDisableFor, callIntegrationAvailable: callIntegrationAvailable, callIntegrationEnabled: callSettings?.1.enableSystemIntegration, phoneDiscoveryEnabled: phoneDiscoveryEnabled, uploadedPhoto: nil) + let initialState = SelectivePrivacySettingsControllerState(setting: SelectivePrivacySettingType(current), enableFor: initialEnableFor, disableFor: initialDisableFor, enableForCloseFriends: initialEnableForCloseFriends, saving: false, callDataSaving: callSettings?.1.dataSaving, callP2PMode: callSettings != nil ? SelectivePrivacySettingType(callSettings!.0) : nil, callP2PEnableFor: initialCallP2PEnableFor, callP2PDisableFor: initialCallP2PDisableFor, callP2PEnableForCloseFriends: initialCallEnableForCloseFriends, callIntegrationAvailable: callIntegrationAvailable, callIntegrationEnabled: callSettings?.1.enableSystemIntegration, phoneDiscoveryEnabled: phoneDiscoveryEnabled, uploadedPhoto: nil) let statePromise = ValuePromise(initialState, ignoreRepeated: true) let stateValue = Atomic(value: initialState) @@ -1116,21 +1138,21 @@ func selectivePrivacySettingsController( case .contacts: settings = SelectivePrivacySettings.enableContacts(enableFor: state.enableFor, disableFor: state.disableFor) case .nobody: - settings = SelectivePrivacySettings.disableEveryone(enableFor: state.enableFor) + settings = SelectivePrivacySettings.disableEveryone(enableFor: state.enableFor, enableForCloseFriends: state.enableForCloseFriends) } if case .phoneNumber = kind, let value = state.phoneDiscoveryEnabled { phoneDiscoveryEnabled = value } - if case .voiceCalls = kind, let callP2PMode = state.callP2PMode, let disableFor = state.callP2PDisableFor, let enableFor = state.callP2PEnableFor { + if case .voiceCalls = kind, let callP2PMode = state.callP2PMode, let disableFor = state.callP2PDisableFor, let enableFor = state.callP2PEnableFor, let enableForCloseFriends = state.callP2PEnableForCloseFriends { switch callP2PMode { case .everybody: callP2PSettings = SelectivePrivacySettings.enableEveryone(disableFor: disableFor) case .contacts: callP2PSettings = SelectivePrivacySettings.enableContacts(enableFor: enableFor, disableFor: disableFor) case .nobody: - callP2PSettings = SelectivePrivacySettings.disableEveryone(enableFor: enableFor) + callP2PSettings = SelectivePrivacySettings.disableEveryone(enableFor: enableFor, enableForCloseFriends: enableForCloseFriends) } } diff --git a/submodules/TelegramApi/Sources/Api0.swift b/submodules/TelegramApi/Sources/Api0.swift index b70cc30a1e..406604c07e 100644 --- a/submodules/TelegramApi/Sources/Api0.swift +++ b/submodules/TelegramApi/Sources/Api0.swift @@ -363,6 +363,7 @@ fileprivate let parsers: [Int32 : (BufferReader) -> Any?] = { dict[506920429] = { return Api.InputPhoneCall.parse_inputPhoneCall($0) } dict[1001634122] = { return Api.InputPhoto.parse_inputPhoto($0) } dict[483901197] = { return Api.InputPhoto.parse_inputPhotoEmpty($0) } + dict[941870144] = { return Api.InputPrivacyKey.parse_inputPrivacyKeyAbout($0) } dict[-786326563] = { return Api.InputPrivacyKey.parse_inputPrivacyKeyAddedByPhone($0) } dict[-1107622874] = { return Api.InputPrivacyKey.parse_inputPrivacyKeyChatInvite($0) } dict[-1529000952] = { return Api.InputPrivacyKey.parse_inputPrivacyKeyForwards($0) } @@ -374,6 +375,7 @@ fileprivate let parsers: [Int32 : (BufferReader) -> Any?] = { dict[-1360618136] = { return Api.InputPrivacyKey.parse_inputPrivacyKeyVoiceMessages($0) } dict[407582158] = { return Api.InputPrivacyRule.parse_inputPrivacyValueAllowAll($0) } dict[-2079962673] = { return Api.InputPrivacyRule.parse_inputPrivacyValueAllowChatParticipants($0) } + dict[793067081] = { return Api.InputPrivacyRule.parse_inputPrivacyValueAllowCloseFriends($0) } dict[218751099] = { return Api.InputPrivacyRule.parse_inputPrivacyValueAllowContacts($0) } dict[320652927] = { return Api.InputPrivacyRule.parse_inputPrivacyValueAllowUsers($0) } dict[-697604407] = { return Api.InputPrivacyRule.parse_inputPrivacyValueDisallowAll($0) } @@ -642,6 +644,7 @@ fileprivate let parsers: [Int32 : (BufferReader) -> Any?] = { dict[512535275] = { return Api.PostAddress.parse_postAddress($0) } dict[1958953753] = { return Api.PremiumGiftOption.parse_premiumGiftOption($0) } dict[1596792306] = { return Api.PremiumSubscriptionOption.parse_premiumSubscriptionOption($0) } + dict[-1534675103] = { return Api.PrivacyKey.parse_privacyKeyAbout($0) } dict[1124062251] = { return Api.PrivacyKey.parse_privacyKeyAddedByPhone($0) } dict[1343122938] = { return Api.PrivacyKey.parse_privacyKeyChatInvite($0) } dict[1777096355] = { return Api.PrivacyKey.parse_privacyKeyForwards($0) } @@ -653,6 +656,7 @@ fileprivate let parsers: [Int32 : (BufferReader) -> Any?] = { dict[110621716] = { return Api.PrivacyKey.parse_privacyKeyVoiceMessages($0) } dict[1698855810] = { return Api.PrivacyRule.parse_privacyValueAllowAll($0) } dict[1796427406] = { return Api.PrivacyRule.parse_privacyValueAllowChatParticipants($0) } + dict[-135735141] = { return Api.PrivacyRule.parse_privacyValueAllowCloseFriends($0) } dict[-123988] = { return Api.PrivacyRule.parse_privacyValueAllowContacts($0) } dict[-1198497870] = { return Api.PrivacyRule.parse_privacyValueAllowUsers($0) } dict[-1955338397] = { return Api.PrivacyRule.parse_privacyValueDisallowAll($0) } @@ -782,8 +786,9 @@ fileprivate let parsers: [Int32 : (BufferReader) -> Any?] = { dict[1087454222] = { return Api.StickerSetCovered.parse_stickerSetFullCovered($0) } dict[872932635] = { return Api.StickerSetCovered.parse_stickerSetMultiCovered($0) } dict[2008112412] = { return Api.StickerSetCovered.parse_stickerSetNoCovered($0) } - dict[-1230510430] = { return Api.StoryItem.parse_storyItem($0) } + dict[271121336] = { return Api.StoryItem.parse_storyItem($0) } dict[-2020380585] = { return Api.StoryItem.parse_storyItemDeleted($0) } + dict[90474706] = { return Api.StoryView.parse_storyView($0) } dict[1964978502] = { return Api.TextWithEntities.parse_textWithEntities($0) } dict[-1609668650] = { return Api.Theme.parse_theme($0) } dict[-94849324] = { return Api.ThemeSettings.parse_themeSettings($0) } @@ -890,6 +895,7 @@ fileprivate let parsers: [Int32 : (BufferReader) -> Any?] = { dict[-1667805217] = { return Api.Update.parse_updateReadHistoryInbox($0) } dict[791617983] = { return Api.Update.parse_updateReadHistoryOutbox($0) } dict[1757493555] = { return Api.Update.parse_updateReadMessagesContents($0) } + dict[-1653870963] = { return Api.Update.parse_updateReadStories($0) } dict[821314523] = { return Api.Update.parse_updateRecentEmojiStatuses($0) } dict[1870160884] = { return Api.Update.parse_updateRecentReactions($0) } dict[-1706939360] = { return Api.Update.parse_updateRecentStickers($0) } @@ -1149,6 +1155,7 @@ fileprivate let parsers: [Int32 : (BufferReader) -> Any?] = { dict[-1432995067] = { return Api.storage.FileType.parse_fileUnknown($0) } dict[276907596] = { return Api.storage.FileType.parse_fileWebp($0) } dict[1214632796] = { return Api.stories.AllStories.parse_allStories($0) } + dict[-79726676] = { return Api.stories.StoryViewsList.parse_storyViewsList($0) } dict[543450958] = { return Api.updates.ChannelDifference.parse_channelDifference($0) } dict[1041346555] = { return Api.updates.ChannelDifference.parse_channelDifferenceEmpty($0) } dict[-1531132162] = { return Api.updates.ChannelDifference.parse_channelDifferenceTooLong($0) } @@ -1702,6 +1709,8 @@ public extension Api { _1.serialize(buffer, boxed) case let _1 as Api.StoryItem: _1.serialize(buffer, boxed) + case let _1 as Api.StoryView: + _1.serialize(buffer, boxed) case let _1 as Api.TextWithEntities: _1.serialize(buffer, boxed) case let _1 as Api.Theme: @@ -2008,6 +2017,8 @@ public extension Api { _1.serialize(buffer, boxed) case let _1 as Api.stories.AllStories: _1.serialize(buffer, boxed) + case let _1 as Api.stories.StoryViewsList: + _1.serialize(buffer, boxed) case let _1 as Api.updates.ChannelDifference: _1.serialize(buffer, boxed) case let _1 as Api.updates.Difference: diff --git a/submodules/TelegramApi/Sources/Api17.swift b/submodules/TelegramApi/Sources/Api17.swift index 4e135a387e..41f414105a 100644 --- a/submodules/TelegramApi/Sources/Api17.swift +++ b/submodules/TelegramApi/Sources/Api17.swift @@ -714,6 +714,7 @@ public extension Api { } public extension Api { enum PrivacyKey: TypeConstructorDescription { + case privacyKeyAbout case privacyKeyAddedByPhone case privacyKeyChatInvite case privacyKeyForwards @@ -726,6 +727,12 @@ public extension Api { public func serialize(_ buffer: Buffer, _ boxed: Swift.Bool) { switch self { + case .privacyKeyAbout: + if boxed { + buffer.appendInt32(-1534675103) + } + + break case .privacyKeyAddedByPhone: if boxed { buffer.appendInt32(1124062251) @@ -785,6 +792,8 @@ public extension Api { public func descriptionFields() -> (String, [(String, Any)]) { switch self { + case .privacyKeyAbout: + return ("privacyKeyAbout", []) case .privacyKeyAddedByPhone: return ("privacyKeyAddedByPhone", []) case .privacyKeyChatInvite: @@ -806,6 +815,9 @@ public extension Api { } } + public static func parse_privacyKeyAbout(_ reader: BufferReader) -> PrivacyKey? { + return Api.PrivacyKey.privacyKeyAbout + } public static func parse_privacyKeyAddedByPhone(_ reader: BufferReader) -> PrivacyKey? { return Api.PrivacyKey.privacyKeyAddedByPhone } @@ -840,6 +852,7 @@ public extension Api { enum PrivacyRule: TypeConstructorDescription { case privacyValueAllowAll case privacyValueAllowChatParticipants(chats: [Int64]) + case privacyValueAllowCloseFriends case privacyValueAllowContacts case privacyValueAllowUsers(users: [Int64]) case privacyValueDisallowAll @@ -864,6 +877,12 @@ public extension Api { for item in chats { serializeInt64(item, buffer: buffer, boxed: false) } + break + case .privacyValueAllowCloseFriends: + if boxed { + buffer.appendInt32(-135735141) + } + break case .privacyValueAllowContacts: if boxed { @@ -922,6 +941,8 @@ public extension Api { return ("privacyValueAllowAll", []) case .privacyValueAllowChatParticipants(let chats): return ("privacyValueAllowChatParticipants", [("chats", chats as Any)]) + case .privacyValueAllowCloseFriends: + return ("privacyValueAllowCloseFriends", []) case .privacyValueAllowContacts: return ("privacyValueAllowContacts", []) case .privacyValueAllowUsers(let users): @@ -953,6 +974,9 @@ public extension Api { return nil } } + public static func parse_privacyValueAllowCloseFriends(_ reader: BufferReader) -> PrivacyRule? { + return Api.PrivacyRule.privacyValueAllowCloseFriends + } public static func parse_privacyValueAllowContacts(_ reader: BufferReader) -> PrivacyRule? { return Api.PrivacyRule.privacyValueAllowContacts } diff --git a/submodules/TelegramApi/Sources/Api21.swift b/submodules/TelegramApi/Sources/Api21.swift index a719e0f69a..35f4ec7c0b 100644 --- a/submodules/TelegramApi/Sources/Api21.swift +++ b/submodules/TelegramApi/Sources/Api21.swift @@ -328,18 +328,36 @@ public extension Api { } public extension Api { indirect enum StoryItem: TypeConstructorDescription { - case storyItem(id: Int64, date: Int32, media: Api.MessageMedia) + case storyItem(flags: Int32, id: Int64, date: Int32, caption: String?, entities: [Api.MessageEntity]?, media: Api.MessageMedia, privacy: [Api.PrivacyRule]?, recentViewers: [Int64]?, viewsCount: Int32?) case storyItemDeleted(id: Int64) public func serialize(_ buffer: Buffer, _ boxed: Swift.Bool) { switch self { - case .storyItem(let id, let date, let media): + case .storyItem(let flags, let id, let date, let caption, let entities, let media, let privacy, let recentViewers, let viewsCount): if boxed { - buffer.appendInt32(-1230510430) + buffer.appendInt32(271121336) } + serializeInt32(flags, buffer: buffer, boxed: false) serializeInt64(id, buffer: buffer, boxed: false) serializeInt32(date, buffer: buffer, boxed: false) + if Int(flags) & Int(1 << 0) != 0 {serializeString(caption!, buffer: buffer, boxed: false)} + if Int(flags) & Int(1 << 1) != 0 {buffer.appendInt32(481674261) + buffer.appendInt32(Int32(entities!.count)) + for item in entities! { + item.serialize(buffer, true) + }} media.serialize(buffer, true) + if Int(flags) & Int(1 << 2) != 0 {buffer.appendInt32(481674261) + buffer.appendInt32(Int32(privacy!.count)) + for item in privacy! { + item.serialize(buffer, true) + }} + if Int(flags) & Int(1 << 3) != 0 {buffer.appendInt32(481674261) + buffer.appendInt32(Int32(recentViewers!.count)) + for item in recentViewers! { + serializeInt64(item, buffer: buffer, boxed: false) + }} + if Int(flags) & Int(1 << 3) != 0 {serializeInt32(viewsCount!, buffer: buffer, boxed: false)} break case .storyItemDeleted(let id): if boxed { @@ -352,27 +370,51 @@ public extension Api { public func descriptionFields() -> (String, [(String, Any)]) { switch self { - case .storyItem(let id, let date, let media): - return ("storyItem", [("id", id as Any), ("date", date as Any), ("media", media as Any)]) + case .storyItem(let flags, let id, let date, let caption, let entities, let media, let privacy, let recentViewers, let viewsCount): + return ("storyItem", [("flags", flags as Any), ("id", id as Any), ("date", date as Any), ("caption", caption as Any), ("entities", entities as Any), ("media", media as Any), ("privacy", privacy as Any), ("recentViewers", recentViewers as Any), ("viewsCount", viewsCount as Any)]) case .storyItemDeleted(let id): return ("storyItemDeleted", [("id", id as Any)]) } } public static func parse_storyItem(_ reader: BufferReader) -> StoryItem? { - var _1: Int64? - _1 = reader.readInt64() - var _2: Int32? - _2 = reader.readInt32() - var _3: Api.MessageMedia? + var _1: Int32? + _1 = reader.readInt32() + var _2: Int64? + _2 = reader.readInt64() + var _3: Int32? + _3 = reader.readInt32() + var _4: String? + if Int(_1!) & Int(1 << 0) != 0 {_4 = parseString(reader) } + var _5: [Api.MessageEntity]? + if Int(_1!) & Int(1 << 1) != 0 {if let _ = reader.readInt32() { + _5 = Api.parseVector(reader, elementSignature: 0, elementType: Api.MessageEntity.self) + } } + var _6: Api.MessageMedia? if let signature = reader.readInt32() { - _3 = Api.parse(reader, signature: signature) as? Api.MessageMedia + _6 = Api.parse(reader, signature: signature) as? Api.MessageMedia } + var _7: [Api.PrivacyRule]? + if Int(_1!) & Int(1 << 2) != 0 {if let _ = reader.readInt32() { + _7 = Api.parseVector(reader, elementSignature: 0, elementType: Api.PrivacyRule.self) + } } + var _8: [Int64]? + if Int(_1!) & Int(1 << 3) != 0 {if let _ = reader.readInt32() { + _8 = Api.parseVector(reader, elementSignature: 570911930, elementType: Int64.self) + } } + var _9: Int32? + if Int(_1!) & Int(1 << 3) != 0 {_9 = reader.readInt32() } let _c1 = _1 != nil let _c2 = _2 != nil let _c3 = _3 != nil - if _c1 && _c2 && _c3 { - return Api.StoryItem.storyItem(id: _1!, date: _2!, media: _3!) + let _c4 = (Int(_1!) & Int(1 << 0) == 0) || _4 != nil + let _c5 = (Int(_1!) & Int(1 << 1) == 0) || _5 != nil + let _c6 = _6 != nil + let _c7 = (Int(_1!) & Int(1 << 2) == 0) || _7 != nil + let _c8 = (Int(_1!) & Int(1 << 3) == 0) || _8 != nil + let _c9 = (Int(_1!) & Int(1 << 3) == 0) || _9 != nil + if _c1 && _c2 && _c3 && _c4 && _c5 && _c6 && _c7 && _c8 && _c9 { + return Api.StoryItem.storyItem(flags: _1!, id: _2!, date: _3!, caption: _4, entities: _5, media: _6!, privacy: _7, recentViewers: _8, viewsCount: _9) } else { return nil @@ -392,6 +434,46 @@ public extension Api { } } +public extension Api { + enum StoryView: TypeConstructorDescription { + case storyView(userId: Int64, date: Int64) + + public func serialize(_ buffer: Buffer, _ boxed: Swift.Bool) { + switch self { + case .storyView(let userId, let date): + if boxed { + buffer.appendInt32(90474706) + } + serializeInt64(userId, buffer: buffer, boxed: false) + serializeInt64(date, buffer: buffer, boxed: false) + break + } + } + + public func descriptionFields() -> (String, [(String, Any)]) { + switch self { + case .storyView(let userId, let date): + return ("storyView", [("userId", userId as Any), ("date", date as Any)]) + } + } + + public static func parse_storyView(_ reader: BufferReader) -> StoryView? { + var _1: Int64? + _1 = reader.readInt64() + var _2: Int64? + _2 = reader.readInt64() + let _c1 = _1 != nil + let _c2 = _2 != nil + if _c1 && _c2 { + return Api.StoryView.storyView(userId: _1!, date: _2!) + } + else { + return nil + } + } + + } +} public extension Api { enum TextWithEntities: TypeConstructorDescription { case textWithEntities(text: String, entities: [Api.MessageEntity]) @@ -881,6 +963,7 @@ public extension Api { case updateReadHistoryInbox(flags: Int32, folderId: Int32?, peer: Api.Peer, maxId: Int32, stillUnreadCount: Int32, pts: Int32, ptsCount: Int32) case updateReadHistoryOutbox(peer: Api.Peer, maxId: Int32, pts: Int32, ptsCount: Int32) case updateReadMessagesContents(messages: [Int32], pts: Int32, ptsCount: Int32) + case updateReadStories(userId: Int64, id: [Int64]) case updateRecentEmojiStatuses case updateRecentReactions case updateRecentStickers @@ -1720,6 +1803,17 @@ public extension Api { serializeInt32(pts, buffer: buffer, boxed: false) serializeInt32(ptsCount, buffer: buffer, boxed: false) break + case .updateReadStories(let userId, let id): + if boxed { + buffer.appendInt32(-1653870963) + } + serializeInt64(userId, buffer: buffer, boxed: false) + buffer.appendInt32(481674261) + buffer.appendInt32(Int32(id.count)) + for item in id { + serializeInt64(item, buffer: buffer, boxed: false) + } + break case .updateRecentEmojiStatuses: if boxed { buffer.appendInt32(821314523) @@ -2056,6 +2150,8 @@ public extension Api { return ("updateReadHistoryOutbox", [("peer", peer as Any), ("maxId", maxId as Any), ("pts", pts as Any), ("ptsCount", ptsCount as Any)]) case .updateReadMessagesContents(let messages, let pts, let ptsCount): return ("updateReadMessagesContents", [("messages", messages as Any), ("pts", pts as Any), ("ptsCount", ptsCount as Any)]) + case .updateReadStories(let userId, let id): + return ("updateReadStories", [("userId", userId as Any), ("id", id as Any)]) case .updateRecentEmojiStatuses: return ("updateRecentEmojiStatuses", []) case .updateRecentReactions: @@ -3775,6 +3871,22 @@ public extension Api { return nil } } + public static func parse_updateReadStories(_ reader: BufferReader) -> Update? { + var _1: Int64? + _1 = reader.readInt64() + var _2: [Int64]? + if let _ = reader.readInt32() { + _2 = Api.parseVector(reader, elementSignature: 570911930, elementType: Int64.self) + } + let _c1 = _1 != nil + let _c2 = _2 != nil + if _c1 && _c2 { + return Api.Update.updateReadStories(userId: _1!, id: _2!) + } + else { + return nil + } + } public static func parse_updateRecentEmojiStatuses(_ reader: BufferReader) -> Update? { return Api.Update.updateRecentEmojiStatuses } diff --git a/submodules/TelegramApi/Sources/Api29.swift b/submodules/TelegramApi/Sources/Api29.swift index 2b8dcc4db3..945e8af007 100644 --- a/submodules/TelegramApi/Sources/Api29.swift +++ b/submodules/TelegramApi/Sources/Api29.swift @@ -418,6 +418,62 @@ public extension Api.stories { } } +public extension Api.stories { + enum StoryViewsList: TypeConstructorDescription { + case storyViewsList(count: Int32, views: [Api.StoryView], users: [Api.User]) + + public func serialize(_ buffer: Buffer, _ boxed: Swift.Bool) { + switch self { + case .storyViewsList(let count, let views, let users): + if boxed { + buffer.appendInt32(-79726676) + } + serializeInt32(count, buffer: buffer, boxed: false) + buffer.appendInt32(481674261) + buffer.appendInt32(Int32(views.count)) + for item in views { + item.serialize(buffer, true) + } + buffer.appendInt32(481674261) + buffer.appendInt32(Int32(users.count)) + for item in users { + item.serialize(buffer, true) + } + break + } + } + + public func descriptionFields() -> (String, [(String, Any)]) { + switch self { + case .storyViewsList(let count, let views, let users): + return ("storyViewsList", [("count", count as Any), ("views", views as Any), ("users", users as Any)]) + } + } + + public static func parse_storyViewsList(_ reader: BufferReader) -> StoryViewsList? { + var _1: Int32? + _1 = reader.readInt32() + var _2: [Api.StoryView]? + if let _ = reader.readInt32() { + _2 = Api.parseVector(reader, elementSignature: 0, elementType: Api.StoryView.self) + } + var _3: [Api.User]? + if let _ = reader.readInt32() { + _3 = Api.parseVector(reader, elementSignature: 0, elementType: Api.User.self) + } + let _c1 = _1 != nil + let _c2 = _2 != nil + let _c3 = _3 != nil + if _c1 && _c2 && _c3 { + return Api.stories.StoryViewsList.storyViewsList(count: _1!, views: _2!, users: _3!) + } + else { + return nil + } + } + + } +} public extension Api.updates { enum ChannelDifference: TypeConstructorDescription { case channelDifference(flags: Int32, pts: Int32, timeout: Int32?, newMessages: [Api.Message], otherUpdates: [Api.Update], chats: [Api.Chat], users: [Api.User]) diff --git a/submodules/TelegramApi/Sources/Api30.swift b/submodules/TelegramApi/Sources/Api30.swift index 35417f2dcc..18f3ead9bb 100644 --- a/submodules/TelegramApi/Sources/Api30.swift +++ b/submodules/TelegramApi/Sources/Api30.swift @@ -2042,6 +2042,22 @@ public extension Api.functions.channels { }) } } +public extension Api.functions.channels { + static func clickSponsoredMessage(channel: Api.InputChannel, randomId: Buffer) -> (FunctionDescription, Buffer, DeserializeFunctionResponse) { + let buffer = Buffer() + buffer.appendInt32(414170259) + channel.serialize(buffer, true) + serializeBytes(randomId, buffer: buffer, boxed: false) + return (FunctionDescription(name: "channels.clickSponsoredMessage", parameters: [("channel", String(describing: channel)), ("randomId", String(describing: randomId))]), buffer, DeserializeFunctionResponse { (buffer: Buffer) -> Api.Bool? in + let reader = BufferReader(buffer) + var result: Api.Bool? + if let signature = reader.readInt32() { + result = Api.parse(reader, signature: signature) as? Api.Bool + } + return result + }) + } +} public extension Api.functions.channels { static func convertToGigagroup(channel: Api.InputChannel) -> (FunctionDescription, Buffer, DeserializeFunctionResponse) { let buffer = Buffer() @@ -3270,6 +3286,25 @@ public extension Api.functions.contacts { }) } } +public extension Api.functions.contacts { + static func editCloseFriends(id: [Int64]) -> (FunctionDescription, Buffer, DeserializeFunctionResponse) { + let buffer = Buffer() + buffer.appendInt32(-1167653392) + buffer.appendInt32(481674261) + buffer.appendInt32(Int32(id.count)) + for item in id { + serializeInt64(item, buffer: buffer, boxed: false) + } + return (FunctionDescription(name: "contacts.editCloseFriends", parameters: [("id", String(describing: id))]), buffer, DeserializeFunctionResponse { (buffer: Buffer) -> Api.Bool? in + let reader = BufferReader(buffer) + var result: Api.Bool? + if let signature = reader.readInt32() { + result = Api.parse(reader, signature: signature) as? Api.Bool + } + return result + }) + } +} public extension Api.functions.contacts { static func exportContactToken() -> (FunctionDescription, Buffer, DeserializeFunctionResponse) { let buffer = Buffer() @@ -8452,6 +8487,24 @@ public extension Api.functions.stories { }) } } +public extension Api.functions.stories { + static func getStoryViews(id: Int64, offsetDate: Int32, offsetId: Int64, limit: Int32) -> (FunctionDescription, Buffer, DeserializeFunctionResponse) { + let buffer = Buffer() + buffer.appendInt32(-1233598578) + serializeInt64(id, buffer: buffer, boxed: false) + serializeInt32(offsetDate, buffer: buffer, boxed: false) + serializeInt64(offsetId, buffer: buffer, boxed: false) + serializeInt32(limit, buffer: buffer, boxed: false) + return (FunctionDescription(name: "stories.getStoryViews", parameters: [("id", String(describing: id)), ("offsetDate", String(describing: offsetDate)), ("offsetId", String(describing: offsetId)), ("limit", String(describing: limit))]), buffer, DeserializeFunctionResponse { (buffer: Buffer) -> Api.stories.StoryViewsList? in + let reader = BufferReader(buffer) + var result: Api.stories.StoryViewsList? + if let signature = reader.readInt32() { + result = Api.parse(reader, signature: signature) as? Api.stories.StoryViewsList + } + return result + }) + } +} public extension Api.functions.stories { static func getUserStories(userId: Api.InputUser) -> (FunctionDescription, Buffer, DeserializeFunctionResponse) { let buffer = Buffer() @@ -8468,16 +8521,43 @@ public extension Api.functions.stories { } } public extension Api.functions.stories { - static func sendStory(media: Api.InputMedia, privacyRules: [Api.InputPrivacyRule]) -> (FunctionDescription, Buffer, DeserializeFunctionResponse) { + static func readStories(userId: Api.InputUser, id: [Int64]) -> (FunctionDescription, Buffer, DeserializeFunctionResponse) { let buffer = Buffer() - buffer.appendInt32(-1310573354) + buffer.appendInt32(1026304810) + userId.serialize(buffer, true) + buffer.appendInt32(481674261) + buffer.appendInt32(Int32(id.count)) + for item in id { + serializeInt64(item, buffer: buffer, boxed: false) + } + return (FunctionDescription(name: "stories.readStories", parameters: [("userId", String(describing: userId)), ("id", String(describing: id))]), buffer, DeserializeFunctionResponse { (buffer: Buffer) -> Api.Bool? in + let reader = BufferReader(buffer) + var result: Api.Bool? + if let signature = reader.readInt32() { + result = Api.parse(reader, signature: signature) as? Api.Bool + } + return result + }) + } +} +public extension Api.functions.stories { + static func sendStory(flags: Int32, media: Api.InputMedia, caption: String?, entities: [Api.MessageEntity]?, privacyRules: [Api.InputPrivacyRule]) -> (FunctionDescription, Buffer, DeserializeFunctionResponse) { + let buffer = Buffer() + buffer.appendInt32(-1904054435) + serializeInt32(flags, buffer: buffer, boxed: false) media.serialize(buffer, true) + if Int(flags) & Int(1 << 0) != 0 {serializeString(caption!, buffer: buffer, boxed: false)} + if Int(flags) & Int(1 << 1) != 0 {buffer.appendInt32(481674261) + buffer.appendInt32(Int32(entities!.count)) + for item in entities! { + item.serialize(buffer, true) + }} buffer.appendInt32(481674261) buffer.appendInt32(Int32(privacyRules.count)) for item in privacyRules { item.serialize(buffer, true) } - return (FunctionDescription(name: "stories.sendStory", parameters: [("media", String(describing: media)), ("privacyRules", String(describing: privacyRules))]), buffer, DeserializeFunctionResponse { (buffer: Buffer) -> Api.Updates? in + return (FunctionDescription(name: "stories.sendStory", parameters: [("flags", String(describing: flags)), ("media", String(describing: media)), ("caption", String(describing: caption)), ("entities", String(describing: entities)), ("privacyRules", String(describing: privacyRules))]), buffer, DeserializeFunctionResponse { (buffer: Buffer) -> Api.Updates? in let reader = BufferReader(buffer) var result: Api.Updates? if let signature = reader.readInt32() { diff --git a/submodules/TelegramApi/Sources/Api9.swift b/submodules/TelegramApi/Sources/Api9.swift index c63a2eef9e..da64ca95fc 100644 --- a/submodules/TelegramApi/Sources/Api9.swift +++ b/submodules/TelegramApi/Sources/Api9.swift @@ -432,6 +432,7 @@ public extension Api { } public extension Api { enum InputPrivacyKey: TypeConstructorDescription { + case inputPrivacyKeyAbout case inputPrivacyKeyAddedByPhone case inputPrivacyKeyChatInvite case inputPrivacyKeyForwards @@ -444,6 +445,12 @@ public extension Api { public func serialize(_ buffer: Buffer, _ boxed: Swift.Bool) { switch self { + case .inputPrivacyKeyAbout: + if boxed { + buffer.appendInt32(941870144) + } + + break case .inputPrivacyKeyAddedByPhone: if boxed { buffer.appendInt32(-786326563) @@ -503,6 +510,8 @@ public extension Api { public func descriptionFields() -> (String, [(String, Any)]) { switch self { + case .inputPrivacyKeyAbout: + return ("inputPrivacyKeyAbout", []) case .inputPrivacyKeyAddedByPhone: return ("inputPrivacyKeyAddedByPhone", []) case .inputPrivacyKeyChatInvite: @@ -524,6 +533,9 @@ public extension Api { } } + public static func parse_inputPrivacyKeyAbout(_ reader: BufferReader) -> InputPrivacyKey? { + return Api.InputPrivacyKey.inputPrivacyKeyAbout + } public static func parse_inputPrivacyKeyAddedByPhone(_ reader: BufferReader) -> InputPrivacyKey? { return Api.InputPrivacyKey.inputPrivacyKeyAddedByPhone } @@ -558,6 +570,7 @@ public extension Api { enum InputPrivacyRule: TypeConstructorDescription { case inputPrivacyValueAllowAll case inputPrivacyValueAllowChatParticipants(chats: [Int64]) + case inputPrivacyValueAllowCloseFriends case inputPrivacyValueAllowContacts case inputPrivacyValueAllowUsers(users: [Api.InputUser]) case inputPrivacyValueDisallowAll @@ -582,6 +595,12 @@ public extension Api { for item in chats { serializeInt64(item, buffer: buffer, boxed: false) } + break + case .inputPrivacyValueAllowCloseFriends: + if boxed { + buffer.appendInt32(793067081) + } + break case .inputPrivacyValueAllowContacts: if boxed { @@ -640,6 +659,8 @@ public extension Api { return ("inputPrivacyValueAllowAll", []) case .inputPrivacyValueAllowChatParticipants(let chats): return ("inputPrivacyValueAllowChatParticipants", [("chats", chats as Any)]) + case .inputPrivacyValueAllowCloseFriends: + return ("inputPrivacyValueAllowCloseFriends", []) case .inputPrivacyValueAllowContacts: return ("inputPrivacyValueAllowContacts", []) case .inputPrivacyValueAllowUsers(let users): @@ -671,6 +692,9 @@ public extension Api { return nil } } + public static func parse_inputPrivacyValueAllowCloseFriends(_ reader: BufferReader) -> InputPrivacyRule? { + return Api.InputPrivacyRule.inputPrivacyValueAllowCloseFriends + } public static func parse_inputPrivacyValueAllowContacts(_ reader: BufferReader) -> InputPrivacyRule? { return Api.InputPrivacyRule.inputPrivacyValueAllowContacts } diff --git a/submodules/TelegramCore/Sources/Account/AccountIntermediateState.swift b/submodules/TelegramCore/Sources/Account/AccountIntermediateState.swift index 57ab51a2a0..4b2a200418 100644 --- a/submodules/TelegramCore/Sources/Account/AccountIntermediateState.swift +++ b/submodules/TelegramCore/Sources/Account/AccountIntermediateState.swift @@ -121,6 +121,7 @@ enum AccountStateMutationOperation { case UpdateExtendedMedia(MessageId, Api.MessageExtendedMedia) case ResetForumTopic(topicId: MessageId, data: StoreMessageHistoryThreadData, pts: Int32) case UpdateStories(Api.UserStories) + case UpdateReadStories(peerId: PeerId, ids: [Int64]) } struct HoleFromPreviousState { @@ -614,9 +615,13 @@ struct AccountMutableState { self.addOperation(.UpdateStories(stories)) } + mutating func readStories(peerId: PeerId, ids: [Int64]) { + self.addOperation(.UpdateReadStories(peerId: peerId, ids: ids)) + } + mutating func addOperation(_ operation: AccountStateMutationOperation) { switch operation { - case .DeleteMessages, .DeleteMessagesWithGlobalIds, .EditMessage, .UpdateMessagePoll, .UpdateMessageReactions, .UpdateMedia, .ReadOutbox, .ReadGroupFeedInbox, .MergePeerPresences, .UpdateSecretChat, .AddSecretMessages, .ReadSecretOutbox, .AddPeerInputActivity, .UpdateCachedPeerData, .UpdatePinnedItemIds, .UpdatePinnedTopic, .UpdatePinnedTopicOrder, .ReadMessageContents, .UpdateMessageImpressionCount, .UpdateMessageForwardsCount, .UpdateInstalledStickerPacks, .UpdateRecentGifs, .UpdateChatInputState, .UpdateCall, .AddCallSignalingData, .UpdateLangPack, .UpdateMinAvailableMessage, .UpdatePeerChatUnreadMark, .UpdateIsContact, .UpdatePeerChatInclusion, .UpdatePeersNearby, .UpdateTheme, .SyncChatListFilters, .UpdateChatListFilterOrder, .UpdateChatListFilter, .UpdateReadThread, .UpdateGroupCallParticipants, .UpdateGroupCall, .UpdateMessagesPinned, .UpdateAutoremoveTimeout, .UpdateAttachMenuBots, .UpdateAudioTranscription, .UpdateConfig, .UpdateExtendedMedia, .ResetForumTopic, .UpdateStories: + case .DeleteMessages, .DeleteMessagesWithGlobalIds, .EditMessage, .UpdateMessagePoll, .UpdateMessageReactions, .UpdateMedia, .ReadOutbox, .ReadGroupFeedInbox, .MergePeerPresences, .UpdateSecretChat, .AddSecretMessages, .ReadSecretOutbox, .AddPeerInputActivity, .UpdateCachedPeerData, .UpdatePinnedItemIds, .UpdatePinnedTopic, .UpdatePinnedTopicOrder, .ReadMessageContents, .UpdateMessageImpressionCount, .UpdateMessageForwardsCount, .UpdateInstalledStickerPacks, .UpdateRecentGifs, .UpdateChatInputState, .UpdateCall, .AddCallSignalingData, .UpdateLangPack, .UpdateMinAvailableMessage, .UpdatePeerChatUnreadMark, .UpdateIsContact, .UpdatePeerChatInclusion, .UpdatePeersNearby, .UpdateTheme, .SyncChatListFilters, .UpdateChatListFilterOrder, .UpdateChatListFilter, .UpdateReadThread, .UpdateGroupCallParticipants, .UpdateGroupCall, .UpdateMessagesPinned, .UpdateAutoremoveTimeout, .UpdateAttachMenuBots, .UpdateAudioTranscription, .UpdateConfig, .UpdateExtendedMedia, .ResetForumTopic, .UpdateStories, .UpdateReadStories: break case let .AddMessages(messages, location): for message in messages { diff --git a/submodules/TelegramCore/Sources/Settings/PrivacySettings.swift b/submodules/TelegramCore/Sources/Settings/PrivacySettings.swift index ddaea62247..90b5027445 100644 --- a/submodules/TelegramCore/Sources/Settings/PrivacySettings.swift +++ b/submodules/TelegramCore/Sources/Settings/PrivacySettings.swift @@ -36,7 +36,7 @@ public final class SelectivePrivacyPeer: Equatable { public enum SelectivePrivacySettings: Equatable { case enableEveryone(disableFor: [PeerId: SelectivePrivacyPeer]) case enableContacts(enableFor: [PeerId: SelectivePrivacyPeer], disableFor: [PeerId: SelectivePrivacyPeer]) - case disableEveryone(enableFor: [PeerId: SelectivePrivacyPeer]) + case disableEveryone(enableFor: [PeerId: SelectivePrivacyPeer], enableForCloseFriends: Bool) public static func ==(lhs: SelectivePrivacySettings, rhs: SelectivePrivacySettings) -> Bool { switch lhs { @@ -52,8 +52,8 @@ public enum SelectivePrivacySettings: Equatable { } else { return false } - case let .disableEveryone(enableFor): - if case .disableEveryone(enableFor) = rhs { + case let .disableEveryone(enableFor, enableForCloseFriends): + if case .disableEveryone(enableFor, enableForCloseFriends) = rhs { return true } else { return false @@ -63,8 +63,8 @@ public enum SelectivePrivacySettings: Equatable { func withEnabledPeers(_ peers: [PeerId: SelectivePrivacyPeer]) -> SelectivePrivacySettings { switch self { - case let .disableEveryone(enableFor): - return .disableEveryone(enableFor: enableFor.merging(peers, uniquingKeysWith: { lhs, rhs in lhs })) + case let .disableEveryone(enableFor, enableForCloseFriends): + return .disableEveryone(enableFor: enableFor.merging(peers, uniquingKeysWith: { lhs, rhs in lhs }), enableForCloseFriends: enableForCloseFriends) case let .enableContacts(enableFor, disableFor): return .enableContacts(enableFor: enableFor.merging(peers, uniquingKeysWith: { lhs, rhs in lhs }), disableFor: disableFor) case .enableEveryone: @@ -82,6 +82,17 @@ public enum SelectivePrivacySettings: Equatable { return .enableEveryone(disableFor: disableFor.merging(peers, uniquingKeysWith: { lhs, rhs in lhs })) } } + + func withEnableForCloseFriends(_ enableForCloseFriends: Bool) -> SelectivePrivacySettings { + switch self { + case let .disableEveryone(enableFor, _): + return .disableEveryone(enableFor: enableFor, enableForCloseFriends: enableForCloseFriends) + case .enableContacts: + return self + case .enableEveryone: + return self + } + } } public struct AccountPrivacySettings: Equatable { @@ -158,10 +169,11 @@ public struct AccountPrivacySettings: Equatable { extension SelectivePrivacySettings { init(apiRules: [Api.PrivacyRule], peers: [PeerId: SelectivePrivacyPeer]) { - var current: SelectivePrivacySettings = .disableEveryone(enableFor: [:]) + var current: SelectivePrivacySettings = .disableEveryone(enableFor: [:], enableForCloseFriends: false) var disableFor: [PeerId: SelectivePrivacyPeer] = [:] var enableFor: [PeerId: SelectivePrivacyPeer] = [:] + var enableForCloseFriends: Bool = false for rule in apiRules { switch rule { @@ -201,10 +213,12 @@ extension SelectivePrivacySettings { } } } + case .privacyValueAllowCloseFriends: + enableForCloseFriends = true } } - self = current.withEnabledPeers(enableFor).withDisabledPeers(disableFor) + self = current.withEnabledPeers(enableFor).withDisabledPeers(disableFor).withEnableForCloseFriends(enableForCloseFriends) } } diff --git a/submodules/TelegramCore/Sources/State/AccountStateManagementUtils.swift b/submodules/TelegramCore/Sources/State/AccountStateManagementUtils.swift index 7fe725beef..8ded43c04c 100644 --- a/submodules/TelegramCore/Sources/State/AccountStateManagementUtils.swift +++ b/submodules/TelegramCore/Sources/State/AccountStateManagementUtils.swift @@ -1630,6 +1630,8 @@ private func finalStateWithUpdatesAndServerTime(accountPeerId: PeerId, postbox: updatedState.updateExtendedMedia(MessageId(peerId: peer.peerId, namespace: Namespaces.Message.Cloud, id: msgId), extendedMedia: extendedMedia) case let .updateStories(stories): updatedState.updateStories(stories: stories) + case let .updateReadStories(userId, id): + updatedState.readStories(peerId: PeerId(namespace: Namespaces.Peer.CloudUser, id: PeerId.Id._internalFromInt64Value(userId)), ids: id) default: break } @@ -3002,7 +3004,7 @@ private func optimizedOperations(_ operations: [AccountStateMutationOperation]) var currentAddScheduledMessages: OptimizeAddMessagesState? for operation in operations { switch operation { - case .DeleteMessages, .DeleteMessagesWithGlobalIds, .EditMessage, .UpdateMessagePoll, .UpdateMessageReactions, .UpdateMedia, .MergeApiChats, .MergeApiUsers, .MergePeerPresences, .UpdatePeer, .ReadInbox, .ReadOutbox, .ReadGroupFeedInbox, .ResetReadState, .ResetIncomingReadState, .UpdatePeerChatUnreadMark, .ResetMessageTagSummary, .UpdateNotificationSettings, .UpdateGlobalNotificationSettings, .UpdateSecretChat, .AddSecretMessages, .ReadSecretOutbox, .AddPeerInputActivity, .UpdateCachedPeerData, .UpdatePinnedItemIds, .UpdatePinnedTopic, .UpdatePinnedTopicOrder, .ReadMessageContents, .UpdateMessageImpressionCount, .UpdateMessageForwardsCount, .UpdateInstalledStickerPacks, .UpdateRecentGifs, .UpdateChatInputState, .UpdateCall, .AddCallSignalingData, .UpdateLangPack, .UpdateMinAvailableMessage, .UpdateIsContact, .UpdatePeerChatInclusion, .UpdatePeersNearby, .UpdateTheme, .SyncChatListFilters, .UpdateChatListFilter, .UpdateChatListFilterOrder, .UpdateReadThread, .UpdateMessagesPinned, .UpdateGroupCallParticipants, .UpdateGroupCall, .UpdateAutoremoveTimeout, .UpdateAttachMenuBots, .UpdateAudioTranscription, .UpdateConfig, .UpdateExtendedMedia, .ResetForumTopic, .UpdateStories: + case .DeleteMessages, .DeleteMessagesWithGlobalIds, .EditMessage, .UpdateMessagePoll, .UpdateMessageReactions, .UpdateMedia, .MergeApiChats, .MergeApiUsers, .MergePeerPresences, .UpdatePeer, .ReadInbox, .ReadOutbox, .ReadGroupFeedInbox, .ResetReadState, .ResetIncomingReadState, .UpdatePeerChatUnreadMark, .ResetMessageTagSummary, .UpdateNotificationSettings, .UpdateGlobalNotificationSettings, .UpdateSecretChat, .AddSecretMessages, .ReadSecretOutbox, .AddPeerInputActivity, .UpdateCachedPeerData, .UpdatePinnedItemIds, .UpdatePinnedTopic, .UpdatePinnedTopicOrder, .ReadMessageContents, .UpdateMessageImpressionCount, .UpdateMessageForwardsCount, .UpdateInstalledStickerPacks, .UpdateRecentGifs, .UpdateChatInputState, .UpdateCall, .AddCallSignalingData, .UpdateLangPack, .UpdateMinAvailableMessage, .UpdateIsContact, .UpdatePeerChatInclusion, .UpdatePeersNearby, .UpdateTheme, .SyncChatListFilters, .UpdateChatListFilter, .UpdateChatListFilterOrder, .UpdateReadThread, .UpdateMessagesPinned, .UpdateGroupCallParticipants, .UpdateGroupCall, .UpdateAutoremoveTimeout, .UpdateAttachMenuBots, .UpdateAudioTranscription, .UpdateConfig, .UpdateExtendedMedia, .ResetForumTopic, .UpdateStories, .UpdateReadStories: if let currentAddMessages = currentAddMessages, !currentAddMessages.messages.isEmpty { result.append(.AddMessages(currentAddMessages.messages, currentAddMessages.location)) } @@ -4334,14 +4336,32 @@ func replayFinalState( switch storyItem { case let .storyItemDeleted(id): storyUpdates.append(InternalStoryUpdate.deleted(id)) - case let .storyItem(id, date, media): + case let .storyItem(flags, id, date, _, _, media, _, recentViewers, viewCount): let (parsedMedia, _, _, _) = textMediaAndExpirationTimerFromApiMedia(media, peerId) if let parsedMedia = parsedMedia { - storyUpdates.append(InternalStoryUpdate.added(peerId: peerId, item: StoryListContext.Item(id: id, timestamp: date, media: EngineMedia(parsedMedia)))) + var seenPeers: [EnginePeer] = [] + if let recentViewers = recentViewers { + for id in recentViewers { + if let peer = transaction.getPeer(PeerId(namespace: Namespaces.Peer.CloudUser, id: PeerId.Id._internalFromInt64Value(id))) { + seenPeers.append(EnginePeer(peer)) + } + } + } + storyUpdates.append(InternalStoryUpdate.added(peerId: peerId, item: StoryListContext.Item( + id: id, + timestamp: date, + media: EngineMedia(parsedMedia), + isSeen: (flags & (1 << 4)) == 0, + seenCount: viewCount.flatMap(Int.init) ?? 0, + seenPeers: seenPeers + ))) } } } } + case let .UpdateReadStories(peerId, ids): + let _ = peerId + storyUpdates.append(InternalStoryUpdate.read(ids)) } } diff --git a/submodules/TelegramCore/Sources/State/AccountStateManager.swift b/submodules/TelegramCore/Sources/State/AccountStateManager.swift index 04dc00bccc..1a27c50fb9 100644 --- a/submodules/TelegramCore/Sources/State/AccountStateManager.swift +++ b/submodules/TelegramCore/Sources/State/AccountStateManager.swift @@ -224,7 +224,7 @@ public final class AccountStateManager { return self.deletedMessagesPipe.signal() } - private let storyUpdatesPipe = ValuePipe<[InternalStoryUpdate]>() + fileprivate let storyUpdatesPipe = ValuePipe<[InternalStoryUpdate]>() public var storyUpdates: Signal<[InternalStoryUpdate], NoError> { return self.storyUpdatesPipe.signal() } @@ -1639,6 +1639,12 @@ public final class AccountStateManager { } } + func injectStoryUpdates(updates: [InternalStoryUpdate]) { + self.impl.with { impl in + impl.storyUpdatesPipe.putNext(updates) + } + } + var updateConfigRequested: (() -> Void)? var isPremiumUpdated: (() -> Void)? diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Messages/Stories.swift b/submodules/TelegramCore/Sources/TelegramEngine/Messages/Stories.swift index 6ec564bb2b..d3cb5d58a6 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Messages/Stories.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Messages/Stories.swift @@ -55,7 +55,7 @@ func _internal_uploadStory(account: Account, media: EngineStoryInputMedia) -> Si case let .content(content): switch content.content { case let .media(inputMedia, _): - return account.network.request(Api.functions.stories.sendStory(media: inputMedia, privacyRules: [.inputPrivacyValueAllowAll])) + return account.network.request(Api.functions.stories.sendStory(flags: 0, media: inputMedia, caption: nil, entities: nil, privacyRules: [.inputPrivacyValueAllowAll])) |> map(Optional.init) |> `catch` { _ -> Signal in return .single(nil) @@ -68,7 +68,7 @@ func _internal_uploadStory(account: Account, media: EngineStoryInputMedia) -> Si case .userStories(let userId, let apiStories), .userStoriesShort(let userId, let apiStories, _): if PeerId(namespace: Namespaces.Peer.CloudUser, id: PeerId.Id._internalFromInt64Value(userId)) == account.peerId, apiStories.count == 1 { switch apiStories[0] { - case let .storyItem(_, _, media): + case let .storyItem(_, _, _, _, _, media, _, _, _): let (parsedMedia, _, _, _) = textMediaAndExpirationTimerFromApiMedia(media, account.peerId) if let parsedMedia = parsedMedia { applyMediaResourceChanges(from: imageMedia, to: parsedMedia, postbox: account.postbox, force: false) @@ -105,3 +105,22 @@ func _internal_deleteStory(account: Account, id: Int64) -> Signal Signal { + return account.postbox.transaction { transaction -> Api.InputUser? in + return transaction.getPeer(peerId).flatMap(apiInputUser) + } + |> mapToSignal { inputUser -> Signal in + guard let inputUser = inputUser else { + return .complete() + } + + account.stateManager.injectStoryUpdates(updates: [.read([id])]) + + return account.network.request(Api.functions.stories.readStories(userId: inputUser, id: [id])) + |> `catch` { _ -> Signal in + return .single(.boolFalse) + } + |> ignoreValues + } +} diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Messages/StoryListContext.swift b/submodules/TelegramCore/Sources/TelegramEngine/Messages/StoryListContext.swift index 0d05910d51..fc30dfa5d5 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Messages/StoryListContext.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Messages/StoryListContext.swift @@ -6,6 +6,7 @@ import SwiftSignalKit enum InternalStoryUpdate { case deleted(Int64) case added(peerId: PeerId, item: StoryListContext.Item) + case read([Int64]) } public final class StoryListContext { @@ -18,11 +19,17 @@ public final class StoryListContext { public let id: Int64 public let timestamp: Int32 public let media: EngineMedia + public let isSeen: Bool + public let seenCount: Int + public let seenPeers: [EnginePeer] - public init(id: Int64, timestamp: Int32, media: EngineMedia) { + public init(id: Int64, timestamp: Int32, media: EngineMedia, isSeen: Bool, seenCount: Int, seenPeers: [EnginePeer]) { self.id = id self.timestamp = timestamp self.media = media + self.isSeen = isSeen + self.seenCount = seenCount + self.seenPeers = seenPeers } public static func ==(lhs: Item, rhs: Item) -> Bool { @@ -35,6 +42,15 @@ public final class StoryListContext { if lhs.media != rhs.media { return false } + if lhs.isSeen != rhs.isSeen { + return false + } + if lhs.seenCount != rhs.seenCount { + return false + } + if lhs.seenPeers != rhs.seenPeers { + return false + } return true } } @@ -137,6 +153,8 @@ public final class StoryListContext { } case .deleted: break + case .read: + break } } return peers @@ -144,6 +162,9 @@ public final class StoryListContext { guard let self else { return } + if self.isLoadingMore { + return + } var itemSets: [PeerItemSet] = self.stateValue.itemSets @@ -151,7 +172,7 @@ public final class StoryListContext { switch update { case let .deleted(id): for i in 0 ..< itemSets.count { - if let index = itemSets[i].items.firstIndex(where: { $0.id != id }) { + if let index = itemSets[i].items.firstIndex(where: { $0.id == id }) { var items = itemSets[i].items items.remove(at: index) itemSets[i] = PeerItemSet( @@ -175,9 +196,9 @@ public final class StoryListContext { items.append(item) items.sort(by: { lhsItem, rhsItem in if lhsItem.timestamp != rhsItem.timestamp { - return lhsItem.timestamp > rhsItem.timestamp + return lhsItem.timestamp < rhsItem.timestamp } - return lhsItem.id > rhsItem.id + return lhsItem.id < rhsItem.id }) itemSets[i] = PeerItemSet( peerId: itemSets[i].peerId, @@ -195,6 +216,29 @@ public final class StoryListContext { totalCount: 1 ), at: 0) } + case let .read(ids): + for id in ids { + for i in 0 ..< itemSets.count { + if let index = itemSets[i].items.firstIndex(where: { $0.id == id }) { + var items = itemSets[i].items + let item = items[index] + items[index] = Item( + id: item.id, + timestamp: item.timestamp, + media: item.media, + isSeen: true, + seenCount: item.seenCount, + seenPeers: item.seenPeers + ) + itemSets[i] = PeerItemSet( + peerId: itemSets[i].peerId, + peer: itemSets[i].peer, + items: items, + totalCount: items.count + ) + } + } + } } } @@ -263,10 +307,25 @@ public final class StoryListContext { for apiStory in apiStories { switch apiStory { - case let .storyItem(id, date, media): + case let .storyItem(flags, id, date, _, _, media, _, recentViewers, viewCount): let (parsedMedia, _, _, _) = textMediaAndExpirationTimerFromApiMedia(media, peerId) if let parsedMedia = parsedMedia { - let item = StoryListContext.Item(id: id, timestamp: date, media: EngineMedia(parsedMedia)) + var seenPeers: [EnginePeer] = [] + if let recentViewers = recentViewers { + for id in recentViewers { + if let peer = transaction.getPeer(PeerId(namespace: Namespaces.Peer.CloudUser, id: PeerId.Id._internalFromInt64Value(id))) { + seenPeers.append(EnginePeer(peer)) + } + } + } + let item = StoryListContext.Item( + id: id, + timestamp: date, + media: EngineMedia(parsedMedia), + isSeen: (flags & (1 << 4)) == 0, + seenCount: viewCount.flatMap(Int.init) ?? 0, + seenPeers: seenPeers + ) if !parsedItemSets.isEmpty && parsedItemSets[parsedItemSets.count - 1].peerId == peerId { parsedItemSets[parsedItemSets.count - 1].items.append(item) parsedItemSets[parsedItemSets.count - 1].totalCount = parsedItemSets[parsedItemSets.count - 1].items.count @@ -366,10 +425,25 @@ public final class StoryListContext { let peerId = PeerId(namespace: Namespaces.Peer.CloudUser, id: PeerId.Id._internalFromInt64Value(apiUserId)) for apiStory in apiStories { switch apiStory { - case let .storyItem(id, date, media): + case let .storyItem(flags, id, date, _, _, media, _, recentViewers, viewCount): let (parsedMedia, _, _, _) = textMediaAndExpirationTimerFromApiMedia(media, peerId) if let parsedMedia = parsedMedia { - let item = StoryListContext.Item(id: id, timestamp: date, media: EngineMedia(parsedMedia)) + var seenPeers: [EnginePeer] = [] + if let recentViewers = recentViewers { + for id in recentViewers { + if let peer = transaction.getPeer(PeerId(namespace: Namespaces.Peer.CloudUser, id: PeerId.Id._internalFromInt64Value(id))) { + seenPeers.append(EnginePeer(peer)) + } + } + } + let item = StoryListContext.Item( + id: id, + timestamp: date, + media: EngineMedia(parsedMedia), + isSeen: (flags & (1 << 4)) == 0, + seenCount: viewCount.flatMap(Int.init) ?? 0, + seenPeers: seenPeers + ) if !parsedItemSets.isEmpty && parsedItemSets[parsedItemSets.count - 1].peerId == peerId { parsedItemSets[parsedItemSets.count - 1].items.append(item) } else { @@ -406,9 +480,9 @@ public final class StoryListContext { items.sort(by: { lhsItem, rhsItem in if lhsItem.timestamp != rhsItem.timestamp { - return lhsItem.timestamp > rhsItem.timestamp + return lhsItem.timestamp < rhsItem.timestamp } - return lhsItem.id > rhsItem.id + return lhsItem.id < rhsItem.id }) itemSets[index] = PeerItemSet( @@ -418,6 +492,12 @@ public final class StoryListContext { totalCount: items.count ) } else { + itemSet.items.sort(by: { lhsItem, rhsItem in + if lhsItem.timestamp != rhsItem.timestamp { + return lhsItem.timestamp < rhsItem.timestamp + } + return lhsItem.id < rhsItem.id + }) itemSets.append(itemSet) } } diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift b/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift index 22307f2892..0a6f004126 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift @@ -582,7 +582,11 @@ public extension TelegramEngine { } public func deleteStory(id: Int64) -> Signal { - return _internal_deleteStory(account: account, id: id) + return _internal_deleteStory(account: self.account, id: id) + } + + public func markStoryAsSeen(peerId: EnginePeer.Id, id: Int64) -> Signal { + return _internal_markStoryAsSeen(account: self.account, peerId: peerId, id: id) } } } diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Privacy/UpdatedAccountPrivacySettings.swift b/submodules/TelegramCore/Sources/TelegramEngine/Privacy/UpdatedAccountPrivacySettings.swift index d06849d902..6ddf348c25 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Privacy/UpdatedAccountPrivacySettings.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Privacy/UpdatedAccountPrivacySettings.swift @@ -273,7 +273,7 @@ func _internal_updateSelectiveAccountPrivacySettings(account: Account, type: Upd return account.postbox.transaction { transaction -> Signal in var rules: [Api.InputPrivacyRule] = [] switch settings { - case let .disableEveryone(enableFor): + case let .disableEveryone(enableFor, enableForCloseFriends): let enablePeers = apiUserAndGroupIds(peerIds: enableFor) if !enablePeers.users.isEmpty { @@ -284,6 +284,9 @@ func _internal_updateSelectiveAccountPrivacySettings(account: Account, type: Upd } rules.append(Api.InputPrivacyRule.inputPrivacyValueDisallowAll) + if enableForCloseFriends { + rules.append(.inputPrivacyValueAllowCloseFriends) + } case let .enableContacts(enableFor, disableFor): let enablePeers = apiUserAndGroupIds(peerIds: enableFor) let disablePeers = apiUserAndGroupIds(peerIds: disableFor) diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContainerScreen.swift b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContainerScreen.swift index 64bf41fe96..9a497ae196 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContainerScreen.swift +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContainerScreen.swift @@ -56,15 +56,18 @@ private final class StoryContainerScreenComponent: Component { let context: AccountContext let initialFocusedId: AnyHashable? let initialContent: [StoryContentItemSlice] + let transitionIn: StoryContainerScreen.TransitionIn? init( context: AccountContext, initialFocusedId: AnyHashable?, - initialContent: [StoryContentItemSlice] + initialContent: [StoryContentItemSlice], + transitionIn: StoryContainerScreen.TransitionIn? ) { self.context = context self.initialFocusedId = initialFocusedId self.initialContent = initialContent + self.transitionIn = transitionIn } static func ==(lhs: StoryContainerScreenComponent, rhs: StoryContainerScreenComponent) -> Bool { @@ -78,24 +81,121 @@ private final class StoryContainerScreenComponent: Component { let view = ComponentView() let externalState = StoryItemSetContainerComponent.ExternalState() + let tintLayer = SimpleGradientLayer() + + var rotationFraction: CGFloat? + override static var layerClass: AnyClass { return CATransformLayer.self } override init(frame: CGRect) { super.init(frame: frame) + + self.tintLayer.opacity = 0.0 + + let colors: [CGColor] = [ + UIColor.black.withAlphaComponent(1.0).cgColor, + UIColor.black.withAlphaComponent(0.8).cgColor, + UIColor.black.withAlphaComponent(0.5).cgColor + ] + + self.tintLayer.startPoint = CGPoint(x: 0.0, y: 0.0) + self.tintLayer.endPoint = CGPoint(x: 1.0, y: 0.0) + self.tintLayer.colors = colors + self.tintLayer.type = .axial } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + guard let componentView = self.view.view else { + return nil + } + return componentView.hitTest(point, with: event) + } } private struct ItemSetPanState: Equatable { var fraction: CGFloat + var didBegin: Bool - init(fraction: CGFloat) { + init(fraction: CGFloat, didBegin: Bool) { self.fraction = fraction + self.didBegin = didBegin + } + } + + private final class StoryPanRecognizer: UIPanGestureRecognizer { + private let updateIsActive: (Bool) -> Void + private var isActive: Bool = false + private var timer: Foundation.Timer? + + init(target: Any?, action: Selector?, updateIsActive: @escaping (Bool) -> Void) { + self.updateIsActive = updateIsActive + + super.init(target: target, action: action) + } + + override func reset() { + super.reset() + + self.isActive = false + self.timer?.invalidate() + self.timer = nil + } + + override func touchesBegan(_ touches: Set, with event: UIEvent) { + super.touchesBegan(touches, with: event) + + if !self.isActive { + if self.timer == nil { + self.timer = Timer.scheduledTimer(withTimeInterval: 0.2, repeats: false, block: { [weak self] timer in + guard let self, self.timer === timer else { + return + } + self.timer = nil + if !self.isActive { + self.isActive = true + self.updateIsActive(true) + } + }) + } + } + } + + override func touchesEnded(_ touches: Set, with event: UIEvent) { + if self.isActive { + self.isActive = false + self.updateIsActive(false) + + for touch in touches { + if let gestureRecognizers = touch.gestureRecognizers { + for gestureRecognizer in gestureRecognizers { + if gestureRecognizer is UITapGestureRecognizer { + gestureRecognizer.state = .cancelled + } + } + } + } + } + self.timer?.invalidate() + self.timer = nil + + super.touchesEnded(touches, with: event) + } + + override func touchesCancelled(_ touches: Set, with event: UIEvent) { + super.touchesCancelled(touches, with: event) + + if self.isActive { + self.isActive = false + self.updateIsActive(false) + } + self.timer?.invalidate() + self.timer = nil } } @@ -129,7 +229,22 @@ private final class StoryContainerScreenComponent: Component { self.backgroundColor = .black - self.addGestureRecognizer(UIPanGestureRecognizer(target: self, action: #selector(self.panGesture(_:)))) + self.addGestureRecognizer(StoryPanRecognizer(target: self, action: #selector(self.panGesture(_:)), updateIsActive: { [weak self] value in + guard let self else { + return + } + if value { + if self.itemSetPanState == nil { + self.itemSetPanState = ItemSetPanState(fraction: 0.0, didBegin: false) + self.state?.updated(transition: Transition(animation: .curve(duration: 0.25, curve: .easeInOut))) + } + } else { + if let itemSetPanState = self.itemSetPanState, !itemSetPanState.didBegin { + self.itemSetPanState = nil + self.state?.updated(transition: Transition(animation: .curve(duration: 0.25, curve: .easeInOut))) + } + } + })) self.audioRecorderDisposable = (self.audioRecorder.get() |> deliverOnMainQueue).start(next: { [weak self] audioRecorder in @@ -247,14 +362,34 @@ private final class StoryContainerScreenComponent: Component { case .began: self.layer.removeAnimation(forKey: "panState") - self.itemSetPanState = ItemSetPanState(fraction: 0.0) - self.state?.updated(transition: .immediate) + if let itemSetPanState = self.itemSetPanState, !itemSetPanState.didBegin { + self.itemSetPanState = ItemSetPanState(fraction: 0.0, didBegin: true) + self.state?.updated(transition: Transition(animation: .curve(duration: 0.25, curve: .easeInOut))) + } else { + self.itemSetPanState = ItemSetPanState(fraction: 0.0, didBegin: true) + self.state?.updated(transition: .immediate) + } case .changed: - if var itemSetPanState = self.itemSetPanState, self.bounds.width > 0.0 { - let translation = recognizer.translation(in: self) + if var itemSetPanState = self.itemSetPanState, self.bounds.width > 0.0, let focusedItemSet = self.focusedItemSet, let focusedIndex = self.itemSets.firstIndex(where: { $0.id == focusedItemSet }) { + var translation = recognizer.translation(in: self) - let fraction = translation.x / self.bounds.width - itemSetPanState.fraction = -max(-1.0, min(1.0, fraction)) + func rubberBandingOffset(offset: CGFloat, bandingStart: CGFloat) -> CGFloat { + let bandedOffset = offset - bandingStart + let range: CGFloat = 600.0 + let coefficient: CGFloat = 0.4 + return bandingStart + (1.0 - (1.0 / ((bandedOffset * coefficient / range) + 1.0))) * range + } + + if translation.x > 0.0 && focusedIndex == 0 { + translation.x = rubberBandingOffset(offset: translation.x, bandingStart: 0.0) + } else if translation.x < 0.0 && focusedIndex == self.itemSets.count - 1 { + translation.x = -rubberBandingOffset(offset: -translation.x, bandingStart: 0.0) + } + + var fraction = translation.x / self.bounds.width + fraction = -max(-1.0, min(1.0, fraction)) + + itemSetPanState.fraction = fraction self.itemSetPanState = itemSetPanState self.state?.updated(transition: .immediate) @@ -306,14 +441,40 @@ private final class StoryContainerScreenComponent: Component { } } - func animateIn() { - self.layer.allowsGroupOpacity = true - self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15, completion: { [weak self] _ in - guard let self else { - return + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + for subview in self.subviews.reversed() { + if !subview.isUserInteractionEnabled || subview.isHidden || subview.alpha == 0.0 { + continue } - self.layer.allowsGroupOpacity = false - }) + if subview is ItemSetView { + if let result = subview.hitTest(point, with: event) { + return result + } + } else { + if let result = subview.hitTest(self.convert(point, to: subview), with: event) { + return result + } + } + } + + return nil + } + + func animateIn() { + if let transitionIn = self.component?.transitionIn, transitionIn.sourceView != nil { + self.layer.animate(from: UIColor.black.withAlphaComponent(0.0).cgColor, to: self.layer.backgroundColor ?? UIColor.black.cgColor, keyPath: "backgroundColor", timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, duration: 0.28) + + if let transitionIn = self.component?.transitionIn, let focusedItemSet = self.focusedItemSet, let itemSetView = self.visibleItemSetViews[focusedItemSet] { + if let itemSetComponentView = itemSetView.view.view as? StoryItemSetContainerComponent.View { + itemSetComponentView.animateIn(transitionIn: transitionIn) + } + } + } else { + self.layer.allowsGroupOpacity = true + self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25, completion: { [weak self] _ in + self?.layer.allowsGroupOpacity = false + }) + } } func animateOut(completion: @escaping () -> Void) { @@ -2088,6 +2249,7 @@ private final class StoryContainerScreenComponent: Component { isProgressPaused: isProgressPaused || i != focusedIndex, audioRecorder: i == focusedIndex ? self.audioRecorderValue : nil, videoRecorder: i == focusedIndex ? self.videoRecorderValue : nil, + hideUI: i == focusedIndex && self.itemSetPanState?.didBegin == false, presentController: { [weak self] c in guard let self, let environment = self.environment else { return @@ -2141,18 +2303,15 @@ private final class StoryContainerScreenComponent: Component { contentDerivedBottomInset = itemSetView.externalState.derivedBottomInset } - var itemFrame = CGRect(origin: CGPoint(), size: availableSize) - itemFrame.origin.x += CGFloat(i - focusedIndex) * (availableSize.width + 10.0) - if let itemSetPanState = self.itemSetPanState { - itemFrame.origin.x += -itemSetPanState.fraction * (availableSize.width + 10.0) - } + let itemFrame = CGRect(origin: CGPoint(), size: availableSize) if let itemSetComponentView = itemSetView.view.view { if itemSetView.superview == nil { self.addSubview(itemSetView) } if itemSetComponentView.superview == nil { - //itemSetComponentView.layer.zPosition = availableSize.width * 0.5 + itemSetComponentView.layer.isDoubleSided = false itemSetView.addSubview(itemSetComponentView) + itemSetView.layer.addSublayer(itemSetView.tintLayer) } itemSetTransition.setPosition(view: itemSetView, position: itemFrame.center) @@ -2161,35 +2320,101 @@ private final class StoryContainerScreenComponent: Component { itemSetTransition.setPosition(view: itemSetComponentView, position: CGRect(origin: CGPoint(), size: itemFrame.size).center) itemSetTransition.setBounds(view: itemSetComponentView, bounds: CGRect(origin: CGPoint(), size: itemFrame.size)) - /*var cubeTransform: CATransform3D - cubeTransform = CATransform3DIdentity + itemSetTransition.setPosition(layer: itemSetView.tintLayer, position: CGRect(origin: CGPoint(), size: itemFrame.size).center) + itemSetTransition.setBounds(layer: itemSetView.tintLayer, bounds: CGRect(origin: CGPoint(), size: itemFrame.size)) - var perspectiveTransform: CATransform3D = CATransform3DIdentity - let perspectiveDistance: CGFloat = 500.0 - perspectiveTransform.m34 = -1.0 / perspectiveDistance - let _ = perspectiveTransform - //itemSetView.layer.sublayerTransform = perspectiveTransform + let perspectiveConstant: CGFloat = 500.0 + let width = itemFrame.width - let yRotation: CGFloat = (self.itemSetPanState?.fraction ?? 0.0) * CGFloat.pi * 0.5 + let sideDistance: CGFloat = 40.0 - let rotationTransform = CATransform3DMakeRotation(yRotation, 0.0, 1.0, 0.0) - cubeTransform = CATransform3DConcat(cubeTransform, rotationTransform) - cubeTransform = CATransform3DTranslate(cubeTransform, (self.itemSetPanState?.fraction ?? 0.0) * availableSize.width * 0.5, 0.0, -availableSize.width * 0.5) + let sideAngle_d: CGFloat = -pow(perspectiveConstant, 2)*pow(sideDistance, 2) + let sideAngle_e: CGFloat = pow(perspectiveConstant, 2)*pow(width, 2) + let sideAngle_f: CGFloat = pow(sideDistance, 2)*pow(width, 2) + let sideAngle_c: CGFloat = sqrt(sideAngle_d + sideAngle_e + sideAngle_f + sideDistance*pow(width, 3) + 0.25*pow(width, 4)) + let sideAngle_a: CGFloat = (2.0*perspectiveConstant*width - 2.0*sideAngle_c) + let sideAngle_b: CGFloat = (-2.0*perspectiveConstant*sideDistance + 2.0*sideDistance*width + pow(width, 2)) - //let transform = CATransform3DTranslate(CATransform3DIdentity, 0.0, 0.0, availableSize.width * 0.5) + let sideAngle: CGFloat = 2.0*atan(sideAngle_a / sideAngle_b) - /*let perspectiveDistance: CGFloat = 500.0 - transform.m34 = -1.0 / perspectiveDistance - let cubeSize: CGFloat = availableSize.width + let faceTransform = CATransform3DMakeTranslation(0, 0, itemFrame.width * 0.5) - // Translate the cube to its original position. - let halfSize = cubeSize / 2.0 - let translationTransform = CATransform3DMakeTranslation(0, 0, halfSize) + func calculateCubeTransform(rotationFraction: CGFloat, sideAngle: CGFloat, cubeSize: CGSize) -> CATransform3D { + let t = rotationFraction + let absT = abs(rotationFraction) + let currentAngle = t * (CGFloat.pi * 0.5 + sideAngle) + let width = cubeSize.width + + let cubeDistance_a: CGFloat = -1.4142135623731*absT*cos(sideAngle + 0.785398163397448) + let cubeDistance_b: CGFloat = sin(sideAngle*absT + 1.5707963267949*absT + 0.785398163397448) + var cubeDistance: CGFloat = 0.5*width*(cubeDistance_a + absT + 1.4142135623731*cubeDistance_b - 1.0) + cubeDistance *= 1.0 + + let backDistance_a = sqrt(pow(width, 2.0)) + let backDistance_b = tan(sideAngle) / 2.0 + let backDistance_c = sqrt(pow(width, 2.0)) + let backDistance_d = (2*cos(sideAngle)) + let backDistance: CGFloat = width / 2.0 + backDistance_a * backDistance_b - backDistance_c / backDistance_d + + var perspective = CATransform3DIdentity + perspective.m34 = -1 / perspectiveConstant + let initialCubeTransform = CATransform3DTranslate(perspective, 0.0, 0.0, -cubeSize.width * 0.5) + + var targetTransform = initialCubeTransform + targetTransform = CATransform3DTranslate(targetTransform, 0.0, 0.0, -cubeDistance + backDistance) + targetTransform = CATransform3DConcat(CATransform3DMakeRotation(currentAngle, 0, 1, 0), targetTransform) + targetTransform = CATransform3DTranslate(targetTransform, 0.0, 0.0, -backDistance) + + return targetTransform + } - // Apply the translation transformation. - transform = CATransform3DConcat(transform, translationTransform)*/ - //itemSetTransition.setTransform(view: itemSetComponentView, transform: transform) - itemSetTransition.setTransform(view: itemSetView, transform: cubeTransform)*/ + let cubeAdditionalRotationFraction: CGFloat + if i == focusedIndex { + cubeAdditionalRotationFraction = 0.0 + } else if i < focusedIndex { + cubeAdditionalRotationFraction = -1.0 + } else { + cubeAdditionalRotationFraction = 1.0 + } + + var panFraction: CGFloat = 0.0 + if let itemSetPanState = self.itemSetPanState { + panFraction = -itemSetPanState.fraction + } + + Transition.immediate.setTransform(view: itemSetComponentView, transform: faceTransform) + Transition.immediate.setTransform(layer: itemSetView.tintLayer, transform: faceTransform) + + if let previousRotationFraction = itemSetView.rotationFraction, "".isEmpty { + let fromT = previousRotationFraction + let toT = panFraction + itemSetTransition.setTransformAsKeyframes(view: itemSetView, transform: { sourceT in + let t = fromT * (1.0 - sourceT) + toT * sourceT + + return calculateCubeTransform(rotationFraction: t + cubeAdditionalRotationFraction, sideAngle: sideAngle, cubeSize: itemFrame.size) + }) + } else { + itemSetTransition.setTransform(view: itemSetView, transform: calculateCubeTransform(rotationFraction: panFraction + cubeAdditionalRotationFraction, sideAngle: sideAngle, cubeSize: itemFrame.size)) + } + itemSetView.rotationFraction = panFraction + + var alphaFraction = panFraction + cubeAdditionalRotationFraction + + if alphaFraction != 0.0 { + if alphaFraction < 0.0 { + itemSetView.tintLayer.startPoint = CGPoint(x: 0.0, y: 0.0) + itemSetView.tintLayer.endPoint = CGPoint(x: 1.0, y: 0.0) + } else { + itemSetView.tintLayer.startPoint = CGPoint(x: 1.0, y: 0.0) + itemSetView.tintLayer.endPoint = CGPoint(x: 0.0, y: 0.0) + } + } + + alphaFraction *= 1.3 + alphaFraction = max(-1.0, min(1.0, alphaFraction)) + alphaFraction = abs(alphaFraction) + + itemSetTransition.setAlpha(layer: itemSetView.tintLayer, alpha: alphaFraction) } } } @@ -2235,20 +2460,38 @@ private final class StoryContainerScreenComponent: Component { } public class StoryContainerScreen: ViewControllerComponentContainer { + public final class TransitionIn { + public weak var sourceView: UIView? + public let sourceRect: CGRect + public let sourceCornerRadius: CGFloat + + public init( + sourceView: UIView, + sourceRect: CGRect, + sourceCornerRadius: CGFloat + ) { + self.sourceView = sourceView + self.sourceRect = sourceRect + self.sourceCornerRadius = sourceCornerRadius + } + } + private let context: AccountContext private var isDismissed: Bool = false public init( context: AccountContext, initialFocusedId: AnyHashable?, - initialContent: [StoryContentItemSlice] + initialContent: [StoryContentItemSlice], + transitionIn: TransitionIn? ) { self.context = context super.init(context: context, component: StoryContainerScreenComponent( context: context, initialFocusedId: initialFocusedId, - initialContent: initialContent + initialContent: initialContent, + transitionIn: transitionIn ), navigationBarAppearance: .none, theme: .dark) self.statusBar.statusBarStyle = .White diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContent.swift b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContent.swift index 771bda5bd5..7de7d8248b 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContent.swift +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContent.swift @@ -42,8 +42,10 @@ public final class StoryContentItem { public let centerInfoComponent: AnyComponent? public let rightInfoComponent: AnyComponent? public let targetMessageId: EngineMessage.Id? + public let storyItem: StoryListContext.Item? public let preload: Signal? public let delete: (() -> Void)? + public let markAsSeen: (() -> Void)? public let hasLike: Bool public let isMy: Bool @@ -54,8 +56,10 @@ public final class StoryContentItem { centerInfoComponent: AnyComponent?, rightInfoComponent: AnyComponent?, targetMessageId: EngineMessage.Id?, + storyItem: StoryListContext.Item?, preload: Signal?, delete: (() -> Void)?, + markAsSeen: (() -> Void)?, hasLike: Bool, isMy: Bool ) { @@ -65,8 +69,10 @@ public final class StoryContentItem { self.centerInfoComponent = centerInfoComponent self.rightInfoComponent = rightInfoComponent self.targetMessageId = targetMessageId + self.storyItem = storyItem self.preload = preload self.delete = delete + self.markAsSeen = markAsSeen self.hasLike = hasLike self.isMy = isMy } diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift index 46f11ea7b9..3e85bf0fe1 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift @@ -39,6 +39,7 @@ public final class StoryItemSetContainerComponent: Component { public let isProgressPaused: Bool public let audioRecorder: ManagedAudioRecorder? public let videoRecorder: InstantVideoController? + public let hideUI: Bool public let presentController: (ViewController) -> Void public let close: () -> Void public let navigateToItemSet: (NavigationDirection) -> Void @@ -56,6 +57,7 @@ public final class StoryItemSetContainerComponent: Component { isProgressPaused: Bool, audioRecorder: ManagedAudioRecorder?, videoRecorder: InstantVideoController?, + hideUI: Bool, presentController: @escaping (ViewController) -> Void, close: @escaping () -> Void, navigateToItemSet: @escaping (NavigationDirection) -> Void, @@ -72,6 +74,7 @@ public final class StoryItemSetContainerComponent: Component { self.isProgressPaused = isProgressPaused self.audioRecorder = audioRecorder self.videoRecorder = videoRecorder + self.hideUI = hideUI self.presentController = presentController self.close = close self.navigateToItemSet = navigateToItemSet @@ -109,6 +112,9 @@ public final class StoryItemSetContainerComponent: Component { if lhs.videoRecorder !== rhs.videoRecorder { return false } + if lhs.hideUI != rhs.hideUI { + return false + } return true } @@ -149,7 +155,7 @@ public final class StoryItemSetContainerComponent: Component { } } - public final class View: UIView, UIScrollViewDelegate { + public final class View: UIView, UIScrollViewDelegate, UIGestureRecognizerDelegate { private let scrollView: ScrollView private let contentContainerView: UIView @@ -205,8 +211,6 @@ public final class StoryItemSetContainerComponent: Component { super.init(frame: frame) - self.backgroundColor = .black - self.scrollView.delaysContentTouches = false self.scrollView.canCancelContentTouches = true self.scrollView.clipsToBounds = false @@ -225,17 +229,17 @@ public final class StoryItemSetContainerComponent: Component { self.scrollView.clipsToBounds = true self.addSubview(self.contentContainerView) - self.layer.addSublayer(self.contentDimLayer) - self.layer.addSublayer(self.topContentGradientLayer) + self.contentContainerView.layer.addSublayer(self.contentDimLayer) + self.contentContainerView.layer.addSublayer(self.topContentGradientLayer) self.layer.addSublayer(self.bottomContentGradientLayer) self.closeButton.addSubview(self.closeButtonIconView) - self.addSubview(self.closeButton) + self.contentContainerView.addSubview(self.closeButton) self.closeButton.addTarget(self, action: #selector(self.closePressed), for: .touchUpInside) - self.contentContainerView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:)))) - - + let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:))) + tapRecognizer.delegate = self + self.contentContainerView.addGestureRecognizer(tapRecognizer) } required init?(coder: NSCoder) { @@ -246,6 +250,13 @@ public final class StoryItemSetContainerComponent: Component { self.currentSliceDisposable?.dispose() } + @objc public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRequireFailureOf otherGestureRecognizer: UIGestureRecognizer) -> Bool { + if otherGestureRecognizer is UIPanGestureRecognizer { + return true + } + return false + } + @objc private func tapGesture(_ recognizer: UITapGestureRecognizer) { if case .ended = recognizer.state, let currentSlice = self.currentSlice, let focusedItemId = self.focusedItemId, let currentIndex = currentSlice.items.firstIndex(where: { $0.id == focusedItemId }), let itemLayout = self.itemLayout { if hasFirstResponder(self) { @@ -267,6 +278,9 @@ public final class StoryItemSetContainerComponent: Component { if nextIndex != currentIndex { let focusedItemId = currentSlice.items[nextIndex].id self.focusedItemId = focusedItemId + + currentSlice.items[nextIndex].markAsSeen?() + self.state?.updated(transition: .immediate) self.currentSliceDisposable?.dispose() @@ -358,6 +372,9 @@ public final class StoryItemSetContainerComponent: Component { if nextIndex != currentIndex { let focusedItemId = currentSlice.items[nextIndex].id self.focusedItemId = focusedItemId + + currentSlice.items[nextIndex].markAsSeen?() + self.state?.updated(transition: .immediate) self.currentSliceDisposable?.dispose() @@ -385,7 +402,7 @@ public final class StoryItemSetContainerComponent: Component { if let view = visibleItem.view.view { if view.superview == nil { view.isUserInteractionEnabled = false - self.contentContainerView.addSubview(view) + self.contentContainerView.insertSubview(view, at: 0) } itemTransition.setFrame(view: view, frame: CGRect(origin: CGPoint(), size: itemLayout.size)) @@ -422,6 +439,69 @@ public final class StoryItemSetContainerComponent: Component { } } + func animateIn(transitionIn: StoryContainerScreen.TransitionIn) { + self.closeButton.layer.animateScale(from: 0.001, to: 1.0, duration: 0.2, delay: 0.12, timingFunction: kCAMediaTimingFunctionSpring) + + if let inputPanelView = self.inputPanel.view { + inputPanelView.layer.animatePosition( + from: CGPoint(x: 0.0, y: self.bounds.height - inputPanelView.frame.minY), + to: CGPoint(), + duration: 0.48, + timingFunction: kCAMediaTimingFunctionSpring, + additive: true + ) + inputPanelView.layer.animateAlpha(from: 0.0, to: inputPanelView.alpha, duration: 0.28) + } + if let footerPanelView = self.footerPanel.view { + footerPanelView.layer.animatePosition( + from: CGPoint(x: 0.0, y: self.bounds.height - footerPanelView.frame.minY), + to: CGPoint(), + duration: 0.3, + timingFunction: kCAMediaTimingFunctionSpring, + additive: true + ) + footerPanelView.layer.animateAlpha(from: 0.0, to: footerPanelView.alpha, duration: 0.28) + } + + if let sourceView = transitionIn.sourceView { + let sourceLocalFrame = sourceView.convert(transitionIn.sourceRect, to: self) + let innerSourceLocalFrame = CGRect(origin: CGPoint(x: sourceLocalFrame.minX - self.contentContainerView.frame.minX, y: sourceLocalFrame.minY - self.contentContainerView.frame.minY), size: sourceLocalFrame.size) + + if let rightInfoView = self.rightInfoItem?.view.view { + rightInfoView.layer.animatePosition(from: CGPoint(x: innerSourceLocalFrame.center.x - rightInfoView.layer.position.x, y: 0.0), to: CGPoint(), duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, additive: true) + rightInfoView.layer.animatePosition(from: CGPoint(x: 0.0, y: innerSourceLocalFrame.center.y - rightInfoView.layer.position.y), to: CGPoint(), duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, additive: true) + + rightInfoView.layer.animateScale(from: innerSourceLocalFrame.width / rightInfoView.bounds.width, to: 1.0, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring) + } + + self.contentContainerView.layer.animatePosition(from: sourceLocalFrame.center, to: self.contentContainerView.center, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring) + self.contentContainerView.layer.animateBounds(from: CGRect(origin: CGPoint(x: innerSourceLocalFrame.minX, y: innerSourceLocalFrame.minY), size: sourceLocalFrame.size), to: self.contentContainerView.bounds, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring) + self.contentContainerView.layer.animate( + from: transitionIn.sourceCornerRadius as NSNumber, + to: self.contentContainerView.layer.cornerRadius as NSNumber, + keyPath: "cornerRadius", + timingFunction: kCAMediaTimingFunctionSpring, + duration: 0.3 + ) + + if let focusedItemId = self.focusedItemId, let visibleItemView = self.visibleItems[focusedItemId]?.view.view { + let innerScale = innerSourceLocalFrame.width / visibleItemView.bounds.width + let innerFromFrame = CGRect(origin: CGPoint(x: innerSourceLocalFrame.minX, y: innerSourceLocalFrame.minY), size: CGSize(width: innerSourceLocalFrame.width, height: visibleItemView.bounds.height * innerScale)) + + visibleItemView.layer.animatePosition( + from: CGPoint( + x: innerFromFrame.midX, + y: innerFromFrame.midY + ), + to: visibleItemView.layer.position, + duration: 0.3, + timingFunction: kCAMediaTimingFunctionSpring + ) + visibleItemView.layer.animateScale(from: innerScale, to: 1.0, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring) + } + } + } + func update(component: StoryItemSetContainerComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { let isFirstTime = self.component == nil @@ -431,6 +511,10 @@ public final class StoryItemSetContainerComponent: Component { self.currentSliceDisposable?.dispose() if let focusedItemId = self.focusedItemId { + if let item = self.currentSlice?.items.first(where: { $0.id == focusedItemId }) { + item.markAsSeen?() + } + self.currentSliceDisposable = (component.initialItemSlice.update( component.initialItemSlice, focusedItemId @@ -490,6 +574,8 @@ public final class StoryItemSetContainerComponent: Component { if let currentSlice = self.currentSlice { if !currentSlice.items.contains(where: { $0.id == focusedItemId }) { self.focusedItemId = currentSlice.items.first?.id + + currentSlice.items.first?.markAsSeen?() } } else { self.focusedItemId = nil @@ -572,9 +658,16 @@ public final class StoryItemSetContainerComponent: Component { containerSize: CGSize(width: availableSize.width, height: 200.0) ) + var currentItem: StoryContentItem? + if let focusedItemId = self.focusedItemId, let currentSlice = self.currentSlice, let item = currentSlice.items.first(where: { $0.id == focusedItemId }) { + currentItem = item + } + let footerPanelSize = self.footerPanel.update( transition: transition, component: AnyComponent(StoryFooterPanelComponent( + context: component.context, + storyItem: currentItem?.storyItem, deleteAction: { [weak self] in guard let self, let component = self.component, let focusedItemId = self.focusedItemId else { return @@ -604,6 +697,8 @@ public final class StoryItemSetContainerComponent: Component { } self.focusedItemId = currentSlice.items[nextIndex].id + currentSlice.items[nextIndex].markAsSeen?() + /*var updatedItems: [StoryContentItem] = [] for item in currentSlice.items { if item.id != focusedItemId { @@ -744,9 +839,10 @@ public final class StoryItemSetContainerComponent: Component { self.closeButtonIconView.tintColor = .white } if let image = self.closeButtonIconView.image { - let closeButtonFrame = CGRect(origin: CGPoint(x: contentFrame.minX, y: contentFrame.minY), size: CGSize(width: 50.0, height: 64.0)) + let closeButtonFrame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: 50.0, height: 64.0)) transition.setFrame(view: self.closeButton, frame: closeButtonFrame) transition.setFrame(view: self.closeButtonIconView, frame: CGRect(origin: CGPoint(x: floor((closeButtonFrame.width - image.size.width) * 0.5), y: floor((closeButtonFrame.height - image.size.height) * 0.5)), size: image.size)) + transition.setAlpha(view: self.closeButton, alpha: component.hideUI ? 0.0 : 1.0) } var focusedItem: StoryContentItem? @@ -808,15 +904,17 @@ public final class StoryItemSetContainerComponent: Component { if let view = currentRightInfoItem.view.view { var animateIn = false if view.superview == nil { - self.addSubview(view) + self.contentContainerView.addSubview(view) animateIn = true } - transition.setFrame(view: view, frame: CGRect(origin: CGPoint(x: contentFrame.maxX - 6.0 - rightInfoItemSize.width, y: contentFrame.minY + 14.0), size: rightInfoItemSize)) + transition.setFrame(view: view, frame: CGRect(origin: CGPoint(x: contentFrame.width - 6.0 - rightInfoItemSize.width, y: 14.0), size: rightInfoItemSize)) if animateIn, !isFirstTime { view.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) view.layer.animateScale(from: 0.5, to: 1.0, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring) } + + transition.setAlpha(view: view, alpha: component.hideUI ? 0.0 : 1.0) } } @@ -833,19 +931,22 @@ public final class StoryItemSetContainerComponent: Component { var animateIn = false if view.superview == nil { view.isUserInteractionEnabled = false - self.addSubview(view) + self.contentContainerView.addSubview(view) animateIn = true } - transition.setFrame(view: view, frame: CGRect(origin: CGPoint(x: contentFrame.minX, y: contentFrame.minY + 10.0), size: centerInfoItemSize)) + transition.setFrame(view: view, frame: CGRect(origin: CGPoint(x: 0.0, y: 10.0), size: centerInfoItemSize)) if animateIn, !isFirstTime { //view.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) } + + transition.setAlpha(view: view, alpha: component.hideUI ? 0.0 : 1.0) } } let gradientHeight: CGFloat = 74.0 - transition.setFrame(layer: self.topContentGradientLayer, frame: CGRect(origin: CGPoint(x: contentFrame.minX, y: contentFrame.minY), size: CGSize(width: contentFrame.width, height: gradientHeight))) + transition.setFrame(layer: self.topContentGradientLayer, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: contentFrame.width, height: gradientHeight))) + transition.setAlpha(layer: self.topContentGradientLayer, alpha: component.hideUI ? 0.0 : 1.0) let itemLayout = ItemLayout(size: CGSize(width: contentFrame.width, height: availableSize.height - component.containerInsets.top - 44.0 - bottomContentInsetWithoutInput)) self.itemLayout = itemLayout @@ -971,7 +1072,7 @@ public final class StoryItemSetContainerComponent: Component { transition.setFrame(layer: self.bottomContentGradientLayer, frame: CGRect(origin: CGPoint(x: contentFrame.minX, y: availableSize.height - component.inputHeight - bottomGradientHeight), size: CGSize(width: contentFrame.width, height: bottomGradientHeight))) transition.setAlpha(layer: self.bottomContentGradientLayer, alpha: inputPanelIsOverlay ? 1.0 : 0.0) - transition.setFrame(layer: self.contentDimLayer, frame: contentFrame) + transition.setFrame(layer: self.contentDimLayer, frame: CGRect(origin: CGPoint(), size: contentFrame.size)) transition.setAlpha(layer: self.contentDimLayer, alpha: (inputPanelIsOverlay || self.inputPanelExternalState.isEditing) ? 1.0 : 0.0) self.ignoreScrolling = true @@ -1005,9 +1106,10 @@ public final class StoryItemSetContainerComponent: Component { if let navigationStripView = self.navigationStrip.view { if navigationStripView.superview == nil { navigationStripView.isUserInteractionEnabled = false - self.addSubview(navigationStripView) + self.contentContainerView.addSubview(navigationStripView) } - transition.setFrame(view: navigationStripView, frame: CGRect(origin: CGPoint(x: contentFrame.minX + navigationStripSideInset, y: contentFrame.minY + navigationStripTopInset), size: CGSize(width: availableSize.width - navigationStripSideInset * 2.0, height: 2.0))) + transition.setFrame(view: navigationStripView, frame: CGRect(origin: CGPoint(x: navigationStripSideInset, y: navigationStripTopInset), size: CGSize(width: availableSize.width - navigationStripSideInset * 2.0, height: 2.0))) + transition.setAlpha(view: navigationStripView, alpha: component.hideUI ? 0.0 : 1.0) } if let focusedItemId = self.focusedItemId, let focusedItem = self.currentSlice?.items.first(where: { $0.id == focusedItemId }) { @@ -1041,9 +1143,9 @@ public final class StoryItemSetContainerComponent: Component { ) if let inlineActionsView = self.inlineActions.view { if inlineActionsView.superview == nil { - self.addSubview(inlineActionsView) + self.contentContainerView.addSubview(inlineActionsView) } - transition.setFrame(view: inlineActionsView, frame: CGRect(origin: CGPoint(x: contentFrame.maxX - 10.0 - inlineActionsSize.width, y: contentFrame.maxY - 20.0 - inlineActionsSize.height), size: inlineActionsSize)) + transition.setFrame(view: inlineActionsView, frame: CGRect(origin: CGPoint(x: contentFrame.width - 10.0 - inlineActionsSize.width, y: contentFrame.height - 20.0 - inlineActionsSize.height), size: inlineActionsSize)) var inlineActionsAlpha: CGFloat = inputPanelIsOverlay ? 0.0 : 1.0 if component.audioRecorder != nil || component.videoRecorder != nil { @@ -1052,6 +1154,9 @@ public final class StoryItemSetContainerComponent: Component { if self.reactionItems != nil { inlineActionsAlpha = 0.0 } + if component.hideUI { + inlineActionsAlpha = 0.0 + } transition.setAlpha(view: inlineActionsView, alpha: inlineActionsAlpha) } diff --git a/submodules/TelegramUI/Components/Stories/StoryContentComponent/Sources/StoryAvatarInfoComponent.swift b/submodules/TelegramUI/Components/Stories/StoryContentComponent/Sources/StoryAvatarInfoComponent.swift index fd75f34511..de6cea18da 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContentComponent/Sources/StoryAvatarInfoComponent.swift +++ b/submodules/TelegramUI/Components/Stories/StoryContentComponent/Sources/StoryAvatarInfoComponent.swift @@ -54,7 +54,8 @@ final class StoryAvatarInfoComponent: Component { self.avatarNode.setPeer( context: component.context, theme: component.context.sharedContext.currentPresentationData.with({ $0 }).theme, - peer: component.peer + peer: component.peer, + synchronousLoad: true ) return size diff --git a/submodules/TelegramUI/Components/Stories/StoryContentComponent/Sources/StoryChatContent.swift b/submodules/TelegramUI/Components/Stories/StoryContentComponent/Sources/StoryChatContent.swift index 61260b85d0..a499ec6a6b 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContentComponent/Sources/StoryChatContent.swift +++ b/submodules/TelegramUI/Components/Stories/StoryContentComponent/Sources/StoryChatContent.swift @@ -8,89 +8,6 @@ import TelegramCore import StoryContainerScreen public enum StoryChatContent { - /*public static func messages( - context: AccountContext, - messageId: EngineMessage.Id - ) -> Signal { - return context.account.postbox.aroundIdMessageHistoryViewForLocation( - .peer(peerId: messageId.peerId, threadId: nil), - ignoreMessagesInTimestampRange: nil, - count: 10, - messageId: messageId, - topTaggedMessageIdNamespaces: Set(), - tagMask: .photoOrVideo, - appendMessagesFromTheSameGroup: false, - namespaces: .not(Set([Namespaces.Message.ScheduledCloud, Namespaces.Message.ScheduledLocal])), - orderStatistics: .combinedLocation - ) - |> map { view -> StoryContentItemSlice in - var items: [StoryContentItem] = [] - var totalCount = 0 - for entry in view.0.entries { - if let location = entry.location { - totalCount = location.count - } - - var hasLike = false - if let reactions = entry.message.effectiveReactions { - for reaction in reactions { - if !reaction.isSelected { - continue - } - if reaction.value == .builtin("❤") { - hasLike = true - } - } - } - - var preload: Signal? - preload = StoryMessageContentComponent.preload(context: context, message: EngineMessage(entry.message)) - - items.append(StoryContentItem( - id: AnyHashable(entry.message.id), - position: entry.location?.index ?? 0, - component: AnyComponent(StoryMessageContentComponent( - context: context, - message: EngineMessage(entry.message) - )), - centerInfoComponent: AnyComponent(StoryAuthorInfoComponent( - context: context, - message: EngineMessage(entry.message) - )), - rightInfoComponent: entry.message.author.flatMap { author -> AnyComponent in - return AnyComponent(StoryAvatarInfoComponent( - context: context, - peer: EnginePeer(author) - )) - }, - targetMessageId: entry.message.id, - preload: preload, - hasLike: hasLike, - isMy: false//!entry.message.effectivelyIncoming(context.account.peerId) - )) - } - return StoryContentItemSlice( - id: AnyHashable(entry.) - focusedItemId: AnyHashable(messageId), - items: items, - totalCount: totalCount, - update: { _, itemId in - if let id = itemId.base as? EngineMessage.Id { - return StoryChatContent.messages( - context: context, - messageId: id - ) - } else { - return StoryChatContent.messages( - context: context, - messageId: messageId - ) - } - } - ) - } - }*/ - public static func stories(context: AccountContext, storyList: StoryListContext, focusItem: Int64?) -> Signal<[StoryContentItemSlice], NoError> { return storyList.state |> map { state -> [StoryContentItemSlice] in @@ -99,6 +16,8 @@ public enum StoryChatContent { for itemSet in state.itemSets { var items: [StoryContentItem] = [] + let peerId = itemSet.peerId + for item in itemSet.items { items.append(StoryContentItem( id: AnyHashable(item.id), @@ -119,10 +38,17 @@ public enum StoryChatContent { )) }, targetMessageId: nil, + storyItem: item, preload: nil, delete: { [weak storyList] in storyList?.delete(id: item.id) }, + markAsSeen: { [weak context] in + guard let context else { + return + } + let _ = context.engine.messages.markStoryAsSeen(peerId: peerId, id: item.id).start() + }, hasLike: false, isMy: itemSet.peerId == context.account.peerId )) @@ -131,6 +57,10 @@ public enum StoryChatContent { var sliceFocusedItemId: AnyHashable? if let focusItem, items.contains(where: { ($0.id.base as? Int64) == focusItem }) { sliceFocusedItemId = AnyHashable(focusItem) + } else if itemSet.peerId != context.account.peerId { + if let id = itemSet.items.first(where: { !$0.isSeen })?.id { + sliceFocusedItemId = AnyHashable(id) + } } itemSlices.append(StoryContentItemSlice( diff --git a/submodules/TelegramUI/Components/Stories/StoryContentComponent/Sources/StoryMessageContentComponent.swift b/submodules/TelegramUI/Components/Stories/StoryContentComponent/Sources/StoryMessageContentComponent.swift deleted file mode 100644 index efb2eaf3f4..0000000000 --- a/submodules/TelegramUI/Components/Stories/StoryContentComponent/Sources/StoryMessageContentComponent.swift +++ /dev/null @@ -1,421 +0,0 @@ -import Foundation -import UIKit -import Display -import ComponentFlow -import AccountContext -import TelegramCore -import AsyncDisplayKit -import PhotoResources -import SwiftSignalKit -import UniversalMediaPlayer -import TelegramUniversalVideoContent -import StoryContainerScreen -import HierarchyTrackingLayer - -final class StoryMessageContentComponent: Component { - typealias EnvironmentType = StoryContentItem.Environment - - let context: AccountContext - let message: EngineMessage - - init(context: AccountContext, message: EngineMessage) { - self.context = context - self.message = message - } - - static func ==(lhs: StoryMessageContentComponent, rhs: StoryMessageContentComponent) -> Bool { - if lhs.context !== rhs.context { - return false - } - if lhs.message != rhs.message { - return false - } - return true - } - - static func preload(context: AccountContext, message: EngineMessage) -> Signal { - var messageMedia: EngineMedia? - for media in message.media { - switch media { - case let image as TelegramMediaImage: - messageMedia = .image(image) - case let file as TelegramMediaFile: - messageMedia = .file(file) - default: - break - } - } - - guard let messageMedia else { - return .complete() - } - - var fetchSignal: Signal? - switch messageMedia { - case let .image(image): - if let representation = image.representations.last { - fetchSignal = fetchedMediaResource( - mediaBox: context.account.postbox.mediaBox, - userLocation: .peer(message.id.peerId), - userContentType: .image, - reference: ImageMediaReference.message(message: MessageReference(message._asMessage()), media: image).resourceReference(representation.resource) - ) - |> ignoreValues - |> `catch` { _ -> Signal in - return .complete() - } - } - case let .file(file): - fetchSignal = fetchedMediaResource( - mediaBox: context.account.postbox.mediaBox, - userLocation: .peer(message.id.peerId), - userContentType: .image, - reference: FileMediaReference.message(message: MessageReference(message._asMessage()), media: file).resourceReference(file.resource) - ) - |> ignoreValues - |> `catch` { _ -> Signal in - return .complete() - } - default: - break - } - - return fetchSignal ?? .complete() - } - - final class View: StoryContentItem.View { - private let imageNode: TransformImageNode - private var videoNode: UniversalVideoNode? - - private var currentMessageMedia: EngineMedia? - private var fetchDisposable: Disposable? - - private var component: StoryMessageContentComponent? - private weak var state: EmptyComponentState? - private var environment: StoryContentItem.Environment? - - private var isProgressPaused: Bool = false - private var currentProgressTimer: SwiftSignalKit.Timer? - private var currentProgressTimerValue: Double = 0.0 - private var videoProgressDisposable: Disposable? - - private var videoPlaybackStatus: MediaPlayerStatus? - - private let hierarchyTrackingLayer: HierarchyTrackingLayer - - override init(frame: CGRect) { - self.hierarchyTrackingLayer = HierarchyTrackingLayer() - self.imageNode = TransformImageNode() - - super.init(frame: frame) - - self.layer.addSublayer(self.hierarchyTrackingLayer) - - self.addSubnode(self.imageNode) - - self.hierarchyTrackingLayer.isInHierarchyUpdated = { [weak self] value in - guard let self else { - return - } - self.updateIsProgressPaused() - } - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - deinit { - self.fetchDisposable?.dispose() - self.currentProgressTimer?.invalidate() - self.videoProgressDisposable?.dispose() - } - - private func performActionAfterImageContentLoaded(update: Bool) { - guard let component = self.component, let currentMessageMedia = self.currentMessageMedia else { - return - } - - if case let .file(file) = currentMessageMedia { - if self.videoNode == nil { - let videoNode = UniversalVideoNode( - postbox: component.context.account.postbox, - audioSession: component.context.sharedContext.mediaManager.audioSession, - manager: component.context.sharedContext.mediaManager.universalVideoManager, - decoration: StoryVideoDecoration(), - content: NativeVideoContent( - id: .message(component.message.stableId, file.fileId), - userLocation: .peer(component.message.id.peerId), - fileReference: .message(message: MessageReference(component.message._asMessage()), media: file), - imageReference: nil, - loopVideo: true, - enableSound: true, - tempFilePath: nil, - captureProtected: component.message._asMessage().isCopyProtected(), - storeAfterDownload: nil - ), - priority: .gallery - ) - - self.videoNode = videoNode - self.addSubnode(videoNode) - - videoNode.ownsContentNodeUpdated = { [weak self] value in - guard let self else { - return - } - if value { - self.videoNode?.seek(0.0) - self.videoNode?.playOnceWithSound(playAndRecord: false) - } - } - videoNode.canAttachContent = true - if update { - self.state?.updated(transition: .immediate) - } - } - } - } - - override func setIsProgressPaused(_ isProgressPaused: Bool) { - if self.isProgressPaused != isProgressPaused { - self.isProgressPaused = isProgressPaused - self.updateIsProgressPaused() - } - } - - private func updateIsProgressPaused() { - if let videoNode = self.videoNode { - if !self.isProgressPaused && self.hierarchyTrackingLayer.isInHierarchy { - videoNode.play() - } else { - videoNode.pause() - } - } - - self.updateVideoPlaybackProgress() - self.updateProgressTimer() - } - - private func updateProgressTimer() { - let needsTimer = !self.isProgressPaused && self.hierarchyTrackingLayer.isInHierarchy - - if needsTimer { - if self.currentProgressTimer == nil { - self.currentProgressTimer = SwiftSignalKit.Timer( - timeout: 1.0 / 60.0, - repeat: true, - completion: { [weak self] in - guard let self, !self.isProgressPaused, self.hierarchyTrackingLayer.isInHierarchy else { - return - } - - if self.videoNode != nil { - self.updateVideoPlaybackProgress() - } else { - let currentProgressTimerLimit: Double = 5.0 - var currentProgressTimerValue = self.currentProgressTimerValue + 1.0 / 60.0 - currentProgressTimerValue = max(0.0, min(currentProgressTimerLimit, currentProgressTimerValue)) - self.currentProgressTimerValue = currentProgressTimerValue - - self.environment?.presentationProgressUpdated(currentProgressTimerValue / currentProgressTimerLimit) - } - }, queue: .mainQueue() - ) - self.currentProgressTimer?.start() - } - } else { - if let currentProgressTimer = self.currentProgressTimer { - self.currentProgressTimer = nil - currentProgressTimer.invalidate() - } - } - } - - private func updateVideoPlaybackProgress() { - guard let videoPlaybackStatus = self.videoPlaybackStatus else { - return - } - var isPlaying = false - var timestampAndDuration: (timestamp: Double?, duration: Double)? - switch videoPlaybackStatus.status { - case .playing: - isPlaying = true - default: - break - } - if case .buffering(true, _, _, _) = videoPlaybackStatus.status { - timestampAndDuration = (nil, videoPlaybackStatus.duration) - } else if Double(0.0).isLess(than: videoPlaybackStatus.duration) { - timestampAndDuration = (videoPlaybackStatus.timestamp, videoPlaybackStatus.duration) - } - - var currentProgress: Double = 0.0 - - if let (maybeTimestamp, duration) = timestampAndDuration, let timestamp = maybeTimestamp, duration > 0.01, let videoPlaybackStatus = self.videoPlaybackStatus { - var actualTimestamp: Double - if videoPlaybackStatus.generationTimestamp.isZero || !isPlaying { - actualTimestamp = timestamp - } else { - let currentTimestamp = CACurrentMediaTime() - actualTimestamp = timestamp + (currentTimestamp - videoPlaybackStatus.generationTimestamp) * videoPlaybackStatus.baseRate - } - - var progress = CGFloat(actualTimestamp / duration) - if progress.isNaN || !progress.isFinite { - progress = 0.0 - } - progress = min(1.0, progress) - - currentProgress = progress - } - - let clippedProgress = max(0.0, min(1.0, currentProgress)) - self.environment?.presentationProgressUpdated(clippedProgress) - } - - func update(component: StoryMessageContentComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { - self.component = component - self.state = state - self.environment = environment[StoryContentItem.Environment.self].value - - var messageMedia: EngineMedia? - for media in component.message.media { - switch media { - case let image as TelegramMediaImage: - messageMedia = .image(image) - case let file as TelegramMediaFile: - messageMedia = .file(file) - default: - break - } - } - - var reloadMedia = false - if self.currentMessageMedia?.id != messageMedia?.id { - self.currentMessageMedia = messageMedia - reloadMedia = true - } - - if reloadMedia, let messageMedia { - var signal: Signal<(TransformImageArguments) -> DrawingContext?, NoError>? - var fetchSignal: Signal? - switch messageMedia { - case let .image(image): - signal = chatMessagePhoto( - postbox: component.context.account.postbox, - userLocation: .peer(component.message.id.peerId), - photoReference: .message(message: MessageReference(component.message._asMessage()), media: image), - synchronousLoad: true, - highQuality: true - ) - if let representation = image.representations.last { - fetchSignal = fetchedMediaResource( - mediaBox: component.context.account.postbox.mediaBox, - userLocation: .peer(component.message.id.peerId), - userContentType: .image, - reference: ImageMediaReference.message(message: MessageReference(component.message._asMessage()), media: image).resourceReference(representation.resource) - ) - |> ignoreValues - |> `catch` { _ -> Signal in - return .complete() - } - } - case let .file(file): - signal = chatMessageVideo( - postbox: component.context.account.postbox, - userLocation: .peer(component.message.id.peerId), - videoReference: .message(message: MessageReference(component.message._asMessage()), media: file), - synchronousLoad: true - ) - fetchSignal = fetchedMediaResource( - mediaBox: component.context.account.postbox.mediaBox, - userLocation: .peer(component.message.id.peerId), - userContentType: .image, - reference: FileMediaReference.message(message: MessageReference(component.message._asMessage()), media: file).resourceReference(file.resource) - ) - |> ignoreValues - |> `catch` { _ -> Signal in - return .complete() - } - default: - break - } - - if let signal { - var wasSynchronous = true - self.imageNode.setSignal(signal |> afterCompleted { [weak self] in - Queue.mainQueue().async { - guard let self else { - return - } - - self.performActionAfterImageContentLoaded(update: !wasSynchronous) - } - }, attemptSynchronously: true) - wasSynchronous = false - } - - self.fetchDisposable?.dispose() - self.fetchDisposable = nil - if let fetchSignal { - self.fetchDisposable = fetchSignal.start() - } - } - - if let messageMedia { - var dimensions: CGSize? - switch messageMedia { - case let .image(image): - dimensions = image.representations.last?.dimensions.cgSize - case let .file(file): - dimensions = file.dimensions?.cgSize - default: - break - } - - if let dimensions { - let apply = self.imageNode.asyncLayout()(TransformImageArguments( - corners: ImageCorners(), - imageSize: dimensions.aspectFilled(availableSize), - boundingSize: availableSize, - intrinsicInsets: UIEdgeInsets() - )) - apply() - - if let videoNode = self.videoNode { - let videoSize = dimensions.aspectFilled(availableSize) - videoNode.frame = CGRect(origin: CGPoint(x: floor((availableSize.width - videoSize.width) * 0.5), y: floor((availableSize.height - videoSize.height) * 0.5)), size: videoSize) - videoNode.updateLayout(size: videoSize, transition: .immediate) - } - } - self.imageNode.frame = CGRect(origin: CGPoint(), size: availableSize) - } - - if let videoNode = self.videoNode { - if self.videoProgressDisposable == nil { - self.videoProgressDisposable = (videoNode.status - |> deliverOnMainQueue).start(next: { [weak self] status in - guard let self, let status else { - return - } - - self.videoPlaybackStatus = status - self.updateVideoPlaybackProgress() - }) - } - } - self.updateProgressTimer() - - return availableSize - } - } - - func makeView() -> View { - return View(frame: CGRect()) - } - - func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { - return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) - } -} diff --git a/submodules/TelegramUI/Components/Stories/StoryFooterPanelComponent/BUILD b/submodules/TelegramUI/Components/Stories/StoryFooterPanelComponent/BUILD index ebdf475252..e9688ac1be 100644 --- a/submodules/TelegramUI/Components/Stories/StoryFooterPanelComponent/BUILD +++ b/submodules/TelegramUI/Components/Stories/StoryFooterPanelComponent/BUILD @@ -15,6 +15,9 @@ swift_library( "//submodules/AppBundle", "//submodules/Components/BundleIconComponent", "//submodules/TelegramUI/Components/ChatListHeaderComponent", + "//submodules/AnimatedAvatarSetNode", + "//submodules/AccountContext", + "//submodules/TelegramCore", ], visibility = [ "//visibility:public", diff --git a/submodules/TelegramUI/Components/Stories/StoryFooterPanelComponent/Sources/StoryFooterPanelComponent.swift b/submodules/TelegramUI/Components/Stories/StoryFooterPanelComponent/Sources/StoryFooterPanelComponent.swift index dad8b06200..eb267263d5 100644 --- a/submodules/TelegramUI/Components/Stories/StoryFooterPanelComponent/Sources/StoryFooterPanelComponent.swift +++ b/submodules/TelegramUI/Components/Stories/StoryFooterPanelComponent/Sources/StoryFooterPanelComponent.swift @@ -5,20 +5,35 @@ import ComponentFlow import AppBundle import BundleIconComponent import ChatListHeaderComponent +import AnimatedAvatarSetNode +import AccountContext +import TelegramCore public final class StoryFooterPanelComponent: Component { + public let context: AccountContext + public let storyItem: StoryListContext.Item? public let deleteAction: () -> Void public let moreAction: (UIView, ContextGesture?) -> Void public init( + context: AccountContext, + storyItem: StoryListContext.Item?, deleteAction: @escaping () -> Void, moreAction: @escaping (UIView, ContextGesture?) -> Void ) { + self.context = context + self.storyItem = storyItem self.deleteAction = deleteAction self.moreAction = moreAction } public static func ==(lhs: StoryFooterPanelComponent, rhs: StoryFooterPanelComponent) -> Bool { + if lhs.context !== rhs.context { + return false + } + if lhs.storyItem != rhs.storyItem { + return false + } return true } @@ -27,11 +42,19 @@ public final class StoryFooterPanelComponent: Component { private let deleteButton = ComponentView() private var moreButton: MoreHeaderButton? + private let avatarsContext: AnimatedAvatarSetContext + private let avatarsNode: AnimatedAvatarSetNode + private var component: StoryFooterPanelComponent? private weak var state: EmptyComponentState? override init(frame: CGRect) { + self.avatarsContext = AnimatedAvatarSetContext() + self.avatarsNode = AnimatedAvatarSetNode() + super.init(frame: frame) + + self.addSubview(self.avatarsNode.view) } required init?(coder: NSCoder) { @@ -45,13 +68,40 @@ public final class StoryFooterPanelComponent: Component { let baseHeight: CGFloat = 44.0 let size = CGSize(width: availableSize.width, height: baseHeight) + var leftOffset: CGFloat = 16.0 + + let avatarSpacing: CGFloat = 18.0 + + var peers: [EnginePeer] = [] + if let seenPeers = component.storyItem?.seenPeers { + peers = Array(seenPeers.prefix(3)) + } + let avatarsContent = self.avatarsContext.update(peers: peers, animated: false) + let avatarsSize = self.avatarsNode.update(context: component.context, content: avatarsContent, itemSize: CGSize(width: 30.0, height: 30.0), animated: false, synchronousLoad: true) + + let avatarsNodeFrame = CGRect(origin: CGPoint(x: leftOffset, y: floor((size.height - avatarsSize.height) * 0.5)), size: avatarsSize) + self.avatarsNode.frame = avatarsNodeFrame + if !avatarsSize.width.isZero { + leftOffset = avatarsNodeFrame.maxX + avatarSpacing + } + + let viewsText: String + if let storyItem = component.storyItem, storyItem.seenCount != 0 { + if storyItem.seenCount == 1 { + viewsText = "1 view" + } else { + viewsText = "\(storyItem.seenCount) views" + } + } else { + viewsText = "No views yet" + } let viewStatsTextSize = self.viewStatsText.update( transition: .immediate, - component: AnyComponent(Text(text: "No views yet", font: Font.regular(15.0), color: .white)), + component: AnyComponent(Text(text: viewsText, font: Font.regular(15.0), color: .white)), environment: {}, containerSize: CGSize(width: availableSize.width, height: size.height) ) - let viewStatsTextFrame = CGRect(origin: CGPoint(x: 16.0, y: floor((size.height - viewStatsTextSize.height) * 0.5)), size: viewStatsTextSize) + let viewStatsTextFrame = CGRect(origin: CGPoint(x: leftOffset, y: floor((size.height - viewStatsTextSize.height) * 0.5)), size: viewStatsTextSize) if let viewStatsTextView = self.viewStatsText.view { if viewStatsTextView.superview == nil { viewStatsTextView.layer.anchorPoint = CGPoint() @@ -85,7 +135,7 @@ public final class StoryFooterPanelComponent: Component { self.addSubview(deleteButtonView) } transition.setFrame(view: deleteButtonView, frame: CGRect(origin: CGPoint(x: rightContentOffset - deleteButtonSize.width, y: floor((size.height - deleteButtonSize.height) * 0.5)), size: deleteButtonSize)) - rightContentOffset -= deleteButtonSize.width - 8.0 + rightContentOffset -= deleteButtonSize.width + 8.0 } let moreButton: MoreHeaderButton diff --git a/submodules/TelegramUI/Components/Stories/StoryPeerListComponent/BUILD b/submodules/TelegramUI/Components/Stories/StoryPeerListComponent/BUILD new file mode 100644 index 0000000000..6a860cab0a --- /dev/null +++ b/submodules/TelegramUI/Components/Stories/StoryPeerListComponent/BUILD @@ -0,0 +1,26 @@ +load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library") + +swift_library( + name = "StoryPeerListComponent", + module_name = "StoryPeerListComponent", + srcs = glob([ + "Sources/**/*.swift", + ]), + copts = [ + "-warnings-as-errors", + ], + deps = [ + "//submodules/Display", + "//submodules/ComponentFlow", + "//submodules/AppBundle", + "//submodules/Components/BundleIconComponent", + "//submodules/AccountContext", + "//submodules/TelegramCore", + "//submodules/SSignalKit/SwiftSignalKit", + "//submodules/TelegramPresentationData", + "//submodules/AvatarNode", + ], + visibility = [ + "//visibility:public", + ], +) diff --git a/submodules/TelegramUI/Components/Stories/StoryPeerListComponent/Sources/StoryPeerListComponent.swift b/submodules/TelegramUI/Components/Stories/StoryPeerListComponent/Sources/StoryPeerListComponent.swift new file mode 100644 index 0000000000..67798eda76 --- /dev/null +++ b/submodules/TelegramUI/Components/Stories/StoryPeerListComponent/Sources/StoryPeerListComponent.swift @@ -0,0 +1,259 @@ +import Foundation +import UIKit +import Display +import ComponentFlow +import AppBundle +import BundleIconComponent +import AccountContext +import TelegramCore +import SwiftSignalKit +import TelegramPresentationData + +public final class StoryPeerListComponent: Component { + public let context: AccountContext + public let theme: PresentationTheme + public let strings: PresentationStrings + public let state: StoryListContext.State? + public let peerAction: (EnginePeer) -> Void + + public init( + context: AccountContext, + theme: PresentationTheme, + strings: PresentationStrings, + state: StoryListContext.State?, + peerAction: @escaping (EnginePeer) -> Void + ) { + self.context = context + self.theme = theme + self.strings = strings + self.state = state + self.peerAction = peerAction + } + + public static func ==(lhs: StoryPeerListComponent, rhs: StoryPeerListComponent) -> Bool { + if lhs.context !== rhs.context { + return false + } + if lhs.theme !== rhs.theme { + return false + } + if lhs.strings !== rhs.strings { + return false + } + if lhs.state != rhs.state { + return false + } + return true + } + + private final class ScrollView: UIScrollView { + override func touchesShouldCancel(in view: UIView) -> Bool { + return true + } + } + + private final class VisibleItem { + let view = ComponentView() + + init() { + } + } + + private struct ItemLayout { + let containerSize: CGSize + let containerInsets: UIEdgeInsets + let itemSize: CGSize + let itemSpacing: CGFloat + let itemCount: Int + + let contentSize: CGSize + + init( + containerSize: CGSize, + containerInsets: UIEdgeInsets, + itemSize: CGSize, + itemSpacing: CGFloat, + itemCount: Int + ) { + self.containerSize = containerSize + self.containerInsets = containerInsets + self.itemSize = itemSize + self.itemSpacing = itemSpacing + self.itemCount = itemCount + + self.contentSize = CGSize(width: containerInsets.left + containerInsets.right + CGFloat(itemCount) * itemSize.width + CGFloat(max(0, itemCount - 1)) * itemSpacing, height: containerSize.height) + } + + func frame(at index: Int) -> CGRect { + return CGRect(origin: CGPoint(x: self.containerInsets.left + (self.itemSize.width + self.itemSpacing) * CGFloat(index), y: self.containerInsets.top), size: self.itemSize) + } + } + + public final class View: UIView, UIScrollViewDelegate { + private let scrollView: ScrollView + + private var ignoreScrolling: Bool = false + private var itemLayout: ItemLayout? + + private var sortedItemSets: [StoryListContext.PeerItemSet] = [] + private var visibleItems: [EnginePeer.Id: VisibleItem] = [:] + + private var component: StoryPeerListComponent? + private weak var state: EmptyComponentState? + + public override init(frame: CGRect) { + self.scrollView = ScrollView() + self.scrollView.delaysContentTouches = false + if #available(iOSApplicationExtension 11.0, iOS 11.0, *) { + self.scrollView.contentInsetAdjustmentBehavior = .never + } + self.scrollView.showsVerticalScrollIndicator = false + self.scrollView.showsHorizontalScrollIndicator = false + self.scrollView.alwaysBounceVertical = false + self.scrollView.alwaysBounceHorizontal = true + + super.init(frame: frame) + + self.scrollView.delegate = self + self.addSubview(self.scrollView) + } + + required public init?(coder: NSCoder) { + preconditionFailure() + } + + public func transitionViewForItem(peerId: EnginePeer.Id) -> UIView? { + if let visibleItem = self.visibleItems[peerId], let itemView = visibleItem.view.view as? StoryPeerListItemComponent.View { + return itemView.transitionView() + } + return nil + } + + public func scrollViewDidScroll(_ scrollView: UIScrollView) { + if !self.ignoreScrolling { + self.updateScrolling(transition: .immediate) + } + } + + private func updateScrolling(transition: Transition) { + guard let component = self.component, let itemLayout = self.itemLayout else { + return + } + + var validIds: [EnginePeer.Id] = [] + for i in 0 ..< self.sortedItemSets.count { + let itemSet = self.sortedItemSets[i] + guard let peer = itemSet.peer else { + continue + } + validIds.append(itemSet.peerId) + + let visibleItem: VisibleItem + var itemTransition = transition + if let current = self.visibleItems[itemSet.peerId] { + visibleItem = current + } else { + itemTransition = .immediate + visibleItem = VisibleItem() + self.visibleItems[itemSet.peerId] = visibleItem + } + + var hasUnseen = false + if peer.id != component.context.account.peerId { + for item in itemSet.items { + if !item.isSeen { + hasUnseen = true + } + } + } + + let _ = visibleItem.view.update( + transition: itemTransition, + component: AnyComponent(StoryPeerListItemComponent( + context: component.context, + theme: component.theme, + strings: component.strings, + peer: peer, + hasUnseen: hasUnseen, + action: component.peerAction + )), + environment: {}, + containerSize: itemLayout.itemSize + ) + + let itemFrame = itemLayout.frame(at: i) + + if let itemView = visibleItem.view.view { + if itemView.superview == nil { + self.scrollView.addSubview(itemView) + } + itemTransition.setFrame(view: itemView, frame: itemFrame) + } + } + + var removedIds: [EnginePeer.Id] = [] + for (id, visibleItem) in self.visibleItems { + if !validIds.contains(id) { + removedIds.append(id) + if let itemView = visibleItem.view.view { + itemView.removeFromSuperview() + } + } + } + for id in removedIds { + self.visibleItems.removeValue(forKey: id) + } + } + + override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + return super.hitTest(point, with: event) + } + + func update(component: StoryPeerListComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + self.component = component + self.state = state + + self.sortedItemSets.removeAll(keepingCapacity: true) + if let state = component.state { + if let myIndex = state.itemSets.firstIndex(where: { $0.peerId == component.context.account.peerId }) { + self.sortedItemSets.append(state.itemSets[myIndex]) + } + for itemSet in state.itemSets { + if itemSet.peerId == component.context.account.peerId { + continue + } + self.sortedItemSets.append(itemSet) + } + } + + let itemLayout = ItemLayout( + containerSize: availableSize, + containerInsets: UIEdgeInsets(top: 0.0, left: 10.0, bottom: 0.0, right: 10.0), + itemSize: CGSize(width: 60.0, height: 77.0), + itemSpacing: 24.0, + itemCount: self.sortedItemSets.count + ) + self.itemLayout = itemLayout + + self.ignoreScrolling = true + + transition.setFrame(view: self.scrollView, frame: CGRect(origin: CGPoint(), size: availableSize)) + if self.scrollView.contentSize != itemLayout.contentSize { + self.scrollView.contentSize = itemLayout.contentSize + } + + self.ignoreScrolling = false + self.updateScrolling(transition: transition) + + return availableSize + } + } + + public func makeView() -> View { + return View(frame: CGRect()) + } + + public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } +} diff --git a/submodules/TelegramUI/Components/Stories/StoryPeerListComponent/Sources/StoryPeerListItemComponent.swift b/submodules/TelegramUI/Components/Stories/StoryPeerListComponent/Sources/StoryPeerListItemComponent.swift new file mode 100644 index 0000000000..54daba591d --- /dev/null +++ b/submodules/TelegramUI/Components/Stories/StoryPeerListComponent/Sources/StoryPeerListItemComponent.swift @@ -0,0 +1,188 @@ +import Foundation +import UIKit +import Display +import ComponentFlow +import AppBundle +import BundleIconComponent +import AccountContext +import TelegramCore +import SwiftSignalKit +import TelegramPresentationData +import AvatarNode + +public final class StoryPeerListItemComponent: Component { + public let context: AccountContext + public let theme: PresentationTheme + public let strings: PresentationStrings + public let peer: EnginePeer + public let hasUnseen: Bool + public let action: (EnginePeer) -> Void + + public init( + context: AccountContext, + theme: PresentationTheme, + strings: PresentationStrings, + peer: EnginePeer, + hasUnseen: Bool, + action: @escaping (EnginePeer) -> Void + ) { + self.context = context + self.theme = theme + self.strings = strings + self.peer = peer + self.hasUnseen = hasUnseen + self.action = action + } + + public static func ==(lhs: StoryPeerListItemComponent, rhs: StoryPeerListItemComponent) -> Bool { + if lhs.context !== rhs.context { + return false + } + if lhs.theme !== rhs.theme { + return false + } + if lhs.strings !== rhs.strings { + return false + } + if lhs.peer != rhs.peer { + return false + } + if lhs.hasUnseen != rhs.hasUnseen { + return false + } + return true + } + + public final class View: HighlightTrackingButton { + private var avatarNode: AvatarNode? + private let indicatorCircleView: UIImageView + private let title = ComponentView() + + private var component: StoryPeerListItemComponent? + private weak var componentState: EmptyComponentState? + + public override init(frame: CGRect) { + self.indicatorCircleView = UIImageView() + self.indicatorCircleView.isUserInteractionEnabled = false + + super.init(frame: frame) + + self.addSubview(self.indicatorCircleView) + + self.highligthedChanged = { [weak self] highlighted in + guard let self else { + return + } + if highlighted { + self.alpha = 0.7 + } else { + let previousAlpha = self.alpha + self.alpha = 1.0 + self.layer.animateAlpha(from: previousAlpha, to: self.alpha, duration: 0.25) + } + } + self.addTarget(self, action: #selector(self.pressed), for: .touchUpInside) + } + + required public init?(coder: NSCoder) { + preconditionFailure() + } + + @objc private func pressed() { + guard let component = self.component else { + return + } + component.action(component.peer) + } + + public func transitionView() -> UIView? { + return self.avatarNode?.view + } + + func update(component: StoryPeerListItemComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + let hadUnseen = self.component?.hasUnseen ?? false + + self.component = component + self.componentState = state + + let avatarNode: AvatarNode + if let current = self.avatarNode { + avatarNode = current + } else { + avatarNode = AvatarNode(font: avatarPlaceholderFont(size: 26.0)) + self.avatarNode = avatarNode + avatarNode.isUserInteractionEnabled = false + self.addSubview(avatarNode.view) + } + + let avatarSize = CGSize(width: 52.0, height: 52.0) + let avatarFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - avatarSize.width) * 0.5), y: 4.0), size: avatarSize) + let indicatorFrame = avatarFrame.insetBy(dx: -4.0, dy: -4.0) + + avatarNode.setPeer( + context: component.context, + theme: component.theme, + peer: component.peer + ) + avatarNode.updateSize(size: avatarSize) + transition.setFrame(view: avatarNode.view, frame: avatarFrame) + + if component.peer.id == component.context.account.peerId && !component.hasUnseen { + self.indicatorCircleView.image = nil + } else if self.indicatorCircleView.image == nil || hadUnseen != component.hasUnseen { + self.indicatorCircleView.image = generateImage(indicatorFrame.size, rotatedContext: { size, context in + context.clear(CGRect(origin: CGPoint(), size: size)) + + let lineWidth: CGFloat = 2.0 + context.setLineWidth(lineWidth) + context.addEllipse(in: CGRect(origin: CGPoint(), size: size).insetBy(dx: lineWidth * 0.5, dy: lineWidth * 0.5)) + context.replacePathWithStrokedPath() + context.clip() + + var locations: [CGFloat] = [1.0, 0.0] + let colors: [CGColor] + + if component.hasUnseen { + colors = [UIColor(rgb: 0x34C76F).cgColor, UIColor(rgb: 0x3DA1FD).cgColor] + } else { + colors = [UIColor(rgb: 0xD8D8E1).cgColor, UIColor(rgb: 0xD8D8E1).cgColor] + } + + let colorSpace = CGColorSpaceCreateDeviceRGB() + let gradient = CGGradient(colorsSpace: colorSpace, colors: colors as CFArray, locations: &locations)! + + context.drawLinearGradient(gradient, start: CGPoint(x: 0.0, y: 0.0), end: CGPoint(x: 0.0, y: size.height), options: CGGradientDrawingOptions()) + }) + } + transition.setFrame(view: self.indicatorCircleView, frame: indicatorFrame) + + //TODO:localize + let titleSize = self.title.update( + transition: .immediate, + component: AnyComponent(Text(text: component.peer.id == component.context.account.peerId ? "My story" : component.peer.compactDisplayTitle, font: Font.regular(11.0), color: component.theme.list.itemPrimaryTextColor)), + environment: {}, + containerSize: CGSize(width: availableSize.width + 4.0, height: 100.0) + ) + let titleFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - titleSize.width) * 0.5), y: indicatorFrame.maxY + 3.0), size: titleSize) + if let titleView = self.title.view { + if titleView.superview == nil { + titleView.layer.anchorPoint = CGPoint() + titleView.isUserInteractionEnabled = false + self.addSubview(titleView) + } + transition.setPosition(view: titleView, position: titleFrame.origin) + transition.setBounds(view: titleView, bounds: CGRect(origin: CGPoint(), size: titleFrame.size)) + } + + return availableSize + } + } + + public func makeView() -> View { + return View(frame: CGRect()) + } + + public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } +} From e8dab905845fa7ab10215388e0d67afdd2299222 Mon Sep 17 00:00:00 2001 From: Ali <> Date: Wed, 10 May 2023 00:36:37 +0400 Subject: [PATCH 2/2] [WIP] Stories --- .../ContactMultiselectionController.swift | 11 +- .../Sources/ChatListSearchItemHeader.swift | 10 +- .../ChatListAdditionalCategoryItem.swift | 8 +- .../Sources/ChatListController.swift | 24 +- .../Sources/Node/ChatListNode.swift | 32 +- .../Sources/Node/ChatListNodeEntries.swift | 2 +- ...tControllerExtractedPresentationNode.swift | 6 +- submodules/Display/Source/ImageNode.swift | 6 +- ...teractiveTransitionGestureRecognizer.swift | 78 +- submodules/Display/Source/NavigationBar.swift | 2 +- .../Sources/FeaturedStickersScreen.swift | 8 +- .../Sources/ListSectionHeaderNode.swift | 2 +- .../ReactionContextBackgroundNode.swift | 9 +- .../Sources/ReactionContextNode.swift | 9 + .../Sources/StickerPackScreen.swift | 4 +- .../State/AccountStateManagementUtils.swift | 37 +- .../TelegramEngine/Messages/Stories.swift | 102 +- .../Messages/StoryListContext.swift | 84 +- .../Messages/TelegramEngineMessages.swift | 4 +- .../Sources/ChatEntityKeyboardInputNode.swift | 12 +- .../MediaRecordingPanelComponent.swift | 14 +- .../Sources/MessageInputPanelComponent.swift | 137 +- .../Components/ShareWithPeersScreen/BUILD | 37 + .../Sources/ActionListItemComponent.swift | 196 ++ .../Sources/PeerListItemComponent.swift | 312 +++ .../Sources/ShareWithPeersScreen.swift | 1508 ++++++++++++ .../Stories/StoryContainerScreen/BUILD | 2 + .../Sources/StoryContainerScreen.swift | 2047 ++--------------- .../Sources/StoryContent.swift | 6 +- .../StoryItemSetContainerComponent.swift | 585 ++++- ...StoryItemSetContainerViewSendMessage.swift | 1660 +++++++++++++ .../Sources/StoryAvatarInfoComponent.swift | 2 +- .../Sources/StoryChatContent.swift | 2 +- .../Sources/StoryItemContentComponent.swift | 5 + .../Sources/TextFieldComponent.swift | 2 +- .../TelegramUI/Sources/ChatController.swift | 12 +- .../Sources/ChatControllerNode.swift | 2 +- .../Sources/ChatTextInputPanelNode.swift | 2 +- .../ContactMultiselectionController.swift | 31 +- .../ContactMultiselectionControllerNode.swift | 2 +- .../EmojisChatInputContextPanelNode.swift | 2 +- ...rizontalStickersChatContextPanelNode.swift | 4 +- .../Sources/InlineReactionSearchPanel.swift | 4 +- .../Sources/PeerInfo/PeerInfoScreen.swift | 2 +- .../Sources/PeerSelectionControllerNode.swift | 2 +- .../Sources/TelegramRootController.swift | 124 +- .../Sources/UndoOverlayController.swift | 2 +- .../Sources/UndoOverlayControllerNode.swift | 6 +- 48 files changed, 4980 insertions(+), 2180 deletions(-) create mode 100644 submodules/TelegramUI/Components/ShareWithPeersScreen/BUILD create mode 100644 submodules/TelegramUI/Components/ShareWithPeersScreen/Sources/ActionListItemComponent.swift create mode 100644 submodules/TelegramUI/Components/ShareWithPeersScreen/Sources/PeerListItemComponent.swift create mode 100644 submodules/TelegramUI/Components/ShareWithPeersScreen/Sources/ShareWithPeersScreen.swift create mode 100644 submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerViewSendMessage.swift diff --git a/submodules/AccountContext/Sources/ContactMultiselectionController.swift b/submodules/AccountContext/Sources/ContactMultiselectionController.swift index 5981d76389..11cd6c8fca 100644 --- a/submodules/AccountContext/Sources/ContactMultiselectionController.swift +++ b/submodules/AccountContext/Sources/ContactMultiselectionController.swift @@ -6,8 +6,8 @@ import TelegramCore import TelegramPresentationData public struct ChatListNodeAdditionalCategory { - public enum Appearance { - case option + public enum Appearance: Equatable { + case option(sectionTitle: String?) case action } @@ -17,7 +17,7 @@ public struct ChatListNodeAdditionalCategory { public var title: String public var appearance: Appearance - public init(id: Int, icon: UIImage?, smallIcon: UIImage?, title: String, appearance: Appearance = .option) { + public init(id: Int, icon: UIImage?, smallIcon: UIImage?, title: String, appearance: Appearance = .option(sectionTitle: nil)) { self.id = id self.icon = icon self.smallIcon = smallIcon @@ -44,6 +44,7 @@ public enum ContactMultiselectionControllerMode { public var additionalCategories: ContactMultiselectionControllerAdditionalCategories? public var chatListFilters: [ChatListFilter]? public var displayAutoremoveTimeout: Bool + public var displayPresence: Bool public init( title: String, @@ -51,7 +52,8 @@ public enum ContactMultiselectionControllerMode { selectedChats: Set, additionalCategories: ContactMultiselectionControllerAdditionalCategories?, chatListFilters: [ChatListFilter]?, - displayAutoremoveTimeout: Bool = false + displayAutoremoveTimeout: Bool = false, + displayPresence: Bool = false ) { self.title = title self.searchPlaceholder = searchPlaceholder @@ -59,6 +61,7 @@ public enum ContactMultiselectionControllerMode { self.additionalCategories = additionalCategories self.chatListFilters = chatListFilters self.displayAutoremoveTimeout = displayAutoremoveTimeout + self.displayPresence = displayPresence } } diff --git a/submodules/ChatListSearchItemHeader/Sources/ChatListSearchItemHeader.swift b/submodules/ChatListSearchItemHeader/Sources/ChatListSearchItemHeader.swift index 4561e8d691..0aebce2226 100644 --- a/submodules/ChatListSearchItemHeader/Sources/ChatListSearchItemHeader.swift +++ b/submodules/ChatListSearchItemHeader/Sources/ChatListSearchItemHeader.swift @@ -30,6 +30,7 @@ public enum ChatListSearchItemHeaderType { case downloading case recentDownloads case topics + case text(String, AnyHashable) fileprivate func title(strings: PresentationStrings) -> String { switch self { @@ -87,6 +88,8 @@ public enum ChatListSearchItemHeaderType { return strings.DownloadList_DownloadedHeader case .topics: return strings.DialogList_SearchSectionTopics + case let .text(text, _): + return text } } @@ -146,11 +149,13 @@ public enum ChatListSearchItemHeaderType { return .recentDownloads case .topics: return .topics + case let .text(_, id): + return .text(id) } } } -private enum ChatListSearchItemHeaderId: Int32 { +private enum ChatListSearchItemHeaderId: Hashable { case localPeers case members case contacts @@ -181,6 +186,7 @@ private enum ChatListSearchItemHeaderId: Int32 { case downloading case recentDownloads case topics + case text(AnyHashable) } public final class ChatListSearchItemHeader: ListViewItemHeader { @@ -197,7 +203,7 @@ public final class ChatListSearchItemHeader: ListViewItemHeader { public init(type: ChatListSearchItemHeaderType, theme: PresentationTheme, strings: PresentationStrings, actionTitle: String? = nil, action: (() -> Void)? = nil) { self.type = type - self.id = ListViewItemNode.HeaderId(space: 0, id: Int64(self.type.id.rawValue)) + self.id = ListViewItemNode.HeaderId(space: 0, id: Int64(self.type.id.hashValue)) self.theme = theme self.strings = strings self.actionTitle = actionTitle diff --git a/submodules/ChatListUI/Sources/ChatListAdditionalCategoryItem.swift b/submodules/ChatListUI/Sources/ChatListAdditionalCategoryItem.swift index 045d8ac679..546bdad670 100644 --- a/submodules/ChatListUI/Sources/ChatListAdditionalCategoryItem.swift +++ b/submodules/ChatListUI/Sources/ChatListAdditionalCategoryItem.swift @@ -45,8 +45,12 @@ public class ChatListAdditionalCategoryItem: ItemListItem, ListViewItemWithHeade self.action = action switch appearance { - case .option: - self.header = ChatListSearchItemHeader(type: .chatTypes, theme: presentationData.theme, strings: presentationData.strings, actionTitle: nil, action: nil) + case let .option(sectionTitle): + if let sectionTitle { + self.header = ChatListSearchItemHeader(type: .text(sectionTitle, AnyHashable(0)), theme: presentationData.theme, strings: presentationData.strings, actionTitle: nil, action: nil) + } else { + self.header = ChatListSearchItemHeader(type: .chatTypes, theme: presentationData.theme, strings: presentationData.strings, actionTitle: nil, action: nil) + } case .action: self.header = header } diff --git a/submodules/ChatListUI/Sources/ChatListController.swift b/submodules/ChatListUI/Sources/ChatListController.swift index e1da129844..bb3603a82c 100644 --- a/submodules/ChatListUI/Sources/ChatListController.swift +++ b/submodules/ChatListUI/Sources/ChatListController.swift @@ -1358,7 +1358,10 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController context: self.context, initialFocusedId: AnyHashable(peerId), initialContent: initialContent, - transitionIn: nil + transitionIn: nil, + transitionOut: { _ in + return nil + } ) self.push(storyContainerScreen) }) @@ -2412,7 +2415,24 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController context: self.context, initialFocusedId: AnyHashable(peer.id), initialContent: initialContent, - transitionIn: transitionIn + transitionIn: transitionIn, + transitionOut: { [weak self] peerId in + guard let self else { + return nil + } + + if let storyPeerListView = self.storyPeerListView?.view as? StoryPeerListComponent.View { + if let transitionView = storyPeerListView.transitionViewForItem(peerId: peerId) { + return StoryContainerScreen.TransitionOut( + destinationView: transitionView, + destinationRect: transitionView.bounds, + destinationCornerRadius: transitionView.bounds.height * 0.5 + ) + } + } + + return nil + } ) self.push(storyContainerScreen) }) diff --git a/submodules/ChatListUI/Sources/Node/ChatListNode.swift b/submodules/ChatListUI/Sources/Node/ChatListNode.swift index 19b8f53a1b..410bebddd1 100644 --- a/submodules/ChatListUI/Sources/Node/ChatListNode.swift +++ b/submodules/ChatListUI/Sources/Node/ChatListNode.swift @@ -22,7 +22,7 @@ import StoryContainerScreen public enum ChatListNodeMode { case chatList(appendContacts: Bool) - case peers(filter: ChatListNodePeersFilter, isSelecting: Bool, additionalCategories: [ChatListNodeAdditionalCategory], chatListFilters: [ChatListFilter]?, displayAutoremoveTimeout: Bool) + case peers(filter: ChatListNodePeersFilter, isSelecting: Bool, additionalCategories: [ChatListNodeAdditionalCategory], chatListFilters: [ChatListFilter]?, displayAutoremoveTimeout: Bool, displayPresence: Bool) case peerType(type: [ReplyMarkupButtonRequestPeerType], hasCreate: Bool) } @@ -405,7 +405,7 @@ private func mappedInsertEntries(context: AccountContext, nodeInteraction: ChatL hiddenOffset: threadInfo?.isHidden == true && !revealed, interaction: nodeInteraction ), directionHint: entry.directionHint) - case let .peers(filter, isSelecting, _, filters, displayAutoremoveTimeout): + case let .peers(filter, isSelecting, _, filters, displayAutoremoveTimeout, displayPresence): let itemPeer = peer.chatMainPeer var chatPeer: EnginePeer? if let peer = peer.peers[peer.peerId] { @@ -488,7 +488,7 @@ private func mappedInsertEntries(context: AccountContext, nodeInteraction: ChatL var header: ChatListSearchItemHeader? switch mode { - case let .peers(_, _, additionalCategories, _, _): + case let .peers(_, _, additionalCategories, _, _, _): if !additionalCategories.isEmpty { let headerType: ChatListSearchItemHeaderType if case .action = additionalCategories[0].appearance { @@ -505,7 +505,9 @@ private func mappedInsertEntries(context: AccountContext, nodeInteraction: ChatL var status: ContactsPeerItemStatus = .none if isSelecting, let itemPeer = itemPeer { - if let (string, multiline, isActive, icon) = statusStringForPeerType(accountPeerId: context.account.peerId, strings: presentationData.strings, peer: itemPeer, isMuted: isRemovedFromTotalUnreadCount, isUnread: combinedReadState?.isUnread ?? false, isContact: isContact, hasUnseenMentions: hasUnseenMentions, chatListFilters: filters, displayAutoremoveTimeout: displayAutoremoveTimeout, autoremoveTimeout: peerEntry.autoremoveTimeout) { + if displayPresence, let presence = presence { + status = .presence(presence, presentationData.dateTimeFormat) + } else if let (string, multiline, isActive, icon) = statusStringForPeerType(accountPeerId: context.account.peerId, strings: presentationData.strings, peer: itemPeer, isMuted: isRemovedFromTotalUnreadCount, isUnread: combinedReadState?.isUnread ?? false, isContact: isContact, hasUnseenMentions: hasUnseenMentions, chatListFilters: filters, displayAutoremoveTimeout: displayAutoremoveTimeout, autoremoveTimeout: peerEntry.autoremoveTimeout) { status = .custom(string: string, multiline: multiline, isActive: isActive, icon: icon) } else { status = .none @@ -749,7 +751,7 @@ private func mappedUpdateEntries(context: AccountContext, nodeInteraction: ChatL hiddenOffset: threadInfo?.isHidden == true && !revealed, interaction: nodeInteraction ), directionHint: entry.directionHint) - case let .peers(filter, isSelecting, _, filters, displayAutoremoveTimeout): + case let .peers(filter, isSelecting, _, filters, displayAutoremoveTimeout, displayPresence): let itemPeer = peer.chatMainPeer var chatPeer: EnginePeer? if let peer = peer.peers[peer.peerId] { @@ -786,7 +788,7 @@ private func mappedUpdateEntries(context: AccountContext, nodeInteraction: ChatL var header: ChatListSearchItemHeader? switch mode { - case let .peers(_, _, additionalCategories, _, _): + case let .peers(_, _, additionalCategories, _, _, _): if !additionalCategories.isEmpty { let headerType: ChatListSearchItemHeaderType if case .action = additionalCategories[0].appearance { @@ -803,7 +805,9 @@ private func mappedUpdateEntries(context: AccountContext, nodeInteraction: ChatL var status: ContactsPeerItemStatus = .none if isSelecting, let itemPeer = itemPeer { - if let (string, multiline, isActive, icon) = statusStringForPeerType(accountPeerId: context.account.peerId, strings: presentationData.strings, peer: itemPeer, isMuted: isRemovedFromTotalUnreadCount, isUnread: combinedReadState?.isUnread ?? false, isContact: isContact, hasUnseenMentions: hasUnseenMentions, chatListFilters: filters, displayAutoremoveTimeout: displayAutoremoveTimeout, autoremoveTimeout: peerEntry.autoremoveTimeout) { + if displayPresence, let presence = presence { + status = .presence(presence, presentationData.dateTimeFormat) + } else if let (string, multiline, isActive, icon) = statusStringForPeerType(accountPeerId: context.account.peerId, strings: presentationData.strings, peer: itemPeer, isMuted: isRemovedFromTotalUnreadCount, isUnread: combinedReadState?.isUnread ?? false, isContact: isContact, hasUnseenMentions: hasUnseenMentions, chatListFilters: filters, displayAutoremoveTimeout: displayAutoremoveTimeout, autoremoveTimeout: peerEntry.autoremoveTimeout) { status = .custom(string: string, multiline: multiline, isActive: isActive, icon: icon) } else { status = .none @@ -1191,7 +1195,7 @@ public final class ChatListNode: ListView { self.animationRenderer = animationRenderer var isSelecting = false - if case .peers(_, true, _, _, _) = mode { + if case .peers(_, true, _, _, _, _) = mode { isSelecting = true } @@ -1555,7 +1559,7 @@ public final class ChatListNode: ListView { let currentRemovingItemId = self.currentRemovingItemId let savedMessagesPeer: Signal - if case let .peers(filter, _, _, _, _) = mode, filter.contains(.onlyWriteable), case .chatList = location, self.chatListFilter == nil { + if case let .peers(filter, _, _, _, _, _) = mode, filter.contains(.onlyWriteable), case .chatList = location, self.chatListFilter == nil { savedMessagesPeer = context.account.postbox.loadedPeerWithId(context.account.peerId) |> map(Optional.init) |> map { peer in @@ -1882,7 +1886,7 @@ public final class ChatListNode: ListView { case .chatList: isEmpty = false return true - case let .peers(filter, _, _, _, _): + case let .peers(filter, _, _, _, _, _): guard !filter.contains(.excludeSavedMessages) || peer.peerId != currentPeerId else { return false } guard !filter.contains(.excludeSavedMessages) || !peer.peerId.isReplies else { return false } guard !filter.contains(.excludeSecretChats) || peer.peerId.namespace != Namespaces.Peer.SecretChat else { return false } @@ -3721,10 +3725,12 @@ private func statusStringForPeerType(accountPeerId: EnginePeer.Id, strings: Pres } else if case let .user(user) = peer { if user.botInfo != nil || user.flags.contains(.isSupport) { return (strings.ChatList_PeerTypeBot, false, false, nil) - } else if isContact { - return (strings.ChatList_PeerTypeContact, false, false, nil) } else { - return (strings.ChatList_PeerTypeNonContact, false, false, nil) + if isContact { + return (strings.ChatList_PeerTypeContact, false, false, nil) + } else { + return (strings.ChatList_PeerTypeNonContact, false, false, nil) + } } } else if case .secretChat = peer { if isContact { diff --git a/submodules/ChatListUI/Sources/Node/ChatListNodeEntries.swift b/submodules/ChatListUI/Sources/Node/ChatListNodeEntries.swift index eadb228339..53350b7ace 100644 --- a/submodules/ChatListUI/Sources/Node/ChatListNodeEntries.swift +++ b/submodules/ChatListUI/Sources/Node/ChatListNodeEntries.swift @@ -819,7 +819,7 @@ func chatListNodeEntriesForView(_ view: EngineChatList, state: ChatListNodeState } if !view.hasLater { - if case let .peers(_, _, additionalCategories, _, _) = mode { + if case let .peers(_, _, additionalCategories, _, _, _) = mode { var index = 0 for category in additionalCategories.reversed() { result.append(.AdditionalCategory(index: index, id: category.id, title: category.title, image: category.icon, appearance: category.appearance, selected: state.selectedAdditionalCategoryIds.contains(category.id), presentationData: state.presentationData)) diff --git a/submodules/ContextUI/Sources/ContextControllerExtractedPresentationNode.swift b/submodules/ContextUI/Sources/ContextControllerExtractedPresentationNode.swift index 6a31d7403d..cdd2d26e37 100644 --- a/submodules/ContextUI/Sources/ContextControllerExtractedPresentationNode.swift +++ b/submodules/ContextUI/Sources/ContextControllerExtractedPresentationNode.swift @@ -280,6 +280,10 @@ final class ContextControllerExtractedPresentationNode: ASDisplayNode, ContextCo self.scrollNode.addSubnode(self.actionsContainerNode) self.actionsContainerNode.addSubnode(self.additionalActionsStackNode) self.actionsContainerNode.addSubnode(self.actionsStackNode) + + #if DEBUG + //self.addSubnode(self.contentRectDebugNode) + #endif self.scroller.delegate = self @@ -609,7 +613,7 @@ final class ContextControllerExtractedPresentationNode: ASDisplayNode, ContextCo } let presentationData = context.sharedContext.currentPresentationData.with { $0 } - let undoController = UndoOverlayController(presentationData: presentationData, content: .sticker(context: context, file: file, title: nil, text: presentationData.strings.Chat_PremiumReactionToastTitle, undoText: presentationData.strings.Chat_PremiumReactionToastAction, customAction: { [weak controller] in + let undoController = UndoOverlayController(presentationData: presentationData, content: .sticker(context: context, file: file, loop: true, title: nil, text: presentationData.strings.Chat_PremiumReactionToastTitle, undoText: presentationData.strings.Chat_PremiumReactionToastAction, customAction: { [weak controller] in controller?.premiumReactionsSelected?() }), elevatedLayout: false, position: position, animateInAsReplacement: animateInAsReplacement, action: { _ in true }) strongSelf.currentUndoController = undoController diff --git a/submodules/Display/Source/ImageNode.swift b/submodules/Display/Source/ImageNode.swift index 5d61c11813..f609ca9f0a 100644 --- a/submodules/Display/Source/ImageNode.swift +++ b/submodules/Display/Source/ImageNode.swift @@ -162,6 +162,7 @@ public class ImageNode: ASDisplayNode { public func setSignal(_ signal: Signal) { var reportedHasImage = false + var wasSynchronous = true self.disposable.set((signal |> deliverOnMainQueue).start(next: {[weak self] next in dispatcher.dispatch { if let strongSelf = self { @@ -169,12 +170,12 @@ public class ImageNode: ASDisplayNode { if strongSelf.first && next != nil { strongSelf.first = false animate = false - if strongSelf.isNodeLoaded && strongSelf.animateFirstTransition { + if strongSelf.isNodeLoaded && strongSelf.animateFirstTransition && !wasSynchronous { strongSelf.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.18) } } if let image = next?.cgImage { - if animate, let previousContents = strongSelf.contents { + if animate, let previousContents = strongSelf.contents, !wasSynchronous { strongSelf.contents = image let tempLayer = CALayer() tempLayer.contents = previousContents @@ -207,6 +208,7 @@ public class ImageNode: ASDisplayNode { } } })) + wasSynchronous = false } public override func clearContents() { diff --git a/submodules/Display/Source/InteractiveTransitionGestureRecognizer.swift b/submodules/Display/Source/InteractiveTransitionGestureRecognizer.swift index e0b2655301..7743e53dd4 100644 --- a/submodules/Display/Source/InteractiveTransitionGestureRecognizer.swift +++ b/submodules/Display/Source/InteractiveTransitionGestureRecognizer.swift @@ -49,6 +49,7 @@ public struct InteractiveTransitionGestureRecognizerDirections: OptionSet { public static let rightEdge = InteractiveTransitionGestureRecognizerDirections(rawValue: 1 << 3) public static let leftCenter = InteractiveTransitionGestureRecognizerDirections(rawValue: 1 << 0) public static let rightCenter = InteractiveTransitionGestureRecognizerDirections(rawValue: 1 << 1) + public static let down = InteractiveTransitionGestureRecognizerDirections(rawValue: 1 << 4) public static let left: InteractiveTransitionGestureRecognizerDirections = [.leftEdge, .leftCenter] public static let right: InteractiveTransitionGestureRecognizerDirections = [.rightEdge, .rightCenter] @@ -105,11 +106,14 @@ public class InteractiveTransitionGestureRecognizer: UIPanGestureRecognizer { let horizontalGestures = hasHorizontalGestures(target, point: self.view?.convert(self.firstLocation, to: target)) switch horizontalGestures { case .some, .strict: - if case .strict = horizontalGestures { - allowedDirections = [] - } else if allowedDirections.contains(.leftEdge) || allowedDirections.contains(.rightEdge) { - allowedDirections.remove(.leftCenter) - allowedDirections.remove(.rightCenter) + if allowedDirections.contains(.down) { + } else { + if case .strict = horizontalGestures { + allowedDirections = [] + } else if allowedDirections.contains(.leftEdge) || allowedDirections.contains(.rightEdge) { + allowedDirections.remove(.leftCenter) + allowedDirections.remove(.rightCenter) + } } case .none: break @@ -132,36 +136,46 @@ public class InteractiveTransitionGestureRecognizer: UIPanGestureRecognizer { let size = self.view?.bounds.size ?? CGSize() - let edgeWidth: CGFloat - switch self.edgeWidth { - case let .constant(value): - edgeWidth = value - case let .widthMultiplier(factor, minValue, maxValue): - edgeWidth = max(minValue, min(size.width * factor, maxValue)) - } - - if !self.validatedGesture { - if self.firstLocation.x < edgeWidth && !self.currentAllowedDirections.contains(.rightEdge) { - self.state = .failed - return + if self.currentAllowedDirections.contains(.down) { + if !self.validatedGesture { + if absTranslationX > 2.0 && absTranslationX > absTranslationY * 2.0 { + self.state = .failed + } else if absTranslationY > 2.0 && absTranslationX * 2.0 < absTranslationY { + self.validatedGesture = true + } } - if self.firstLocation.x > size.width - edgeWidth && !self.currentAllowedDirections.contains(.leftEdge) { - self.state = .failed - return + } else { + let edgeWidth: CGFloat + switch self.edgeWidth { + case let .constant(value): + edgeWidth = value + case let .widthMultiplier(factor, minValue, maxValue): + edgeWidth = max(minValue, min(size.width * factor, maxValue)) } - if self.currentAllowedDirections.contains(.rightEdge) && self.firstLocation.x < edgeWidth { - self.validatedGesture = true - } else if self.currentAllowedDirections.contains(.leftEdge) && self.firstLocation.x > size.width - edgeWidth { - self.validatedGesture = true - } else if !self.currentAllowedDirections.contains(.leftCenter) && translation.x < 0.0 { - self.state = .failed - } else if !self.currentAllowedDirections.contains(.rightCenter) && translation.x > 0.0 { - self.state = .failed - } else if absTranslationY > 2.0 && absTranslationY > absTranslationX * 2.0 { - self.state = .failed - } else if absTranslationX > 2.0 && absTranslationY * 2.0 < absTranslationX { - self.validatedGesture = true + if !self.validatedGesture { + if self.firstLocation.x < edgeWidth && !self.currentAllowedDirections.contains(.rightEdge) { + self.state = .failed + return + } + if self.firstLocation.x > size.width - edgeWidth && !self.currentAllowedDirections.contains(.leftEdge) { + self.state = .failed + return + } + + if self.currentAllowedDirections.contains(.rightEdge) && self.firstLocation.x < edgeWidth { + self.validatedGesture = true + } else if self.currentAllowedDirections.contains(.leftEdge) && self.firstLocation.x > size.width - edgeWidth { + self.validatedGesture = true + } else if !self.currentAllowedDirections.contains(.leftCenter) && translation.x < 0.0 { + self.state = .failed + } else if !self.currentAllowedDirections.contains(.rightCenter) && translation.x > 0.0 { + self.state = .failed + } else if absTranslationY > 2.0 && absTranslationY > absTranslationX * 2.0 { + self.state = .failed + } else if absTranslationX > 2.0 && absTranslationY * 2.0 < absTranslationX { + self.validatedGesture = true + } } } diff --git a/submodules/Display/Source/NavigationBar.swift b/submodules/Display/Source/NavigationBar.swift index daa31364b2..b43cdc2d90 100644 --- a/submodules/Display/Source/NavigationBar.swift +++ b/submodules/Display/Source/NavigationBar.swift @@ -297,7 +297,7 @@ open class BlurredBackgroundView: UIView { private var enableBlur: Bool - private var effectView: UIVisualEffectView? + public private(set) var effectView: UIVisualEffectView? private let backgroundView: UIView private var validLayout: (CGSize, CGFloat)? diff --git a/submodules/FeaturedStickersScreen/Sources/FeaturedStickersScreen.swift b/submodules/FeaturedStickersScreen/Sources/FeaturedStickersScreen.swift index 8ed57eea50..ddd732d64b 100644 --- a/submodules/FeaturedStickersScreen/Sources/FeaturedStickersScreen.swift +++ b/submodules/FeaturedStickersScreen/Sources/FeaturedStickersScreen.swift @@ -502,7 +502,7 @@ private final class FeaturedStickersScreenNode: ViewControllerTracingNode { |> deliverOnMainQueue).start(next: { result in switch result { case .generic: - strongSelf.controller?.presentInGlobalOverlay(UndoOverlayController(presentationData: strongSelf.presentationData, content: .sticker(context: strongSelf.context, file: item.file, title: nil, text: !isStarred ? strongSelf.presentationData.strings.Conversation_StickerAddedToFavorites : strongSelf.presentationData.strings.Conversation_StickerRemovedFromFavorites, undoText: nil, customAction: nil), elevatedLayout: false, action: { _ in return false }), with: nil) + strongSelf.controller?.presentInGlobalOverlay(UndoOverlayController(presentationData: strongSelf.presentationData, content: .sticker(context: strongSelf.context, file: item.file, loop: true, title: nil, text: !isStarred ? strongSelf.presentationData.strings.Conversation_StickerAddedToFavorites : strongSelf.presentationData.strings.Conversation_StickerRemovedFromFavorites, undoText: nil, customAction: nil), elevatedLayout: false, action: { _ in return false }), with: nil) case let .limitExceeded(limit, premiumLimit): let premiumConfiguration = PremiumConfiguration.with(appConfiguration: strongSelf.context.currentAppConfiguration.with { $0 }) let text: String @@ -511,7 +511,7 @@ private final class FeaturedStickersScreenNode: ViewControllerTracingNode { } else { text = strongSelf.presentationData.strings.Premium_MaxFavedStickersText("\(premiumLimit)").string } - strongSelf.controller?.presentInGlobalOverlay(UndoOverlayController(presentationData: strongSelf.presentationData, content: .sticker(context: strongSelf.context, file: item.file, title: strongSelf.presentationData.strings.Premium_MaxFavedStickersTitle("\(limit)").string, text: text, undoText: nil, customAction: nil), elevatedLayout: false, action: { [weak self] action in + strongSelf.controller?.presentInGlobalOverlay(UndoOverlayController(presentationData: strongSelf.presentationData, content: .sticker(context: strongSelf.context, file: item.file, loop: true, title: strongSelf.presentationData.strings.Premium_MaxFavedStickersTitle("\(limit)").string, text: text, undoText: nil, customAction: nil), elevatedLayout: false, action: { [weak self] action in if let strongSelf = self { if case .info = action { let controller = PremiumIntroScreen(context: strongSelf.context, source: .savedStickers) @@ -590,7 +590,7 @@ private final class FeaturedStickersScreenNode: ViewControllerTracingNode { |> deliverOnMainQueue).start(next: { result in switch result { case .generic: - strongSelf.controller?.presentInGlobalOverlay(UndoOverlayController(presentationData: strongSelf.presentationData, content: .sticker(context: strongSelf.context, file: item.file, title: nil, text: !isStarred ? strongSelf.presentationData.strings.Conversation_StickerAddedToFavorites : strongSelf.presentationData.strings.Conversation_StickerRemovedFromFavorites, undoText: nil, customAction: nil), elevatedLayout: false, action: { _ in return false }), with: nil) + strongSelf.controller?.presentInGlobalOverlay(UndoOverlayController(presentationData: strongSelf.presentationData, content: .sticker(context: strongSelf.context, file: item.file, loop: true, title: nil, text: !isStarred ? strongSelf.presentationData.strings.Conversation_StickerAddedToFavorites : strongSelf.presentationData.strings.Conversation_StickerRemovedFromFavorites, undoText: nil, customAction: nil), elevatedLayout: false, action: { _ in return false }), with: nil) case let .limitExceeded(limit, premiumLimit): let premiumConfiguration = PremiumConfiguration.with(appConfiguration: strongSelf.context.currentAppConfiguration.with { $0 }) let text: String @@ -599,7 +599,7 @@ private final class FeaturedStickersScreenNode: ViewControllerTracingNode { } else { text = strongSelf.presentationData.strings.Premium_MaxFavedStickersText("\(premiumLimit)").string } - strongSelf.controller?.presentInGlobalOverlay(UndoOverlayController(presentationData: strongSelf.presentationData, content: .sticker(context: strongSelf.context, file: item.file, title: strongSelf.presentationData.strings.Premium_MaxFavedStickersTitle("\(limit)").string, text: text, undoText: nil, customAction: nil), elevatedLayout: false, action: { [weak self] action in + strongSelf.controller?.presentInGlobalOverlay(UndoOverlayController(presentationData: strongSelf.presentationData, content: .sticker(context: strongSelf.context, file: item.file, loop: true, title: strongSelf.presentationData.strings.Premium_MaxFavedStickersTitle("\(limit)").string, text: text, undoText: nil, customAction: nil), elevatedLayout: false, action: { [weak self] action in if let strongSelf = self { if case .info = action { let controller = PremiumIntroScreen(context: strongSelf.context, source: .savedStickers) diff --git a/submodules/ListSectionHeaderNode/Sources/ListSectionHeaderNode.swift b/submodules/ListSectionHeaderNode/Sources/ListSectionHeaderNode.swift index bbd1942090..30c658bc32 100644 --- a/submodules/ListSectionHeaderNode/Sources/ListSectionHeaderNode.swift +++ b/submodules/ListSectionHeaderNode/Sources/ListSectionHeaderNode.swift @@ -4,7 +4,7 @@ import AsyncDisplayKit import Display import TelegramPresentationData -private let titleFont = Font.bold(13.0) +private let titleFont = Font.regular(13.0) private let actionFont = Font.regular(13.0) public enum ListSectionHeaderActionType { diff --git a/submodules/ReactionSelectionNode/Sources/ReactionContextBackgroundNode.swift b/submodules/ReactionSelectionNode/Sources/ReactionContextBackgroundNode.swift index 9583f3f2fd..8243efd2bb 100644 --- a/submodules/ReactionSelectionNode/Sources/ReactionContextBackgroundNode.swift +++ b/submodules/ReactionSelectionNode/Sources/ReactionContextBackgroundNode.swift @@ -119,6 +119,7 @@ final class ReactionContextBackgroundNode: ASDisplayNode { isLeftAligned: Bool, isMinimized: Bool, isCoveredByInput: Bool, + displayTail: Bool, transition: ContainedViewLayoutTransition ) { let shadowInset: CGFloat = 15.0 @@ -188,10 +189,10 @@ final class ReactionContextBackgroundNode: ASDisplayNode { transition.updateFrame(layer: self.backgroundShadowLayer, frame: backgroundFrame.insetBy(dx: -shadowInset, dy: -shadowInset), beginWithCurrentState: true) transition.updateFrame(layer: self.largeCircleShadowLayer, frame: largeCircleFrame.insetBy(dx: -shadowInset, dy: -shadowInset), beginWithCurrentState: true) - transition.updateAlpha(layer: self.largeCircleLayer, alpha: isCoveredByInput ? 0.0 : 1.0) - transition.updateAlpha(layer: self.largeCircleShadowLayer, alpha: isCoveredByInput ? 0.0 : 1.0) - transition.updateAlpha(layer: self.smallCircleLayer, alpha: isCoveredByInput ? 0.0 : 1.0) - transition.updateAlpha(layer: self.smallCircleShadowLayer, alpha: isCoveredByInput ? 0.0 : 1.0) + transition.updateAlpha(layer: self.largeCircleLayer, alpha: (isCoveredByInput || !displayTail) ? 0.0 : 1.0) + transition.updateAlpha(layer: self.largeCircleShadowLayer, alpha: (isCoveredByInput || !displayTail) ? 0.0 : 1.0) + transition.updateAlpha(layer: self.smallCircleLayer, alpha: (isCoveredByInput || !displayTail) ? 0.0 : 1.0) + transition.updateAlpha(layer: self.smallCircleShadowLayer, alpha: (isCoveredByInput || !displayTail) ? 0.0 : 1.0) transition.updateFrame(layer: self.smallCircleShadowLayer, frame: smallCircleFrame.insetBy(dx: -shadowInset, dy: -shadowInset), beginWithCurrentState: true) diff --git a/submodules/ReactionSelectionNode/Sources/ReactionContextNode.swift b/submodules/ReactionSelectionNode/Sources/ReactionContextNode.swift index 80e7983466..4df15d5933 100644 --- a/submodules/ReactionSelectionNode/Sources/ReactionContextNode.swift +++ b/submodules/ReactionSelectionNode/Sources/ReactionContextNode.swift @@ -232,7 +232,11 @@ public final class ReactionContextNode: ASDisplayNode, UIScrollViewDelegate { private weak var animationTargetView: UIView? private var animationHideNode: Bool = false + public var displayTail: Bool = true + private var didAnimateIn: Bool = false + public private(set) var isAnimatingOut: Bool = false + public private(set) var isAnimatingOutToReaction: Bool = false public var contentHeight: CGFloat { return self.currentContentHeight @@ -1179,6 +1183,7 @@ public final class ReactionContextNode: ASDisplayNode, UIScrollViewDelegate { isLeftAligned: isLeftAligned, isMinimized: self.highlightedReaction != nil && !self.highlightedByHover, isCoveredByInput: isCoveredByInput, + displayTail: self.displayTail, transition: transition ) @@ -1654,6 +1659,8 @@ public final class ReactionContextNode: ASDisplayNode, UIScrollViewDelegate { } public func animateOut(to targetAnchorRect: CGRect?, animatingOutToReaction: Bool) { + self.isAnimatingOut = true + self.backgroundNode.animateOut() for (_, itemNode) in self.visibleItemNodes { @@ -1760,6 +1767,8 @@ public final class ReactionContextNode: ASDisplayNode, UIScrollViewDelegate { } public func animateOutToReaction(value: MessageReaction.Reaction, targetView: UIView, hideNode: Bool, animateTargetContainer: UIView?, addStandaloneReactionAnimation: ((StandaloneReactionAnimation) -> Void)?, completion: @escaping () -> Void) { + self.isAnimatingOutToReaction = true + var foundItemNode: ReactionNode? for (_, itemNode) in self.visibleItemNodes { if let itemNode = itemNode as? ReactionNode, itemNode.item.reaction.rawValue == value { diff --git a/submodules/StickerPackPreviewUI/Sources/StickerPackScreen.swift b/submodules/StickerPackPreviewUI/Sources/StickerPackScreen.swift index 2124e1d528..a3f35c5eae 100644 --- a/submodules/StickerPackPreviewUI/Sources/StickerPackScreen.swift +++ b/submodules/StickerPackPreviewUI/Sources/StickerPackScreen.swift @@ -621,7 +621,7 @@ private final class StickerPackContainer: ASDisplayNode { let presentationData = context.sharedContext.currentPresentationData.with { $0 } - let undoController = UndoOverlayController(presentationData: presentationData, content: .sticker(context: context, file: file, title: nil, text: presentationData.strings.EmojiStatus_AppliedText, undoText: nil, customAction: nil), elevatedLayout: false, animateInAsReplacement: animateInAsReplacement, action: { _ in return false }) + let undoController = UndoOverlayController(presentationData: presentationData, content: .sticker(context: context, file: file, loop: true, title: nil, text: presentationData.strings.EmojiStatus_AppliedText, undoText: nil, customAction: nil), elevatedLayout: false, animateInAsReplacement: animateInAsReplacement, action: { _ in return false }) controller.present(undoController, in: .window(.root)) } let copyEmoji: (TelegramMediaFile) -> Void = { file in @@ -1998,7 +1998,7 @@ public final class StickerPackScreenImpl: ViewController { if let strongSelf = self, let file = attribute.file { let presentationData = strongSelf.context.sharedContext.currentPresentationData.with { $0 } - strongSelf.present(UndoOverlayController(presentationData: presentationData, content: .sticker(context: strongSelf.context, file: file, title: nil, text: presentationData.strings.Conversation_EmojiCopied, undoText: nil, customAction: nil), elevatedLayout: false, animateInAsReplacement: false, action: { _ in return false }), in: .current) + strongSelf.present(UndoOverlayController(presentationData: presentationData, content: .sticker(context: strongSelf.context, file: file, loop: true, title: nil, text: presentationData.strings.Conversation_EmojiCopied, undoText: nil, customAction: nil), elevatedLayout: false, animateInAsReplacement: false, action: { _ in return false }), in: .current) } })) diff --git a/submodules/TelegramCore/Sources/State/AccountStateManagementUtils.swift b/submodules/TelegramCore/Sources/State/AccountStateManagementUtils.swift index 8ded43c04c..1f1c05d624 100644 --- a/submodules/TelegramCore/Sources/State/AccountStateManagementUtils.swift +++ b/submodules/TelegramCore/Sources/State/AccountStateManagementUtils.swift @@ -4336,7 +4336,7 @@ func replayFinalState( switch storyItem { case let .storyItemDeleted(id): storyUpdates.append(InternalStoryUpdate.deleted(id)) - case let .storyItem(flags, id, date, _, _, media, _, recentViewers, viewCount): + case let .storyItem(flags, id, date, _, _, media, privacy, recentViewers, viewCount): let (parsedMedia, _, _, _) = textMediaAndExpirationTimerFromApiMedia(media, peerId) if let parsedMedia = parsedMedia { var seenPeers: [EnginePeer] = [] @@ -4347,13 +4347,46 @@ func replayFinalState( } } } + + var parsedPrivacy: EngineStoryPrivacy? + if let privacy = privacy { + var base: EngineStoryPrivacy.Base = .everyone + var additionalPeerIds: [EnginePeer.Id] = [] + for rule in privacy { + switch rule { + case .privacyValueAllowAll: + base = .everyone + case .privacyValueAllowContacts: + base = .contacts + case .privacyValueAllowCloseFriends: + base = .closeFriends + case let .privacyValueAllowUsers(users): + for id in users { + additionalPeerIds.append(EnginePeer.Id(namespace: Namespaces.Peer.CloudUser, id: EnginePeer.Id.Id._internalFromInt64Value(id))) + } + case let .privacyValueAllowChatParticipants(chats): + for id in chats { + if let peer = transaction.getPeer(EnginePeer.Id(namespace: Namespaces.Peer.CloudGroup, id: EnginePeer.Id.Id._internalFromInt64Value(id))) { + additionalPeerIds.append(peer.id) + } else if let peer = transaction.getPeer(EnginePeer.Id(namespace: Namespaces.Peer.CloudChannel, id: EnginePeer.Id.Id._internalFromInt64Value(id))) { + additionalPeerIds.append(peer.id) + } + } + default: + break + } + } + parsedPrivacy = EngineStoryPrivacy(base: base, additionallyIncludePeers: additionalPeerIds) + } + storyUpdates.append(InternalStoryUpdate.added(peerId: peerId, item: StoryListContext.Item( id: id, timestamp: date, media: EngineMedia(parsedMedia), isSeen: (flags & (1 << 4)) == 0, seenCount: viewCount.flatMap(Int.init) ?? 0, - seenPeers: seenPeers + seenPeers: seenPeers, + privacy: parsedPrivacy ))) } } diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Messages/Stories.swift b/submodules/TelegramCore/Sources/TelegramEngine/Messages/Stories.swift index d3cb5d58a6..69df816aba 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Messages/Stories.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Messages/Stories.swift @@ -7,7 +7,23 @@ public enum EngineStoryInputMedia { case image(dimensions: PixelDimensions, data: Data) } -func _internal_uploadStory(account: Account, media: EngineStoryInputMedia) -> Signal { +public struct EngineStoryPrivacy: Equatable { + public enum Base { + case everyone + case contacts + case closeFriends + } + + public var base: Base + public var additionallyIncludePeers: [EnginePeer.Id] + + public init(base: Base, additionallyIncludePeers: [EnginePeer.Id]) { + self.base = base + self.additionallyIncludePeers = additionallyIncludePeers + } +} + +func _internal_uploadStory(account: Account, media: EngineStoryInputMedia, privacy: EngineStoryPrivacy) -> Signal { switch media { case let .image(dimensions, data): let resource = LocalFileMediaResource(fileId: Int64.random(in: Int64.min ... Int64.max)) @@ -51,47 +67,79 @@ func _internal_uploadStory(account: Account, media: EngineStoryInputMedia) -> Si return .single(nil) } |> mapToSignal { result -> Signal in - switch result { - case let .content(content): - switch content.content { - case let .media(inputMedia, _): - return account.network.request(Api.functions.stories.sendStory(flags: 0, media: inputMedia, caption: nil, entities: nil, privacyRules: [.inputPrivacyValueAllowAll])) - |> map(Optional.init) - |> `catch` { _ -> Signal in - return .single(nil) + return account.postbox.transaction { transaction -> Signal in + var privacyRules: [Api.InputPrivacyRule] + switch privacy.base { + case .everyone: + privacyRules = [.inputPrivacyValueAllowAll] + case .contacts: + privacyRules = [.inputPrivacyValueAllowContacts] + case .closeFriends: + privacyRules = [.inputPrivacyValueAllowCloseFriends] + } + var privacyUsers: [Api.InputUser] = [] + var privacyChats: [Int64] = [] + for peerId in privacy.additionallyIncludePeers { + if let peer = transaction.getPeer(peerId) { + if let _ = peer as? TelegramUser { + if let inputUser = apiInputUser(peer) { + privacyUsers.append(inputUser) + } + } else if peer is TelegramGroup || peer is TelegramChannel { + privacyChats.append(peer.id.id._internalGetInt64Value()) + } } - |> mapToSignal { updates -> Signal in - if let updates = updates { - for update in updates.allUpdates { - if case let .updateStories(stories) = update { - switch stories { - case .userStories(let userId, let apiStories), .userStoriesShort(let userId, let apiStories, _): - if PeerId(namespace: Namespaces.Peer.CloudUser, id: PeerId.Id._internalFromInt64Value(userId)) == account.peerId, apiStories.count == 1 { - switch apiStories[0] { - case let .storyItem(_, _, _, _, _, media, _, _, _): - let (parsedMedia, _, _, _) = textMediaAndExpirationTimerFromApiMedia(media, account.peerId) - if let parsedMedia = parsedMedia { - applyMediaResourceChanges(from: imageMedia, to: parsedMedia, postbox: account.postbox, force: false) + } + if !privacyUsers.isEmpty { + privacyRules.append(.inputPrivacyValueAllowUsers(users: privacyUsers)) + } + if !privacyChats.isEmpty { + privacyRules.append(.inputPrivacyValueAllowChatParticipants(chats: privacyChats)) + } + + switch result { + case let .content(content): + switch content.content { + case let .media(inputMedia, _): + return account.network.request(Api.functions.stories.sendStory(flags: 0, media: inputMedia, caption: nil, entities: nil, privacyRules: privacyRules)) + |> map(Optional.init) + |> `catch` { _ -> Signal in + return .single(nil) + } + |> mapToSignal { updates -> Signal in + if let updates = updates { + for update in updates.allUpdates { + if case let .updateStories(stories) = update { + switch stories { + case .userStories(let userId, let apiStories), .userStoriesShort(let userId, let apiStories, _): + if PeerId(namespace: Namespaces.Peer.CloudUser, id: PeerId.Id._internalFromInt64Value(userId)) == account.peerId, apiStories.count == 1 { + switch apiStories[0] { + case let .storyItem(_, _, _, _, _, media, _, _, _): + let (parsedMedia, _, _, _) = textMediaAndExpirationTimerFromApiMedia(media, account.peerId) + if let parsedMedia = parsedMedia { + applyMediaResourceChanges(from: imageMedia, to: parsedMedia, postbox: account.postbox, force: false) + } + default: + break } - default: - break } } } } + + account.stateManager.addUpdates(updates) } - account.stateManager.addUpdates(updates) + return .complete() } - + default: return .complete() } default: return .complete() } - default: - return .complete() } + |> switchToLatest } } } diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Messages/StoryListContext.swift b/submodules/TelegramCore/Sources/TelegramEngine/Messages/StoryListContext.swift index fc30dfa5d5..b4623bc03d 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Messages/StoryListContext.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Messages/StoryListContext.swift @@ -22,14 +22,16 @@ public final class StoryListContext { public let isSeen: Bool public let seenCount: Int public let seenPeers: [EnginePeer] + public let privacy: EngineStoryPrivacy? - public init(id: Int64, timestamp: Int32, media: EngineMedia, isSeen: Bool, seenCount: Int, seenPeers: [EnginePeer]) { + public init(id: Int64, timestamp: Int32, media: EngineMedia, isSeen: Bool, seenCount: Int, seenPeers: [EnginePeer], privacy: EngineStoryPrivacy?) { self.id = id self.timestamp = timestamp self.media = media self.isSeen = isSeen self.seenCount = seenCount self.seenPeers = seenPeers + self.privacy = privacy } public static func ==(lhs: Item, rhs: Item) -> Bool { @@ -51,6 +53,9 @@ public final class StoryListContext { if lhs.seenPeers != rhs.seenPeers { return false } + if lhs.privacy != rhs.privacy { + return false + } return true } } @@ -228,7 +233,8 @@ public final class StoryListContext { media: item.media, isSeen: true, seenCount: item.seenCount, - seenPeers: item.seenPeers + seenPeers: item.seenPeers, + privacy: item.privacy ) itemSets[i] = PeerItemSet( peerId: itemSets[i].peerId, @@ -307,7 +313,7 @@ public final class StoryListContext { for apiStory in apiStories { switch apiStory { - case let .storyItem(flags, id, date, _, _, media, _, recentViewers, viewCount): + case let .storyItem(flags, id, date, _, _, media, privacy, recentViewers, viewCount): let (parsedMedia, _, _, _) = textMediaAndExpirationTimerFromApiMedia(media, peerId) if let parsedMedia = parsedMedia { var seenPeers: [EnginePeer] = [] @@ -318,13 +324,46 @@ public final class StoryListContext { } } } + + var parsedPrivacy: EngineStoryPrivacy? + if let privacy = privacy { + var base: EngineStoryPrivacy.Base = .everyone + var additionalPeerIds: [EnginePeer.Id] = [] + for rule in privacy { + switch rule { + case .privacyValueAllowAll: + base = .everyone + case .privacyValueAllowContacts: + base = .contacts + case .privacyValueAllowCloseFriends: + base = .closeFriends + case let .privacyValueAllowUsers(users): + for id in users { + additionalPeerIds.append(EnginePeer.Id(namespace: Namespaces.Peer.CloudUser, id: EnginePeer.Id.Id._internalFromInt64Value(id))) + } + case let .privacyValueAllowChatParticipants(chats): + for id in chats { + if let peer = transaction.getPeer(EnginePeer.Id(namespace: Namespaces.Peer.CloudGroup, id: EnginePeer.Id.Id._internalFromInt64Value(id))) { + additionalPeerIds.append(peer.id) + } else if let peer = transaction.getPeer(EnginePeer.Id(namespace: Namespaces.Peer.CloudChannel, id: EnginePeer.Id.Id._internalFromInt64Value(id))) { + additionalPeerIds.append(peer.id) + } + } + default: + break + } + } + parsedPrivacy = EngineStoryPrivacy(base: base, additionallyIncludePeers: additionalPeerIds) + } + let item = StoryListContext.Item( id: id, timestamp: date, media: EngineMedia(parsedMedia), isSeen: (flags & (1 << 4)) == 0, seenCount: viewCount.flatMap(Int.init) ?? 0, - seenPeers: seenPeers + seenPeers: seenPeers, + privacy: parsedPrivacy ) if !parsedItemSets.isEmpty && parsedItemSets[parsedItemSets.count - 1].peerId == peerId { parsedItemSets[parsedItemSets.count - 1].items.append(item) @@ -425,7 +464,7 @@ public final class StoryListContext { let peerId = PeerId(namespace: Namespaces.Peer.CloudUser, id: PeerId.Id._internalFromInt64Value(apiUserId)) for apiStory in apiStories { switch apiStory { - case let .storyItem(flags, id, date, _, _, media, _, recentViewers, viewCount): + case let .storyItem(flags, id, date, _, _, media, privacy, recentViewers, viewCount): let (parsedMedia, _, _, _) = textMediaAndExpirationTimerFromApiMedia(media, peerId) if let parsedMedia = parsedMedia { var seenPeers: [EnginePeer] = [] @@ -436,13 +475,46 @@ public final class StoryListContext { } } } + + var parsedPrivacy: EngineStoryPrivacy? + if let privacy = privacy { + var base: EngineStoryPrivacy.Base = .everyone + var additionalPeerIds: [EnginePeer.Id] = [] + for rule in privacy { + switch rule { + case .privacyValueAllowAll: + base = .everyone + case .privacyValueAllowContacts: + base = .contacts + case .privacyValueAllowCloseFriends: + base = .closeFriends + case let .privacyValueAllowUsers(users): + for id in users { + additionalPeerIds.append(EnginePeer.Id(namespace: Namespaces.Peer.CloudUser, id: EnginePeer.Id.Id._internalFromInt64Value(id))) + } + case let .privacyValueAllowChatParticipants(chats): + for id in chats { + if let peer = transaction.getPeer(EnginePeer.Id(namespace: Namespaces.Peer.CloudGroup, id: EnginePeer.Id.Id._internalFromInt64Value(id))) { + additionalPeerIds.append(peer.id) + } else if let peer = transaction.getPeer(EnginePeer.Id(namespace: Namespaces.Peer.CloudChannel, id: EnginePeer.Id.Id._internalFromInt64Value(id))) { + additionalPeerIds.append(peer.id) + } + } + default: + break + } + } + parsedPrivacy = EngineStoryPrivacy(base: base, additionallyIncludePeers: additionalPeerIds) + } + let item = StoryListContext.Item( id: id, timestamp: date, media: EngineMedia(parsedMedia), isSeen: (flags & (1 << 4)) == 0, seenCount: viewCount.flatMap(Int.init) ?? 0, - seenPeers: seenPeers + seenPeers: seenPeers, + privacy: parsedPrivacy ) if !parsedItemSets.isEmpty && parsedItemSets[parsedItemSets.count - 1].peerId == peerId { parsedItemSets[parsedItemSets.count - 1].items.append(item) diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift b/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift index 0a6f004126..d7f233feb1 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift @@ -577,8 +577,8 @@ public extension TelegramEngine { return StoryListContext(account: self.account, scope: .peer(id)) } - public func uploadStory(media: EngineStoryInputMedia) -> Signal { - return _internal_uploadStory(account: self.account, media: media) + public func uploadStory(media: EngineStoryInputMedia, privacy: EngineStoryPrivacy) -> Signal { + return _internal_uploadStory(account: self.account, media: media, privacy: privacy) } public func deleteStory(id: Int64) -> Signal { diff --git a/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/Sources/ChatEntityKeyboardInputNode.swift b/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/Sources/ChatEntityKeyboardInputNode.swift index 3ddeb72b0b..aefd014290 100644 --- a/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/Sources/ChatEntityKeyboardInputNode.swift +++ b/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/Sources/ChatEntityKeyboardInputNode.swift @@ -657,7 +657,7 @@ public final class ChatEntityKeyboardInputNode: ChatInputNode { let presentationData = context.sharedContext.currentPresentationData.with { $0 } - let controller = UndoOverlayController(presentationData: presentationData, content: .sticker(context: context, file: file, title: nil, text: presentationData.strings.EmojiStatus_AppliedText, undoText: nil, customAction: nil), elevatedLayout: false, animateInAsReplacement: animateInAsReplacement, action: { _ in return false }) + let controller = UndoOverlayController(presentationData: presentationData, content: .sticker(context: context, file: file, loop: true, title: nil, text: presentationData.strings.EmojiStatus_AppliedText, undoText: nil, customAction: nil), elevatedLayout: false, animateInAsReplacement: animateInAsReplacement, action: { _ in return false }) strongSelf.currentUndoOverlayController = controller controllerInteraction.presentController(controller, nil) }, @@ -692,7 +692,7 @@ public final class ChatEntityKeyboardInputNode: ChatInputNode { let presentationData = context.sharedContext.currentPresentationData.with { $0 } - let controller = UndoOverlayController(presentationData: presentationData, content: .sticker(context: context, file: file, title: nil, text: presentationData.strings.EmojiPreview_CopyEmoji, undoText: nil, customAction: nil), elevatedLayout: false, animateInAsReplacement: animateInAsReplacement, action: { _ in return false }) + let controller = UndoOverlayController(presentationData: presentationData, content: .sticker(context: context, file: file, loop: true, title: nil, text: presentationData.strings.EmojiPreview_CopyEmoji, undoText: nil, customAction: nil), elevatedLayout: false, animateInAsReplacement: animateInAsReplacement, action: { _ in return false }) strongSelf.currentUndoOverlayController = controller controllerInteraction.presentController(controller, nil) } @@ -831,7 +831,7 @@ public final class ChatEntityKeyboardInputNode: ChatInputNode { actionTitle = presentationData.strings.EmojiInput_PremiumEmojiToast_Action } - let controller = UndoOverlayController(presentationData: presentationData, content: .sticker(context: context, file: file, title: nil, text: text, undoText: actionTitle, customAction: { [weak controllerInteraction] in + let controller = UndoOverlayController(presentationData: presentationData, content: .sticker(context: context, file: file, loop: true, title: nil, text: text, undoText: actionTitle, customAction: { [weak controllerInteraction] in guard let controllerInteraction = controllerInteraction else { return } @@ -2321,7 +2321,7 @@ public final class EntityInputView: UIInputView, AttachmentTextInputPanelInputVi if file.isPremiumEmoji && !hasPremium { let presentationData = context.sharedContext.currentPresentationData.with { $0 } - strongSelf.presentController?(UndoOverlayController(presentationData: presentationData, content: .sticker(context: context, file: file, title: nil, text: presentationData.strings.EmojiInput_PremiumEmojiToast_Text, undoText: presentationData.strings.EmojiInput_PremiumEmojiToast_Action, customAction: { + strongSelf.presentController?(UndoOverlayController(presentationData: presentationData, content: .sticker(context: context, file: file, loop: true, title: nil, text: presentationData.strings.EmojiInput_PremiumEmojiToast_Text, undoText: presentationData.strings.EmojiInput_PremiumEmojiToast_Action, customAction: { guard let strongSelf = self else { return } @@ -2765,7 +2765,7 @@ public final class EmojiContentPeekBehaviorImpl: EmojiContentPeekBehavior { |> deliverOnMainQueue).start(next: { result in switch result { case .generic: - interaction.presentGlobalOverlayController(UndoOverlayController(presentationData: presentationData, content: .sticker(context: context, file: file, title: nil, text: !isStarred ? presentationData.strings.Conversation_StickerAddedToFavorites : presentationData.strings.Conversation_StickerRemovedFromFavorites, undoText: nil, customAction: nil), elevatedLayout: false, action: { _ in return false }), nil) + interaction.presentGlobalOverlayController(UndoOverlayController(presentationData: presentationData, content: .sticker(context: context, file: file, loop: true, title: nil, text: !isStarred ? presentationData.strings.Conversation_StickerAddedToFavorites : presentationData.strings.Conversation_StickerRemovedFromFavorites, undoText: nil, customAction: nil), elevatedLayout: false, action: { _ in return false }), nil) case let .limitExceeded(limit, premiumLimit): let premiumConfiguration = PremiumConfiguration.with(appConfiguration: context.currentAppConfiguration.with { $0 }) let text: String @@ -2774,7 +2774,7 @@ public final class EmojiContentPeekBehaviorImpl: EmojiContentPeekBehavior { } else { text = presentationData.strings.Premium_MaxFavedStickersText("\(premiumLimit)").string } - interaction.presentGlobalOverlayController(UndoOverlayController(presentationData: presentationData, content: .sticker(context: context, file: file, title: presentationData.strings.Premium_MaxFavedStickersTitle("\(limit)").string, text: text, undoText: nil, customAction: nil), elevatedLayout: false, action: { action in + interaction.presentGlobalOverlayController(UndoOverlayController(presentationData: presentationData, content: .sticker(context: context, file: file, loop: true, title: presentationData.strings.Premium_MaxFavedStickersTitle("\(limit)").string, text: text, undoText: nil, customAction: nil), elevatedLayout: false, action: { action in if case .info = action { let controller = PremiumIntroScreen(context: context, source: .savedStickers) interaction.navigationController()?.pushViewController(controller) diff --git a/submodules/TelegramUI/Components/MessageInputPanelComponent/Sources/MediaRecordingPanelComponent.swift b/submodules/TelegramUI/Components/MessageInputPanelComponent/Sources/MediaRecordingPanelComponent.swift index a1ebef11fb..752908bbc2 100644 --- a/submodules/TelegramUI/Components/MessageInputPanelComponent/Sources/MediaRecordingPanelComponent.swift +++ b/submodules/TelegramUI/Components/MessageInputPanelComponent/Sources/MediaRecordingPanelComponent.swift @@ -16,15 +16,18 @@ public final class MediaRecordingPanelComponent: Component { public let audioRecorder: ManagedAudioRecorder? public let videoRecordingStatus: InstantVideoControllerRecordingStatus? public let cancelFraction: CGFloat + public let insets: UIEdgeInsets public init( audioRecorder: ManagedAudioRecorder?, videoRecordingStatus: InstantVideoControllerRecordingStatus?, - cancelFraction: CGFloat + cancelFraction: CGFloat, + insets: UIEdgeInsets ) { self.audioRecorder = audioRecorder self.videoRecordingStatus = videoRecordingStatus self.cancelFraction = cancelFraction + self.insets = insets } public static func ==(lhs: MediaRecordingPanelComponent, rhs: MediaRecordingPanelComponent) -> Bool { @@ -37,6 +40,9 @@ public final class MediaRecordingPanelComponent: Component { if lhs.cancelFraction != rhs.cancelFraction { return false } + if lhs.insets != rhs.insets { + return false + } return true } @@ -234,7 +240,7 @@ public final class MediaRecordingPanelComponent: Component { if indicatorView.superview == nil { self.addSubview(indicatorView) } - transition.setFrame(view: indicatorView, frame: CGRect(origin: CGPoint(x: 3.0, y: floor((availableSize.height - indicatorSize.height) * 0.5)), size: indicatorSize)) + transition.setFrame(view: indicatorView, frame: CGRect(origin: CGPoint(x: 3.0, y: component.insets.top + floor((availableSize.height - component.insets.top - component.insets.bottom - indicatorSize.height) * 0.5)), size: indicatorSize)) } let timerTextSize = self.timerText.update( @@ -248,7 +254,7 @@ public final class MediaRecordingPanelComponent: Component { self.addSubview(timerTextView) timerTextView.layer.anchorPoint = CGPoint(x: 0.0, y: 0.5) } - let timerTextFrame = CGRect(origin: CGPoint(x: 38.0, y: floor((availableSize.height - timerTextSize.height) * 0.5)), size: timerTextSize) + let timerTextFrame = CGRect(origin: CGPoint(x: 38.0, y: component.insets.top + floor((availableSize.height - component.insets.top - component.insets.bottom - timerTextSize.height) * 0.5)), size: timerTextSize) transition.setPosition(view: timerTextView, position: CGPoint(x: timerTextFrame.minX, y: timerTextFrame.midY)) timerTextView.bounds = CGRect(origin: CGPoint(), size: timerTextFrame.size) } @@ -266,7 +272,7 @@ public final class MediaRecordingPanelComponent: Component { containerSize: CGSize(width: max(30.0, availableSize.width - 100.0), height: 44.0) ) - var textFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - cancelTextSize.width) * 0.5), y: floor((availableSize.height - cancelTextSize.height) * 0.5)), size: cancelTextSize) + var textFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - cancelTextSize.width) * 0.5), y: component.insets.top + floor((availableSize.height - component.insets.top - component.insets.bottom - cancelTextSize.height) * 0.5)), size: cancelTextSize) let bandingStart: CGFloat = 0.0 let bandedOffset = abs(component.cancelFraction) - bandingStart diff --git a/submodules/TelegramUI/Components/MessageInputPanelComponent/Sources/MessageInputPanelComponent.swift b/submodules/TelegramUI/Components/MessageInputPanelComponent/Sources/MessageInputPanelComponent.swift index 6e596d91e2..61777363d4 100644 --- a/submodules/TelegramUI/Components/MessageInputPanelComponent/Sources/MessageInputPanelComponent.swift +++ b/submodules/TelegramUI/Components/MessageInputPanelComponent/Sources/MessageInputPanelComponent.swift @@ -29,6 +29,8 @@ public final class MessageInputPanelComponent: Component { public let reactionAction: (UIView) -> Void public let audioRecorder: ManagedAudioRecorder? public let videoRecordingStatus: InstantVideoControllerRecordingStatus? + public let displayGradient: Bool + public let bottomInset: CGFloat public init( externalState: ExternalState, @@ -41,7 +43,9 @@ public final class MessageInputPanelComponent: Component { attachmentAction: @escaping () -> Void, reactionAction: @escaping (UIView) -> Void, audioRecorder: ManagedAudioRecorder?, - videoRecordingStatus: InstantVideoControllerRecordingStatus? + videoRecordingStatus: InstantVideoControllerRecordingStatus?, + displayGradient: Bool, + bottomInset: CGFloat ) { self.externalState = externalState self.context = context @@ -54,6 +58,8 @@ public final class MessageInputPanelComponent: Component { self.reactionAction = reactionAction self.audioRecorder = audioRecorder self.videoRecordingStatus = videoRecordingStatus + self.displayGradient = displayGradient + self.bottomInset = bottomInset } public static func ==(lhs: MessageInputPanelComponent, rhs: MessageInputPanelComponent) -> Bool { @@ -75,6 +81,12 @@ public final class MessageInputPanelComponent: Component { if lhs.videoRecordingStatus !== rhs.videoRecordingStatus { return false } + if lhs.displayGradient != rhs.displayGradient { + return false + } + if lhs.bottomInset != rhs.bottomInset { + return false + } return true } @@ -83,7 +95,12 @@ public final class MessageInputPanelComponent: Component { } public final class View: UIView { - private let fieldBackgroundView: UIImageView + private let fieldBackgroundView: BlurredBackgroundView + private let vibrancyEffectView: UIVisualEffectView + private let gradientView: UIImageView + private let bottomGradientView: UIView + + private let placeholder = ComponentView() private let textField = ComponentView() private let textFieldExternalState = TextFieldComponent.ExternalState() @@ -103,10 +120,22 @@ public final class MessageInputPanelComponent: Component { private weak var state: EmptyComponentState? override init(frame: CGRect) { - self.fieldBackgroundView = UIImageView() + self.fieldBackgroundView = BlurredBackgroundView(color: UIColor(white: 0.0, alpha: 0.5), enableBlur: true) + + let style: UIBlurEffect.Style = .dark + let blurEffect = UIBlurEffect(style: style) + let vibrancyEffect = UIVibrancyEffect(blurEffect: blurEffect) + let vibrancyEffectView = UIVisualEffectView(effect: vibrancyEffect) + self.vibrancyEffectView = vibrancyEffectView + + self.gradientView = UIImageView() + self.bottomGradientView = UIView() super.init(frame: frame) + self.addSubview(self.bottomGradientView) + self.addSubview(self.gradientView) + self.fieldBackgroundView.addSubview(self.vibrancyEffectView) self.addSubview(self.fieldBackgroundView) } @@ -136,15 +165,41 @@ public final class MessageInputPanelComponent: Component { } func update(component: MessageInputPanelComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { - let baseHeight: CGFloat = 44.0 - let insets = UIEdgeInsets(top: 5.0, left: 41.0, bottom: 5.0, right: 41.0) - let fieldCornerRadius: CGFloat = 16.0 + let insets = UIEdgeInsets(top: 14.0, left: 50.0, bottom: 6.0, right: 50.0) + let baseFieldHeight: CGFloat = 40.0 self.component = component self.state = state - if self.fieldBackgroundView.image == nil { - self.fieldBackgroundView.image = generateStretchableFilledCircleImage(diameter: fieldCornerRadius * 2.0, color: nil, strokeColor: UIColor(white: 1.0, alpha: 0.16), strokeWidth: 1.0, backgroundColor: nil) + let hasMediaRecording = component.audioRecorder != nil || component.videoRecordingStatus != nil + + let topGradientHeight: CGFloat = 32.0 + if self.gradientView.image == nil { + let baseAlpha: CGFloat = 0.7 + + self.gradientView.image = generateImage(CGSize(width: insets.left + insets.right + baseFieldHeight, height: topGradientHeight + insets.top + baseFieldHeight + insets.bottom), rotatedContext: { size, context in + context.clear(CGRect(origin: CGPoint(), size: size)) + + var locations: [CGFloat] = [] + var colors: [CGColor] = [] + let numStops = 10 + for i in 0 ..< numStops { + let step = 1.0 - CGFloat(i) / CGFloat(numStops - 1) + locations.append((1.0 - step)) + let alphaStep: CGFloat = pow(step, 1.5) + colors.append(UIColor.black.withAlphaComponent(alphaStep * baseAlpha).cgColor) + } + + if let gradient = CGGradient(colorsSpace: context.colorSpace, colors: colors as CFArray, locations: &locations) { + context.drawLinearGradient(gradient, start: CGPoint(x: 0.0, y: size.height), end: CGPoint(x: 0.0, y: 0.0), options: CGGradientDrawingOptions()) + } + + context.setBlendMode(.copy) + context.setFillColor(UIColor.clear.cgColor) + context.fillEllipse(in: CGRect(origin: CGPoint(x: insets.left, y: topGradientHeight + insets.top), size: CGSize(width: baseFieldHeight, height: baseFieldHeight)).insetBy(dx: 3.0, dy: 3.0)) + })?.resizableImage(withCapInsets: UIEdgeInsets(top: topGradientHeight + insets.top + baseFieldHeight * 0.5, left: insets.left + baseFieldHeight * 0.5, bottom: insets.bottom + baseFieldHeight * 0.5, right: insets.right + baseFieldHeight * 0.5)) + + self.bottomGradientView.backgroundColor = UIColor.black.withAlphaComponent(baseAlpha) } let availableTextFieldSize = CGSize(width: availableSize.width - insets.left - insets.right, height: availableSize.height - insets.top - insets.bottom) @@ -154,17 +209,45 @@ public final class MessageInputPanelComponent: Component { transition: .immediate, component: AnyComponent(TextFieldComponent( externalState: self.textFieldExternalState, - placeholder: "Reply Privately..." + placeholder: "" + )), + environment: {}, + containerSize: availableTextFieldSize + ) + + let placeholderSize = self.placeholder.update( + transition: .immediate, + component: AnyComponent(Text( + text: "Reply Privately", + font: Font.regular(17.0), + color: .white )), environment: {}, containerSize: availableTextFieldSize ) let fieldFrame = CGRect(origin: CGPoint(x: insets.left, y: insets.top), size: CGSize(width: availableSize.width - insets.left - insets.right, height: textFieldSize.height)) - transition.setFrame(view: self.fieldBackgroundView, frame: fieldFrame) - transition.setAlpha(view: self.fieldBackgroundView, alpha: (component.audioRecorder != nil || component.videoRecordingStatus != nil) ? 0.0 : 1.0) + transition.setFrame(view: self.vibrancyEffectView, frame: CGRect(origin: CGPoint(), size: fieldFrame.size)) + transition.setAlpha(view: self.vibrancyEffectView, alpha: (component.audioRecorder != nil || component.videoRecordingStatus != nil) ? 0.0 : 1.0) - //let rightFieldInset: CGFloat = 34.0 + transition.setFrame(view: self.fieldBackgroundView, frame: fieldFrame) + self.fieldBackgroundView.update(size: fieldFrame.size, cornerRadius: baseFieldHeight * 0.5, transition: transition.containedViewLayoutTransition) + + let gradientFrame = CGRect(origin: CGPoint(x: 0.0, y: -topGradientHeight), size: CGSize(width: availableSize.width, height: topGradientHeight + fieldFrame.maxY + insets.bottom)) + transition.setFrame(view: self.gradientView, frame: gradientFrame) + transition.setFrame(view: self.bottomGradientView, frame: CGRect(origin: CGPoint(x: 0.0, y: gradientFrame.maxY), size: CGSize(width: availableSize.width, height: component.bottomInset))) + transition.setAlpha(view: self.gradientView, alpha: component.displayGradient ? 1.0 : 0.0) + transition.setAlpha(view: self.bottomGradientView, alpha: component.displayGradient ? 1.0 : 0.0) + + let placeholderFrame = CGRect(origin: CGPoint(x: 16.0, y: floor((fieldFrame.height - placeholderSize.height) * 0.5)), size: placeholderSize) + if let placeholderView = self.placeholder.view { + if placeholderView.superview == nil { + placeholderView.layer.anchorPoint = CGPoint() + self.vibrancyEffectView.contentView.addSubview(placeholderView) + } + transition.setPosition(view: placeholderView, position: placeholderFrame.origin) + placeholderView.bounds = CGRect(origin: CGPoint(), size: placeholderFrame.size) + } let size = CGSize(width: availableSize.width, height: textFieldSize.height + insets.top + insets.bottom) @@ -189,15 +272,15 @@ public final class MessageInputPanelComponent: Component { } self.component?.attachmentAction() } - ).minSize(CGSize(width: 41.0, height: baseHeight))), + ).minSize(CGSize(width: 41.0, height: baseFieldHeight))), environment: {}, - containerSize: CGSize(width: 41.0, height: baseHeight) + containerSize: CGSize(width: 41.0, height: baseFieldHeight) ) if let attachmentButtonView = self.attachmentButton.view { if attachmentButtonView.superview == nil { self.addSubview(attachmentButtonView) } - transition.setFrame(view: attachmentButtonView, frame: CGRect(origin: CGPoint(x: floor((insets.left - attachmentButtonSize.width) * 0.5), y: size.height - baseHeight + floor((baseHeight - attachmentButtonSize.height) * 0.5)), size: attachmentButtonSize)) + transition.setFrame(view: attachmentButtonView, frame: CGRect(origin: CGPoint(x: floor((insets.left - attachmentButtonSize.width) * 0.5), y: size.height - insets.bottom - baseFieldHeight + floor((baseFieldHeight - attachmentButtonSize.height) * 0.5)), size: attachmentButtonSize)) } let inputActionButtonSize = self.inputActionButton.update( @@ -251,7 +334,7 @@ public final class MessageInputPanelComponent: Component { if inputActionButtonView.superview == nil { self.addSubview(inputActionButtonView) } - transition.setFrame(view: inputActionButtonView, frame: CGRect(origin: CGPoint(x: size.width - insets.right + floorToScreenPixels((insets.right - inputActionButtonSize.width) * 0.5), y: size.height - baseHeight + floorToScreenPixels((baseHeight - inputActionButtonSize.height) * 0.5)), size: inputActionButtonSize)) + transition.setFrame(view: inputActionButtonView, frame: CGRect(origin: CGPoint(x: size.width - insets.right + floorToScreenPixels((insets.right - inputActionButtonSize.width) * 0.5), y: size.height - insets.bottom - baseFieldHeight + floorToScreenPixels((baseFieldHeight - inputActionButtonSize.height) * 0.5)), size: inputActionButtonSize)) } var fieldIconNextX = fieldFrame.maxX - 2.0 let stickerButtonSize = self.stickerButton.update( @@ -279,7 +362,7 @@ public final class MessageInputPanelComponent: Component { transition.setPosition(view: stickerButtonView, position: stickerIconFrame.center) transition.setBounds(view: stickerButtonView, bounds: CGRect(origin: CGPoint(), size: stickerIconFrame.size)) - transition.setAlpha(view: stickerButtonView, alpha: self.textFieldExternalState.hasText ? 0.0 : 1.0) + transition.setAlpha(view: stickerButtonView, alpha: (self.textFieldExternalState.hasText || hasMediaRecording) ? 0.0 : 1.0) transition.setScale(view: stickerButtonView, scale: self.textFieldExternalState.hasText ? 0.1 : 1.0) fieldIconNextX -= stickerButtonSize.width + 2.0 @@ -310,22 +393,17 @@ public final class MessageInputPanelComponent: Component { transition.setPosition(view: reactionButtonView, position: reactionIconFrame.center) transition.setBounds(view: reactionButtonView, bounds: CGRect(origin: CGPoint(), size: reactionIconFrame.size)) - transition.setAlpha(view: reactionButtonView, alpha: self.textFieldExternalState.hasText ? 0.0 : 1.0) + transition.setAlpha(view: reactionButtonView, alpha: (self.textFieldExternalState.hasText || hasMediaRecording) ? 0.0 : 1.0) transition.setScale(view: reactionButtonView, scale: self.textFieldExternalState.hasText ? 0.1 : 1.0) fieldIconNextX -= reactionButtonSize.width + 2.0 } - /*if let image = self.reactionIconView.image { - let stickerIconFrame = CGRect(origin: CGPoint(x: fieldIconNextX - image.size.width, y: fieldFrame.minY + floor((fieldFrame.height - image.size.height) * 0.5)), size: image.size) - transition.setPosition(view: self.reactionIconView, position: stickerIconFrame.center) - transition.setBounds(view: self.reactionIconView, bounds: CGRect(origin: CGPoint(), size: stickerIconFrame.size)) - - transition.setAlpha(view: self.reactionIconView, alpha: self.textFieldExternalState.hasText ? 0.0 : 1.0) - transition.setScale(view: self.reactionIconView, scale: self.textFieldExternalState.hasText ? 0.1 : 1.0) - - fieldIconNextX -= image.size.width + 4.0 - }*/ + self.fieldBackgroundView.updateColor(color: self.textFieldExternalState.isEditing ? UIColor(white: 0.0, alpha: 0.5) : UIColor(white: 1.0, alpha: 0.09), transition: transition.containedViewLayoutTransition) + transition.setAlpha(view: self.fieldBackgroundView, alpha: hasMediaRecording ? 0.0 : 1.0) + if let placeholderView = self.placeholder.view { + placeholderView.isHidden = self.textFieldExternalState.hasText + } component.externalState.isEditing = self.textFieldExternalState.isEditing component.externalState.hasText = self.textFieldExternalState.hasText @@ -353,7 +431,8 @@ public final class MessageInputPanelComponent: Component { component: AnyComponent(MediaRecordingPanelComponent( audioRecorder: component.audioRecorder, videoRecordingStatus: component.videoRecordingStatus, - cancelFraction: self.mediaCancelFraction + cancelFraction: self.mediaCancelFraction, + insets: insets )), environment: {}, containerSize: size diff --git a/submodules/TelegramUI/Components/ShareWithPeersScreen/BUILD b/submodules/TelegramUI/Components/ShareWithPeersScreen/BUILD new file mode 100644 index 0000000000..27ed088b79 --- /dev/null +++ b/submodules/TelegramUI/Components/ShareWithPeersScreen/BUILD @@ -0,0 +1,37 @@ +load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library") + +swift_library( + name = "ShareWithPeersScreen", + module_name = "ShareWithPeersScreen", + srcs = glob([ + "Sources/**/*.swift", + ]), + copts = [ + "-warnings-as-errors", + ], + deps = [ + "//submodules/AsyncDisplayKit", + "//submodules/Display", + "//submodules/Postbox", + "//submodules/TelegramCore", + "//submodules/SSignalKit/SwiftSignalKit", + "//submodules/ComponentFlow", + "//submodules/Components/ViewControllerComponent", + "//submodules/Components/ComponentDisplayAdapters", + "//submodules/Components/MultilineTextComponent", + "//submodules/TelegramPresentationData", + "//submodules/AccountContext", + "//submodules/AppBundle", + "//submodules/TelegramStringFormatting", + "//submodules/PresentationDataUtils", + "//submodules/Components/SolidRoundedButtonComponent", + "//submodules/TelegramUI/Components/ButtonComponent", + "//submodules/TelegramUI/Components/PlainButtonComponent", + "//submodules/TelegramUI/Components/AnimatedCounterComponent", + "//submodules/AvatarNode", + "//submodules/CheckNode", + ], + visibility = [ + "//visibility:public", + ], +) diff --git a/submodules/TelegramUI/Components/ShareWithPeersScreen/Sources/ActionListItemComponent.swift b/submodules/TelegramUI/Components/ShareWithPeersScreen/Sources/ActionListItemComponent.swift new file mode 100644 index 0000000000..9e4fa31343 --- /dev/null +++ b/submodules/TelegramUI/Components/ShareWithPeersScreen/Sources/ActionListItemComponent.swift @@ -0,0 +1,196 @@ +import Foundation +import UIKit +import Display +import AsyncDisplayKit +import ComponentFlow +import MultilineTextComponent +import TelegramPresentationData + +final class ActionListItemComponent: Component { + let theme: PresentationTheme + let sideInset: CGFloat + let iconName: String? + let title: String + let hasNext: Bool + let action: () -> Void + + init( + theme: PresentationTheme, + sideInset: CGFloat, + iconName: String?, + title: String, + hasNext: Bool, + action: @escaping () -> Void + ) { + self.theme = theme + self.sideInset = sideInset + self.iconName = iconName + self.title = title + self.hasNext = hasNext + self.action = action + } + + static func ==(lhs: ActionListItemComponent, rhs: ActionListItemComponent) -> Bool { + if lhs.theme !== rhs.theme { + return false + } + if lhs.sideInset != rhs.sideInset { + return false + } + if lhs.iconName != rhs.iconName { + return false + } + if lhs.title != rhs.title { + return false + } + if lhs.hasNext != rhs.hasNext { + return false + } + return true + } + + final class View: UIView { + private let containerButton: HighlightTrackingButton + + private let title = ComponentView() + private let iconView: UIImageView + private let separatorLayer: SimpleLayer + + private var highlightBackgroundFrame: CGRect? + private var highlightBackgroundLayer: SimpleLayer? + + private var component: ActionListItemComponent? + private weak var state: EmptyComponentState? + + override init(frame: CGRect) { + self.separatorLayer = SimpleLayer() + + self.containerButton = HighlightTrackingButton() + + self.iconView = UIImageView() + + super.init(frame: frame) + + self.layer.addSublayer(self.separatorLayer) + self.addSubview(self.containerButton) + + self.containerButton.addSubview(self.iconView) + + self.containerButton.highligthedChanged = { [weak self] isHighlighted in + guard let self, let component = self.component, let highlightBackgroundFrame = self.highlightBackgroundFrame else { + return + } + + if isHighlighted { + self.superview?.bringSubviewToFront(self) + + let highlightBackgroundLayer: SimpleLayer + if let current = self.highlightBackgroundLayer { + highlightBackgroundLayer = current + } else { + highlightBackgroundLayer = SimpleLayer() + self.highlightBackgroundLayer = highlightBackgroundLayer + self.layer.insertSublayer(highlightBackgroundLayer, above: self.separatorLayer) + highlightBackgroundLayer.backgroundColor = component.theme.list.itemHighlightedBackgroundColor.cgColor + } + highlightBackgroundLayer.frame = highlightBackgroundFrame + highlightBackgroundLayer.opacity = 1.0 + } else { + if let highlightBackgroundLayer = self.highlightBackgroundLayer { + self.highlightBackgroundLayer = nil + highlightBackgroundLayer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, removeOnCompletion: false, completion: { [weak highlightBackgroundLayer] _ in + highlightBackgroundLayer?.removeFromSuperlayer() + }) + } + } + } + self.containerButton.addTarget(self, action: #selector(self.pressed), for: .touchUpInside) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc private func pressed() { + guard let component = self.component else { + return + } + component.action() + } + + func update(component: ActionListItemComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + let themeUpdated = self.component?.theme !== component.theme + + if self.component?.iconName != component.iconName { + if let iconName = component.iconName { + self.iconView.image = UIImage(bundleImageName: iconName)?.withRenderingMode(.alwaysTemplate) + } else { + self.iconView.image = nil + } + } + if themeUpdated { + self.iconView.tintColor = component.theme.list.itemAccentColor + } + + self.component = component + self.state = state + + let contextInset: CGFloat = 0.0 + + let height: CGFloat = 44.0 + let verticalInset: CGFloat = 1.0 + let leftInset: CGFloat = 62.0 + component.sideInset + let rightInset: CGFloat = contextInset * 2.0 + 8.0 + component.sideInset + + let previousTitleFrame = self.title.view?.frame + + let titleSize = self.title.update( + transition: .immediate, + component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString(string: component.title, font: Font.regular(17.0), textColor: component.theme.list.itemAccentColor)) + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - leftInset - rightInset, height: 100.0) + ) + + let centralContentHeight: CGFloat = titleSize.height + + let titleFrame = CGRect(origin: CGPoint(x: leftInset, y: floor((height - verticalInset * 2.0 - centralContentHeight) / 2.0)), size: titleSize) + if let titleView = self.title.view { + if titleView.superview == nil { + titleView.isUserInteractionEnabled = false + self.containerButton.addSubview(titleView) + } + titleView.frame = titleFrame + if let previousTitleFrame, previousTitleFrame.origin.x != titleFrame.origin.x { + transition.animatePosition(view: titleView, from: CGPoint(x: previousTitleFrame.origin.x - titleFrame.origin.x, y: 0.0), to: CGPoint(), additive: true) + } + } + + if let iconImage = self.iconView.image { + transition.setFrame(view: self.iconView, frame: CGRect(origin: CGPoint(x: floor((leftInset - iconImage.size.width) / 2.0), y: floor((height - iconImage.size.height) / 2.0)), size: iconImage.size)) + } + + if themeUpdated { + self.separatorLayer.backgroundColor = component.theme.list.itemPlainSeparatorColor.cgColor + } + transition.setFrame(layer: self.separatorLayer, frame: CGRect(origin: CGPoint(x: leftInset, y: height), size: CGSize(width: availableSize.width - leftInset, height: UIScreenPixel))) + self.separatorLayer.isHidden = !component.hasNext + + self.highlightBackgroundFrame = CGRect(origin: CGPoint(), size: CGSize(width: availableSize.width, height: height + ((component.hasNext) ? UIScreenPixel : 0.0))) + + let containerFrame = CGRect(origin: CGPoint(x: contextInset, y: verticalInset), size: CGSize(width: availableSize.width - contextInset * 2.0, height: height - verticalInset * 2.0)) + transition.setFrame(view: self.containerButton, frame: containerFrame) + + return CGSize(width: availableSize.width, height: height) + } + } + + func makeView() -> View { + return View(frame: CGRect()) + } + + func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } +} diff --git a/submodules/TelegramUI/Components/ShareWithPeersScreen/Sources/PeerListItemComponent.swift b/submodules/TelegramUI/Components/ShareWithPeersScreen/Sources/PeerListItemComponent.swift new file mode 100644 index 0000000000..10f12a5634 --- /dev/null +++ b/submodules/TelegramUI/Components/ShareWithPeersScreen/Sources/PeerListItemComponent.swift @@ -0,0 +1,312 @@ +import Foundation +import UIKit +import Display +import AsyncDisplayKit +import ComponentFlow +import SwiftSignalKit +import AccountContext +import TelegramCore +import MultilineTextComponent +import AvatarNode +import TelegramPresentationData +import CheckNode +import TelegramStringFormatting + +private let avatarFont = avatarPlaceholderFont(size: 15.0) + +final class PeerListItemComponent: Component { + enum SelectionState: Equatable { + case none + case editing(isSelected: Bool, isTinted: Bool) + } + + let context: AccountContext + let theme: PresentationTheme + let strings: PresentationStrings + let sideInset: CGFloat + let title: String + let peer: EnginePeer? + let subtitle: String? + let selectionState: SelectionState + let hasNext: Bool + let action: (EnginePeer) -> Void + + init( + context: AccountContext, + theme: PresentationTheme, + strings: PresentationStrings, + sideInset: CGFloat, + title: String, + peer: EnginePeer?, + subtitle: String?, + selectionState: SelectionState, + hasNext: Bool, + action: @escaping (EnginePeer) -> Void + ) { + self.context = context + self.theme = theme + self.strings = strings + self.sideInset = sideInset + self.title = title + self.peer = peer + self.subtitle = subtitle + self.selectionState = selectionState + self.hasNext = hasNext + self.action = action + } + + static func ==(lhs: PeerListItemComponent, rhs: PeerListItemComponent) -> Bool { + if lhs.context !== rhs.context { + return false + } + if lhs.theme !== rhs.theme { + return false + } + if lhs.strings !== rhs.strings { + return false + } + if lhs.sideInset != rhs.sideInset { + return false + } + if lhs.title != rhs.title { + return false + } + if lhs.peer != rhs.peer { + return false + } + if lhs.subtitle != rhs.subtitle { + return false + } + if lhs.selectionState != rhs.selectionState { + return false + } + if lhs.hasNext != rhs.hasNext { + return false + } + return true + } + + final class View: UIView { + private let containerButton: HighlightTrackingButton + + private let title = ComponentView() + private let label = ComponentView() + private let separatorLayer: SimpleLayer + private let avatarNode: AvatarNode + + private var checkLayer: CheckLayer? + + private var component: PeerListItemComponent? + private weak var state: EmptyComponentState? + + override init(frame: CGRect) { + self.separatorLayer = SimpleLayer() + + self.containerButton = HighlightTrackingButton() + + self.avatarNode = AvatarNode(font: avatarFont) + self.avatarNode.isLayerBacked = true + + super.init(frame: frame) + + self.layer.addSublayer(self.separatorLayer) + self.addSubview(self.containerButton) + self.containerButton.layer.addSublayer(self.avatarNode.layer) + + self.containerButton.addTarget(self, action: #selector(self.pressed), for: .touchUpInside) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc private func pressed() { + guard let component = self.component, let peer = component.peer else { + return + } + component.action(peer) + } + + func update(component: PeerListItemComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + let themeUpdated = self.component?.theme !== component.theme + + var hasSelectionUpdated = false + if let previousComponent = self.component { + switch previousComponent.selectionState { + case .none: + if case .none = component.selectionState { + } else { + hasSelectionUpdated = true + } + case .editing: + if case .editing = component.selectionState { + } else { + hasSelectionUpdated = true + } + } + } + + self.component = component + self.state = state + + let contextInset: CGFloat = 0.0 + + let height: CGFloat = 60.0 + let verticalInset: CGFloat = 1.0 + var leftInset: CGFloat = 62.0 + component.sideInset + let rightInset: CGFloat = contextInset * 2.0 + 8.0 + component.sideInset + var avatarLeftInset: CGFloat = component.sideInset + 10.0 + + if case let .editing(isSelected, isTinted) = component.selectionState { + leftInset += 44.0 + avatarLeftInset += 44.0 + let checkSize: CGFloat = 22.0 + + let checkLayer: CheckLayer + if let current = self.checkLayer { + checkLayer = current + if themeUpdated { + var theme = CheckNodeTheme(theme: component.theme, style: .plain) + if isTinted { + theme.backgroundColor = theme.backgroundColor.mixedWith(component.theme.list.itemBlocksBackgroundColor, alpha: 0.5) + } + checkLayer.theme = theme + } + checkLayer.setSelected(isSelected, animated: !transition.animation.isImmediate) + } else { + var theme = CheckNodeTheme(theme: component.theme, style: .plain) + if isTinted { + theme.backgroundColor = theme.backgroundColor.mixedWith(component.theme.list.itemBlocksBackgroundColor, alpha: 0.5) + } + checkLayer = CheckLayer(theme: theme) + self.checkLayer = checkLayer + self.containerButton.layer.addSublayer(checkLayer) + checkLayer.frame = CGRect(origin: CGPoint(x: -checkSize, y: floor((height - verticalInset * 2.0 - checkSize) / 2.0)), size: CGSize(width: checkSize, height: checkSize)) + checkLayer.setSelected(isSelected, animated: false) + checkLayer.setNeedsDisplay() + } + transition.setFrame(layer: checkLayer, frame: CGRect(origin: CGPoint(x: floor((54.0 - checkSize) * 0.5), y: floor((height - verticalInset * 2.0 - checkSize) / 2.0)), size: CGSize(width: checkSize, height: checkSize))) + } else { + if let checkLayer = self.checkLayer { + self.checkLayer = nil + transition.setPosition(layer: checkLayer, position: CGPoint(x: -checkLayer.bounds.width * 0.5, y: checkLayer.position.y), completion: { [weak checkLayer] _ in + checkLayer?.removeFromSuperlayer() + }) + } + } + + let avatarSize: CGFloat = 40.0 + + let avatarFrame = CGRect(origin: CGPoint(x: avatarLeftInset, y: floor((height - verticalInset * 2.0 - avatarSize) / 2.0)), size: CGSize(width: avatarSize, height: avatarSize)) + if self.avatarNode.bounds.isEmpty { + self.avatarNode.frame = avatarFrame + } else { + transition.setFrame(layer: self.avatarNode.layer, frame: avatarFrame) + } + if let peer = component.peer { + let clipStyle: AvatarNodeClipStyle + if case let .channel(channel) = peer, channel.flags.contains(.isForum) { + clipStyle = .roundedRect + } else { + clipStyle = .round + } + if peer.id == component.context.account.peerId { + self.avatarNode.setPeer(context: component.context, theme: component.theme, peer: peer, overrideImage: .savedMessagesIcon, clipStyle: clipStyle, displayDimensions: CGSize(width: avatarSize, height: avatarSize)) + } else { + self.avatarNode.setPeer(context: component.context, theme: component.theme, peer: peer, clipStyle: clipStyle, displayDimensions: CGSize(width: avatarSize, height: avatarSize)) + } + } + + let labelData: (String, Bool) + if let subtitle = component.subtitle { + labelData = (subtitle, false) + } else if case .legacyGroup = component.peer { + labelData = (component.strings.Group_Status, false) + } else if case let .channel(channel) = component.peer { + if case .group = channel.info { + labelData = (component.strings.Group_Status, false) + } else { + labelData = (component.strings.Channel_Status, false) + } + } else { + labelData = (component.strings.Group_Status, false) + } + + let labelSize = self.label.update( + transition: .immediate, + component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString(string: labelData.0, font: Font.regular(15.0), textColor: labelData.1 ? component.theme.list.itemAccentColor : component.theme.list.itemSecondaryTextColor)) + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - leftInset - rightInset, height: 100.0) + ) + + let previousTitleFrame = self.title.view?.frame + var previousTitleContents: UIView? + if hasSelectionUpdated && !"".isEmpty { + previousTitleContents = self.title.view?.snapshotView(afterScreenUpdates: false) + } + + let titleSize = self.title.update( + transition: .immediate, + component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString(string: component.title, font: Font.semibold(17.0), textColor: component.theme.list.itemPrimaryTextColor)) + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - leftInset - rightInset, height: 100.0) + ) + + let titleSpacing: CGFloat = 1.0 + let centralContentHeight: CGFloat = titleSize.height + labelSize.height + titleSpacing + + let titleFrame = CGRect(origin: CGPoint(x: leftInset, y: floor((height - verticalInset * 2.0 - centralContentHeight) / 2.0)), size: titleSize) + if let titleView = self.title.view { + if titleView.superview == nil { + titleView.isUserInteractionEnabled = false + self.containerButton.addSubview(titleView) + } + titleView.frame = titleFrame + if let previousTitleFrame, previousTitleFrame.origin.x != titleFrame.origin.x { + transition.animatePosition(view: titleView, from: CGPoint(x: previousTitleFrame.origin.x - titleFrame.origin.x, y: 0.0), to: CGPoint(), additive: true) + } + + if let previousTitleFrame, let previousTitleContents, previousTitleFrame.size != titleSize { + previousTitleContents.frame = CGRect(origin: previousTitleFrame.origin, size: previousTitleFrame.size) + self.addSubview(previousTitleContents) + + transition.setFrame(view: previousTitleContents, frame: CGRect(origin: titleFrame.origin, size: previousTitleFrame.size)) + transition.setAlpha(view: previousTitleContents, alpha: 0.0, completion: { [weak previousTitleContents] _ in + previousTitleContents?.removeFromSuperview() + }) + transition.animateAlpha(view: titleView, from: 0.0, to: 1.0) + } + } + if let labelView = self.label.view { + if labelView.superview == nil { + labelView.isUserInteractionEnabled = false + self.containerButton.addSubview(labelView) + } + transition.setFrame(view: labelView, frame: CGRect(origin: CGPoint(x: titleFrame.minX, y: titleFrame.maxY + titleSpacing), size: labelSize)) + } + + if themeUpdated { + self.separatorLayer.backgroundColor = component.theme.list.itemPlainSeparatorColor.cgColor + } + transition.setFrame(layer: self.separatorLayer, frame: CGRect(origin: CGPoint(x: leftInset, y: height), size: CGSize(width: availableSize.width - leftInset, height: UIScreenPixel))) + self.separatorLayer.isHidden = !component.hasNext + + let containerFrame = CGRect(origin: CGPoint(x: contextInset, y: verticalInset), size: CGSize(width: availableSize.width - contextInset * 2.0, height: height - verticalInset * 2.0)) + transition.setFrame(view: self.containerButton, frame: containerFrame) + + return CGSize(width: availableSize.width, height: height) + } + } + + func makeView() -> View { + return View(frame: CGRect()) + } + + func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } +} diff --git a/submodules/TelegramUI/Components/ShareWithPeersScreen/Sources/ShareWithPeersScreen.swift b/submodules/TelegramUI/Components/ShareWithPeersScreen/Sources/ShareWithPeersScreen.swift new file mode 100644 index 0000000000..834c442be0 --- /dev/null +++ b/submodules/TelegramUI/Components/ShareWithPeersScreen/Sources/ShareWithPeersScreen.swift @@ -0,0 +1,1508 @@ +import Foundation +import UIKit +import Display +import AsyncDisplayKit +import ComponentFlow +import SwiftSignalKit +import ViewControllerComponent +import ComponentDisplayAdapters +import TelegramPresentationData +import AccountContext +import TelegramCore +import MultilineTextComponent +import SolidRoundedButtonComponent +import PresentationDataUtils +import ButtonComponent +import PlainButtonComponent +import AnimatedCounterComponent + +/*private final class ShareWithPeersScreenComponent: Component { + typealias EnvironmentType = ViewControllerComponentContainer.Environment + + let context: AccountContext + + init( + context: AccountContext + ) { + self.context = context + } + + static func ==(lhs: ShareWithPeersScreenComponent, rhs: ShareWithPeersScreenComponent) -> Bool { + if lhs.context !== rhs.context { + return false + } + return true + } + + private struct ItemLayout: Equatable { + var containerSize: CGSize + var containerInset: CGFloat + var bottomInset: CGFloat + var topInset: CGFloat + var contentHeight: CGFloat + + init(containerSize: CGSize, containerInset: CGFloat, bottomInset: CGFloat, topInset: CGFloat, contentHeight: CGFloat) { + self.containerSize = containerSize + self.containerInset = containerInset + self.bottomInset = bottomInset + self.topInset = topInset + self.contentHeight = contentHeight + } + } + + private final class ScrollView: UIScrollView { + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + return super.hitTest(point, with: event) + } + + override func touchesShouldCancel(in view: UIView) -> Bool { + return true + } + } + + final class AnimationHint { + init() { + } + } + + final class View: UIView, UIScrollViewDelegate { + private let dimView: UIView + private let backgroundView: UIImageView + private let scrollView: ScrollView + private let scrollContentClippingView: SparseContainerView + private let scrollContentView: UIView + + private let itemContainerView: UIView + private var items: [AnyHashable: ComponentView] = [:] + + private var ignoreScrolling: Bool = false + + private var component: ShareWithPeersScreenComponent? + private weak var state: EmptyComponentState? + private var environment: ViewControllerComponentContainer.Environment? + private var itemLayout: ItemLayout? + + private var topOffsetDistance: CGFloat? + + override init(frame: CGRect) { + self.dimView = UIView() + + self.backgroundView = UIImageView(image: generateStretchableFilledCircleImage(asdf)) + + self.scrollView = ScrollView() + + self.scrollContentClippingView = SparseContainerView() + self.scrollContentClippingView.clipsToBounds = true + + self.scrollContentView = UIView() + + self.itemContainerView = UIView() + self.itemContainerView.clipsToBounds = true + self.itemContainerView.layer.cornerRadius = 10.0 + + self.bottomBackgroundLayer = SimpleLayer() + self.bottomSeparatorLayer = SimpleLayer() + + super.init(frame: frame) + + self.addSubview(self.dimView) + self.layer.addSublayer(self.backgroundLayer) + + self.addSubview(self.navigationBarContainer) + + self.scrollView.delaysContentTouches = false + self.scrollView.canCancelContentTouches = true + self.scrollView.clipsToBounds = false + if #available(iOSApplicationExtension 11.0, iOS 11.0, *) { + self.scrollView.contentInsetAdjustmentBehavior = .never + } + if #available(iOS 13.0, *) { + self.scrollView.automaticallyAdjustsScrollIndicatorInsets = false + } + self.scrollView.showsVerticalScrollIndicator = false + self.scrollView.showsHorizontalScrollIndicator = false + self.scrollView.alwaysBounceHorizontal = false + self.scrollView.alwaysBounceVertical = true + self.scrollView.scrollsToTop = false + self.scrollView.delegate = self + self.scrollView.clipsToBounds = true + + self.addSubview(self.scrollContentClippingView) + self.scrollContentClippingView.addSubview(self.scrollView) + + self.scrollView.addSubview(self.scrollContentView) + + self.scrollContentView.addSubview(self.itemContainerView) + + self.layer.addSublayer(self.bottomBackgroundLayer) + self.layer.addSublayer(self.bottomSeparatorLayer) + + self.dimView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.dimTapGesture(_:)))) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + self.joinDisposable?.dispose() + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + if !self.ignoreScrolling { + self.updateScrolling(transition: .immediate) + } + } + + func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer) { + guard let itemLayout = self.itemLayout, let topOffsetDistance = self.topOffsetDistance else { + return + } + + if scrollView.contentOffset.y <= -100.0 && velocity.y <= -2.0 { + self.environment?.controller()?.dismiss() + } else { + var topOffset = -self.scrollView.bounds.minY + itemLayout.topInset + if topOffset > 0.0 { + topOffset = max(0.0, topOffset) + + if topOffset < topOffsetDistance { + targetContentOffset.pointee.y = scrollView.contentOffset.y + scrollView.setContentOffset(CGPoint(x: 0.0, y: itemLayout.topInset), animated: true) + } + } + } + } + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + if !self.bounds.contains(point) { + return nil + } + if !self.backgroundLayer.frame.contains(point) { + return self.dimView + } + + if let result = self.navigationBarContainer.hitTest(self.convert(point, to: self.navigationBarContainer), with: event) { + return result + } + + let result = super.hitTest(point, with: event) + return result + } + + @objc private func dimTapGesture(_ recognizer: UITapGestureRecognizer) { + if case .ended = recognizer.state { + guard let environment = self.environment, let controller = environment.controller() else { + return + } + controller.dismiss() + } + } + + private func updateScrolling(transition: Transition) { + guard let environment = self.environment, let controller = environment.controller(), let itemLayout = self.itemLayout else { + return + } + var topOffset = -self.scrollView.bounds.minY + itemLayout.topInset + topOffset = max(0.0, topOffset) + transition.setTransform(layer: self.backgroundLayer, transform: CATransform3DMakeTranslation(0.0, topOffset + itemLayout.containerInset, 0.0)) + + let bottomDistance = itemLayout.contentHeight - self.scrollView.bounds.maxY + let bottomAlphaDistance: CGFloat = 30.0 + var bottomAlpha: CGFloat = bottomDistance / bottomAlphaDistance + bottomAlpha = max(0.0, min(1.0, bottomAlpha)) + + let bottomOverlayAlpha: CGFloat = bottomAlpha + transition.setAlpha(layer: self.bottomBackgroundLayer, alpha: bottomOverlayAlpha) + transition.setAlpha(layer: self.bottomSeparatorLayer, alpha: bottomOverlayAlpha) + + transition.setPosition(view: self.navigationBarContainer, position: CGPoint(x: 0.0, y: topOffset + itemLayout.containerInset)) + + let topOffsetDistance: CGFloat = min(200.0, floor(itemLayout.containerSize.height * 0.25)) + self.topOffsetDistance = topOffsetDistance + var topOffsetFraction = topOffset / topOffsetDistance + topOffsetFraction = max(0.0, min(1.0, topOffsetFraction)) + + let transitionFactor: CGFloat = 1.0 - topOffsetFraction + controller.updateModalStyleOverlayTransitionFactor(transitionFactor, transition: transition.containedViewLayoutTransition) + } + + func animateIn() { + self.dimView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3) + let animateOffset: CGFloat = self.bounds.height - self.backgroundLayer.frame.minY + self.scrollContentClippingView.layer.animatePosition(from: CGPoint(x: 0.0, y: animateOffset), to: CGPoint(), duration: 0.5, timingFunction: kCAMediaTimingFunctionSpring, additive: true) + self.backgroundLayer.animatePosition(from: CGPoint(x: 0.0, y: animateOffset), to: CGPoint(), duration: 0.5, timingFunction: kCAMediaTimingFunctionSpring, additive: true) + self.navigationBarContainer.layer.animatePosition(from: CGPoint(x: 0.0, y: animateOffset), to: CGPoint(), duration: 0.5, timingFunction: kCAMediaTimingFunctionSpring, additive: true) + self.bottomBackgroundLayer.animatePosition(from: CGPoint(x: 0.0, y: animateOffset), to: CGPoint(), duration: 0.5, timingFunction: kCAMediaTimingFunctionSpring, additive: true) + self.bottomSeparatorLayer.animatePosition(from: CGPoint(x: 0.0, y: animateOffset), to: CGPoint(), duration: 0.5, timingFunction: kCAMediaTimingFunctionSpring, additive: true) + if let actionButtonView = self.actionButton.view { + actionButtonView.layer.animatePosition(from: CGPoint(x: 0.0, y: animateOffset), to: CGPoint(), duration: 0.5, timingFunction: kCAMediaTimingFunctionSpring, additive: true) + } + } + + func animateOut(completion: @escaping () -> Void) { + if let controller = self.environment?.controller() { + controller.updateModalStyleOverlayTransitionFactor(0.0, transition: .animated(duration: 0.3, curve: .easeInOut)) + } + + var animateOffset: CGFloat = self.bounds.height - self.backgroundLayer.frame.minY + if self.scrollView.contentOffset.y < 0.0 { + animateOffset += -self.scrollView.contentOffset.y + } + + self.dimView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false) + self.scrollContentClippingView.layer.animatePosition(from: CGPoint(), to: CGPoint(x: 0.0, y: animateOffset), duration: 0.3, timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, removeOnCompletion: false, additive: true, completion: { _ in + completion() + }) + self.backgroundLayer.animatePosition(from: CGPoint(), to: CGPoint(x: 0.0, y: animateOffset), duration: 0.3, timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, removeOnCompletion: false, additive: true) + self.bottomBackgroundLayer.animatePosition(from: CGPoint(), to: CGPoint(x: 0.0, y: animateOffset), duration: 0.3, timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, removeOnCompletion: false, additive: true) + self.bottomSeparatorLayer.animatePosition(from: CGPoint(), to: CGPoint(x: 0.0, y: animateOffset), duration: 0.3, timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, removeOnCompletion: false, additive: true) + self.navigationBarContainer.layer.animatePosition(from: CGPoint(), to: CGPoint(x: 0.0, y: animateOffset), duration: 0.3, timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, removeOnCompletion: false, additive: true) + if let actionButtonView = self.actionButton.view { + actionButtonView.layer.animatePosition(from: CGPoint(), to: CGPoint(x: 0.0, y: animateOffset), duration: 0.3, timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, removeOnCompletion: false, additive: true) + } + } + + func update(component: ShareWithPeersScreenComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + let animationHint = transition.userData(AnimationHint.self) + + var contentTransition = transition + if animationHint != nil { + contentTransition = .immediate + } + + let environment = environment[ViewControllerComponentContainer.Environment.self].value + let themeUpdated = self.environment?.theme !== environment.theme + + let resetScrolling = self.scrollView.bounds.width != availableSize.width + + let sideInset: CGFloat = 16.0 + + if self.component?.linkContents == nil, let linkContents = component.linkContents { + if case let .remove(_, defaultSelectedPeerIds) = component.subject { + for peer in linkContents.peers { + if defaultSelectedPeerIds.contains(peer.id) { + self.selectedItems.insert(peer.id) + } + } + } else { + for peer in linkContents.peers { + self.selectedItems.insert(peer.id) + } + } + } + + if self.component == nil, case let .linkList(_, initialLinks) = component.subject { + self.linkListItems = initialLinks + } + + self.component = component + self.state = state + self.environment = environment + + if themeUpdated { + self.dimView.backgroundColor = UIColor(white: 0.0, alpha: 0.5) + self.backgroundLayer.backgroundColor = environment.theme.list.blocksBackgroundColor.cgColor + self.itemContainerView.backgroundColor = environment.theme.list.itemBlocksBackgroundColor + self.bottomBackgroundLayer.backgroundColor = environment.theme.rootController.navigationBar.opaqueBackgroundColor.cgColor + self.bottomSeparatorLayer.backgroundColor = environment.theme.rootController.navigationBar.separatorColor.cgColor + } + + transition.setFrame(view: self.dimView, frame: CGRect(origin: CGPoint(), size: availableSize)) + + var contentHeight: CGFloat = 0.0 + + let leftButtonSize = self.leftButton.update( + transition: contentTransition, + component: AnyComponent(Button( + content: AnyComponent(Text(text: environment.strings.Common_Cancel, font: Font.regular(17.0), color: environment.theme.list.itemAccentColor)), + action: { [weak self] in + guard let self, let controller = self.environment?.controller() else { + return + } + controller.dismiss() + } + ).minSize(CGSize(width: 44.0, height: 56.0))), + environment: {}, + containerSize: CGSize(width: 120.0, height: 100.0) + ) + let leftButtonFrame = CGRect(origin: CGPoint(x: 16.0, y: 0.0), size: leftButtonSize) + if let leftButtonView = self.leftButton.view { + if leftButtonView.superview == nil { + self.navigationBarContainer.addSubview(leftButtonView) + } + transition.setFrame(view: leftButtonView, frame: leftButtonFrame) + } + + let titleString: String + var allChatsAdded = false + var canAddChatCount = 0 + if case .linkList = component.subject { + titleString = environment.strings.FolderLinkPreview_TitleShare + } else if let linkContents = component.linkContents { + if case .remove = component.subject { + titleString = environment.strings.FolderLinkPreview_TitleRemove + } else if linkContents.localFilterId != nil { + if linkContents.alreadyMemberPeerIds == Set(linkContents.peers.map(\.id)) { + allChatsAdded = true + } + canAddChatCount = linkContents.peers.map(\.id).count - linkContents.alreadyMemberPeerIds.count + + if allChatsAdded { + titleString = environment.strings.FolderLinkPreview_TitleAddFolder + } else { + titleString = environment.strings.FolderLinkPreview_TitleAddChats(Int32(canAddChatCount)) + } + } else { + titleString = environment.strings.FolderLinkPreview_TitleAddFolder + } + } else { + titleString = " " + } + let titleSize = self.title.update( + transition: .immediate, + component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString(string: titleString, font: Font.semibold(17.0), textColor: environment.theme.list.itemPrimaryTextColor)) + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - leftButtonFrame.maxX * 2.0, height: 100.0) + ) + let titleFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - titleSize.width) * 0.5), y: 18.0), size: titleSize) + if let titleView = self.title.view { + if titleView.superview == nil { + self.navigationBarContainer.addSubview(titleView) + } + contentTransition.setFrame(view: titleView, frame: titleFrame) + } + + contentHeight += 44.0 + contentHeight += 14.0 + + var topBadge: String? + if case .linkList = component.subject { + } else if case .remove = component.subject { + } else if !allChatsAdded, let linkContents = component.linkContents, linkContents.localFilterId != nil, canAddChatCount != 0 { + topBadge = "+\(canAddChatCount)" + } + + let topIconSize = self.topIcon.update( + transition: contentTransition, + component: AnyComponent(ChatFolderLinkHeaderComponent( + theme: environment.theme, + strings: environment.strings, + title: component.linkContents?.title ?? "Folder", + badge: topBadge + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - sideInset, height: 1000.0) + ) + let topIconFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - topIconSize.width) * 0.5), y: contentHeight), size: topIconSize) + if let topIconView = self.topIcon.view { + if topIconView.superview == nil { + self.scrollContentView.addSubview(topIconView) + } + contentTransition.setFrame(view: topIconView, frame: topIconFrame) + topIconView.isHidden = component.linkContents == nil + } + + contentHeight += topIconSize.height + contentHeight += 20.0 + + let text: String + if case .linkList = component.subject { + text = environment.strings.FolderLinkPreview_TextLinkList + } else if let linkContents = component.linkContents { + if case .remove = component.subject { + text = environment.strings.FolderLinkPreview_TextRemoveFolder + } else if allChatsAdded { + text = environment.strings.FolderLinkPreview_TextAllAdded + } else if linkContents.localFilterId == nil { + text = environment.strings.FolderLinkPreview_TextAddFolder + } else { + let chatCountString: String = environment.strings.FolderLinkPreview_TextAddChatsCount(Int32(canAddChatCount)) + text = environment.strings.FolderLinkPreview_TextAddChats(chatCountString, linkContents.title ?? "").string + } + } else { + text = " " + } + + let body = MarkdownAttributeSet(font: Font.regular(15.0), textColor: environment.theme.list.freeTextColor) + let bold = MarkdownAttributeSet(font: Font.semibold(15.0), textColor: environment.theme.list.freeTextColor) + + let descriptionTextSize = self.descriptionText.update( + transition: .immediate, + component: AnyComponent(MultilineTextComponent( + text: .markdown(text: text, attributes: MarkdownAttributes( + body: body, + bold: bold, + link: body, + linkAttribute: { _ in nil } + )), + horizontalAlignment: .center, + maximumNumberOfLines: 0 + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - sideInset * 2.0 - 16.0 * 2.0, height: 1000.0) + ) + let descriptionTextFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - descriptionTextSize.width) * 0.5), y: contentHeight), size: descriptionTextSize) + if let descriptionTextView = self.descriptionText.view { + if descriptionTextView.superview == nil { + self.scrollContentView.addSubview(descriptionTextView) + } + descriptionTextView.bounds = CGRect(origin: CGPoint(), size: descriptionTextFrame.size) + contentTransition.setPosition(view: descriptionTextView, position: descriptionTextFrame.center) + } + + contentHeight += descriptionTextFrame.height + contentHeight += 39.0 + + var singleItemHeight: CGFloat = 0.0 + + var itemsHeight: CGFloat = 0.0 + var validIds: [AnyHashable] = [] + if case let .linkList(folderId, _) = component.subject { + do { + let id = AnyHashable("action") + validIds.append(id) + + let item: ComponentView + var itemTransition = transition + if let current = self.items[id] { + item = current + } else { + itemTransition = .immediate + item = ComponentView() + self.items[id] = item + } + + let itemSize = item.update( + transition: itemTransition, + component: AnyComponent(ActionListItemComponent( + theme: environment.theme, + sideInset: 0.0, + iconName: "Contact List/LinkActionIcon", + title: environment.strings.InviteLink_Create, + hasNext: !self.linkListItems.isEmpty, + action: { [weak self] in + self?.openCreateLink() + } + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 1000.0) + ) + let itemFrame = CGRect(origin: CGPoint(x: 0.0, y: itemsHeight), size: itemSize) + + if let itemView = item.view { + if itemView.superview == nil { + self.itemContainerView.addSubview(itemView) + } + itemTransition.setFrame(view: itemView, frame: itemFrame) + } + + itemsHeight += itemSize.height + singleItemHeight = itemSize.height + } + + for i in 0 ..< self.linkListItems.count { + let link = self.linkListItems[i] + + let id = AnyHashable(link.link) + validIds.append(id) + + let item: ComponentView + var itemTransition = transition + if let current = self.items[id] { + item = current + } else { + itemTransition = .immediate + item = ComponentView() + self.items[id] = item + } + + let subtitle: String = environment.strings.ChatListFilter_LinkLabelChatCount(Int32(link.peerIds.count)) + + let itemComponent = LinkListItemComponent( + theme: environment.theme, + sideInset: 0.0, + title: link.title.isEmpty ? link.link : link.title, + link: link, + label: subtitle, + selectionState: .none, + hasNext: i != self.linkListItems.count - 1, + action: { [weak self] link in + guard let self else { + return + } + self.openLink(link: link) + }, + contextAction: { [weak self] link, sourceView, gesture in + guard let self, let component = self.component, let environment = self.environment else { + return + } + + let presentationData = component.context.sharedContext.currentPresentationData.with { $0 } + + var itemList: [ContextMenuItem] = [] + + itemList.append(.action(ContextMenuActionItem(text: presentationData.strings.InviteLink_ContextCopy, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Copy"), color: theme.contextMenu.primaryColor) + }, action: { [weak self] _, f in + f(.default) + + UIPasteboard.general.string = link.link + + if let self, let component = self.component, let controller = self.environment?.controller() { + let presentationData = component.context.sharedContext.currentPresentationData.with { $0 } + controller.present(UndoOverlayController(presentationData: presentationData, content: .linkCopied(text: presentationData.strings.InviteLink_InviteLinkCopiedText), elevatedLayout: false, animateInAsReplacement: false, action: { _ in return false }), in: .window(.root)) + } + }))) + + itemList.append(.action(ContextMenuActionItem(text: presentationData.strings.InviteLink_ContextGetQRCode, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Settings/QrIcon"), color: theme.contextMenu.primaryColor) + }, action: { [weak self] _, f in + f(.dismissWithoutContent) + + if let self, let component = self.component, let controller = self.environment?.controller() { + controller.present(QrCodeScreen(context: component.context, updatedPresentationData: nil, subject: .chatFolder(slug: link.slug)), in: .window(.root)) + } + }))) + + itemList.append(.action(ContextMenuActionItem(text: presentationData.strings.InviteLink_ContextRevoke, textColor: .destructive, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Delete"), color: theme.contextMenu.destructiveColor) + }, action: { [weak self] _, f in + f(.dismissWithoutContent) + + if let self, let component = self.component { + self.linkListItems.removeAll(where: { $0.link == link.link }) + self.state?.updated(transition: Transition(animation: .curve(duration: 0.3, curve: .easeInOut))) + + let context = component.context + let _ = (context.engine.peers.editChatFolderLink(filterId: folderId, link: link, title: nil, peerIds: nil, revoke: true) + |> deliverOnMainQueue).start(completed: { + let _ = (context.engine.peers.deleteChatFolderLink(filterId: folderId, link: link) + |> deliverOnMainQueue).start(completed: { + }) + }) + } + }))) + + let items = ContextController.Items(content: .list(itemList)) + + let controller = ContextController( + account: component.context.account, + presentationData: presentationData, + source: .extracted(LinkListContextExtractedContentSource(contentView: sourceView)), + items: .single(items), + recognizer: nil, + gesture: gesture + ) + + environment.controller()?.forEachController({ controller in + if let controller = controller as? UndoOverlayController { + controller.dismiss() + } + return true + }) + environment.controller()?.presentInGlobalOverlay(controller) + } + ) + + let itemSize = item.update( + transition: itemTransition, + component: AnyComponent(itemComponent), + environment: {}, + containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 1000.0) + ) + let itemFrame = CGRect(origin: CGPoint(x: 0.0, y: itemsHeight), size: itemSize) + + if let itemView = item.view { + if itemView.superview == nil { + self.itemContainerView.addSubview(itemView) + } + itemTransition.setFrame(view: itemView, frame: itemFrame) + } + + itemsHeight += itemSize.height + singleItemHeight = itemSize.height + } + } else if let linkContents = component.linkContents { + for i in 0 ..< linkContents.peers.count { + let peer = linkContents.peers[i] + + let id = AnyHashable(peer.id) + validIds.append(id) + + let item: ComponentView + var itemTransition = transition + if let current = self.items[id] { + item = current + } else { + itemTransition = .immediate + item = ComponentView() + self.items[id] = item + } + + var subtitle: String? + if case let .channel(channel) = peer, case .broadcast = channel.info { + if linkContents.alreadyMemberPeerIds.contains(peer.id) { + subtitle = environment.strings.FolderLinkPreview_LabelPeerSubscriber + } else if let memberCount = linkContents.memberCounts[peer.id] { + subtitle = environment.strings.FolderLinkPreview_LabelPeerSubscribers(Int32(memberCount)) + } + } else { + if linkContents.alreadyMemberPeerIds.contains(peer.id) { + subtitle = environment.strings.FolderLinkPreview_LabelPeerMember + } else if let memberCount = linkContents.memberCounts[peer.id] { + subtitle = environment.strings.FolderLinkPreview_LabelPeerMembers(Int32(memberCount)) + } + } + + let itemSize = item.update( + transition: itemTransition, + component: AnyComponent(PeerListItemComponent( + context: component.context, + theme: environment.theme, + strings: environment.strings, + sideInset: 0.0, + title: peer.displayTitle(strings: environment.strings, displayOrder: .firstLast), + peer: peer, + subtitle: subtitle, + selectionState: .editing(isSelected: self.selectedItems.contains(peer.id), isTinted: linkContents.alreadyMemberPeerIds.contains(peer.id)), + hasNext: i != linkContents.peers.count - 1, + action: { [weak self] peer in + guard let self, let component = self.component, let linkContents = component.linkContents, let controller = self.environment?.controller() else { + return + } + + if case .remove = component.subject { + if self.selectedItems.contains(peer.id) { + self.selectedItems.remove(peer.id) + } else { + self.selectedItems.insert(peer.id) + } + self.state?.updated(transition: Transition(animation: .curve(duration: 0.3, curve: .easeInOut))) + } else if linkContents.alreadyMemberPeerIds.contains(peer.id) { + let presentationData = component.context.sharedContext.currentPresentationData.with { $0 } + let text: String + if case let .channel(channel) = peer, case .broadcast = channel.info { + text = presentationData.strings.FolderLinkPreview_ToastAlreadyMemberChannel + } else { + text = presentationData.strings.FolderLinkPreview_ToastAlreadyMemberGroup + } + controller.present(UndoOverlayController(presentationData: presentationData, content: .peers(context: component.context, peers: [peer], title: nil, text: text, customUndoText: nil), elevatedLayout: false, action: { _ in true }), in: .current) + } else { + if self.selectedItems.contains(peer.id) { + self.selectedItems.remove(peer.id) + } else { + self.selectedItems.insert(peer.id) + } + self.state?.updated(transition: Transition(animation: .curve(duration: 0.3, curve: .easeInOut))) + } + } + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 1000.0) + ) + let itemFrame = CGRect(origin: CGPoint(x: 0.0, y: itemsHeight), size: itemSize) + + if let itemView = item.view { + if itemView.superview == nil { + self.itemContainerView.addSubview(itemView) + } + itemTransition.setFrame(view: itemView, frame: itemFrame) + } + + itemsHeight += itemSize.height + singleItemHeight = itemSize.height + } + } + + var removeIds: [AnyHashable] = [] + for (id, item) in self.items { + if !validIds.contains(id) { + removeIds.append(id) + item.view?.removeFromSuperview() + } + } + for id in removeIds { + self.items.removeValue(forKey: id) + } + + let listHeaderTitle: String + if case .linkList = component.subject { + listHeaderTitle = environment.strings.FolderLinkPreview_LinkSectionHeader + } else if let linkContents = component.linkContents { + if case .remove = component.subject { + listHeaderTitle = environment.strings.FolderLinkPreview_RemoveSectionSelectedHeader(Int32(linkContents.peers.count)) + } else if allChatsAdded { + listHeaderTitle = environment.strings.FolderLinkPreview_ChatSectionHeader(Int32(linkContents.peers.count)) + } else { + listHeaderTitle = environment.strings.FolderLinkPreview_ChatSectionJoinHeader(Int32(linkContents.peers.count)) + } + } else { + listHeaderTitle = " " + } + + var listHeaderActionItems: [AnimatedCounterComponent.Item] = [] + + let dynamicIndex = environment.strings.FolderLinkPreview_ListSelectionSelectAllFormat.range(of: "{dynamic}") + let staticIndex = environment.strings.FolderLinkPreview_ListSelectionSelectAllFormat.range(of: "{static}") + var headerActionItemIndices: [Int: Int] = [:] + if let dynamicIndex, let staticIndex { + if dynamicIndex.lowerBound < staticIndex.lowerBound { + headerActionItemIndices[0] = 0 + headerActionItemIndices[1] = 1 + } else { + headerActionItemIndices[0] = 1 + headerActionItemIndices[1] = 0 + } + } else if dynamicIndex != nil { + headerActionItemIndices[0] = 0 + } else if staticIndex != nil { + headerActionItemIndices[1] = 0 + } + + let dynamicItem: AnimatedCounterComponent.Item + let staticItem: AnimatedCounterComponent.Item + + if self.selectedItems.count == self.items.count { + dynamicItem = AnimatedCounterComponent.Item(id: AnyHashable(0), text: environment.strings.FolderLinkPreview_ListSelectionSelectAllDynamicPartDeselect, numericValue: 0) + staticItem = AnimatedCounterComponent.Item(id: AnyHashable(1), text: environment.strings.FolderLinkPreview_ListSelectionSelectAllStaticPartDeselect, numericValue: 1) + } else { + dynamicItem = AnimatedCounterComponent.Item(id: AnyHashable(0), text: environment.strings.FolderLinkPreview_ListSelectionSelectAllDynamicPartSelect, numericValue: 1) + staticItem = AnimatedCounterComponent.Item(id: AnyHashable(1), text: environment.strings.FolderLinkPreview_ListSelectionSelectAllStaticPartSelect, numericValue: 1) + } + + if let dynamicIndex = headerActionItemIndices[0], let staticIndex = headerActionItemIndices[1] { + if dynamicIndex < staticIndex { + listHeaderActionItems = [dynamicItem, staticItem] + } else { + listHeaderActionItems = [staticItem, dynamicItem] + } + } else if headerActionItemIndices[0] != nil { + listHeaderActionItems = [dynamicItem] + } else if headerActionItemIndices[1] != nil { + listHeaderActionItems = [staticItem] + } + + let listHeaderBody = MarkdownAttributeSet(font: Font.with(size: 13.0, design: .regular, traits: [.monospacedNumbers]), textColor: environment.theme.list.freeTextColor) + + let listHeaderTextSize = self.listHeaderText.update( + transition: .immediate, + component: AnyComponent(MultilineTextComponent( + text: .markdown( + text: listHeaderTitle, + attributes: MarkdownAttributes( + body: listHeaderBody, + bold: listHeaderBody, + link: listHeaderBody, + linkAttribute: { _ in nil } + ) + ) + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - sideInset * 2.0 - 15.0, height: 1000.0) + ) + if let listHeaderTextView = self.listHeaderText.view { + if listHeaderTextView.superview == nil { + listHeaderTextView.layer.anchorPoint = CGPoint() + self.scrollContentView.addSubview(listHeaderTextView) + } + let listHeaderTextFrame = CGRect(origin: CGPoint(x: sideInset + 15.0, y: contentHeight), size: listHeaderTextSize) + contentTransition.setPosition(view: listHeaderTextView, position: listHeaderTextFrame.origin) + listHeaderTextView.bounds = CGRect(origin: CGPoint(), size: listHeaderTextFrame.size) + listHeaderTextView.isHidden = component.linkContents == nil + } + + let listHeaderActionSize = self.listHeaderAction.update( + transition: transition, + component: AnyComponent(PlainButtonComponent( + content: AnyComponent(AnimatedCounterComponent( + font: Font.regular(13.0), + color: environment.theme.list.itemAccentColor, + alignment: .right, + items: listHeaderActionItems + )), + effectAlignment: .right, + action: { [weak self] in + guard let self, let component = self.component, let linkContents = component.linkContents else { + return + } + if self.selectedItems.count != linkContents.peers.count { + for peer in linkContents.peers { + self.selectedItems.insert(peer.id) + } + } else { + self.selectedItems.removeAll() + for peerId in linkContents.alreadyMemberPeerIds { + self.selectedItems.insert(peerId) + } + } + self.state?.updated(transition: Transition(animation: .curve(duration: 0.3, curve: .easeInOut))) + } + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - sideInset * 2.0 - 15.0, height: 1000.0) + ) + if let listHeaderActionView = self.listHeaderAction.view { + if listHeaderActionView.superview == nil { + listHeaderActionView.layer.anchorPoint = CGPoint(x: 1.0, y: 0.0) + self.scrollContentView.addSubview(listHeaderActionView) + } + let listHeaderActionFrame = CGRect(origin: CGPoint(x: availableSize.width - sideInset - 15.0 - listHeaderActionSize.width, y: contentHeight), size: listHeaderActionSize) + contentTransition.setFrame(view: listHeaderActionView, frame: listHeaderActionFrame) + + if let linkContents = component.linkContents, !allChatsAdded, linkContents.peers.count > 1 { + listHeaderActionView.isHidden = false + } else { + listHeaderActionView.isHidden = true + } + } + + contentHeight += listHeaderTextSize.height + contentHeight += 6.0 + + contentTransition.setFrame(view: self.itemContainerView, frame: CGRect(origin: CGPoint(x: sideInset, y: contentHeight), size: CGSize(width: availableSize.width - sideInset * 2.0, height: itemsHeight))) + + var initialContentHeight = contentHeight + initialContentHeight += min(itemsHeight, floor(singleItemHeight * 3.5)) + + contentHeight += itemsHeight + contentHeight += 24.0 + initialContentHeight += 24.0 + + let actionButtonTitle: String + var actionButtonBadge: Int = 0 + if case .remove = component.subject { + actionButtonBadge = self.selectedItems.count + if self.selectedItems.isEmpty { + actionButtonTitle = environment.strings.FolderLinkPreview_ButtonRemoveFolder + } else { + actionButtonTitle = environment.strings.FolderLinkPreview_ButtonRemoveFolderAndChats + } + } else if allChatsAdded { + actionButtonBadge = 0 + actionButtonTitle = environment.strings.Common_OK + } else if let linkContents = component.linkContents { + actionButtonBadge = max(0, self.selectedItems.count - (linkContents.peers.count - canAddChatCount)) + if linkContents.localFilterId != nil { + if actionButtonBadge == 0 { + actionButtonTitle = environment.strings.FolderLinkPreview_ButtonDoNotJoinChats + } else { + actionButtonTitle = environment.strings.FolderLinkPreview_ButtonJoinChats + } + } else { + actionButtonTitle = environment.strings.FolderLinkPreview_ButtonAddFolder + } + } else { + actionButtonTitle = " " + } + + let actionButtonSize = self.actionButton.update( + transition: transition, + component: AnyComponent(ButtonComponent( + background: ButtonComponent.Background( + color: environment.theme.list.itemCheckColors.fillColor, + foreground: environment.theme.list.itemCheckColors.foregroundColor, + pressedColor: environment.theme.list.itemCheckColors.fillColor.withMultipliedAlpha(0.9) + ), + content: AnyComponentWithIdentity( + id: actionButtonTitle, + component: AnyComponent(ButtonTextContentComponent( + text: actionButtonTitle, + badge: actionButtonBadge, + textColor: environment.theme.list.itemCheckColors.foregroundColor, + badgeBackground: environment.theme.list.itemCheckColors.foregroundColor, + badgeForeground: environment.theme.list.itemCheckColors.fillColor + )) + ), + isEnabled: !self.selectedItems.isEmpty || component.linkContents?.localFilterId != nil, + displaysProgress: self.inProgress, + action: { [weak self] in + guard let self, let component = self.component, let linkContents = component.linkContents, let controller = self.environment?.controller() else { + return + } + + if case let .remove(folderId, _) = component.subject { + self.inProgress = true + self.state?.updated(transition: .immediate) + + component.completion?() + + let disposable = DisposableSet() + disposable.add(component.context.account.postbox.addHiddenChatIds(peerIds: Array(self.selectedItems))) + disposable.add(component.context.account.viewTracker.addHiddenChatListFilterIds([folderId])) + + let folderTitle = linkContents.title ?? "" + + let presentationData = component.context.sharedContext.currentPresentationData.with({ $0 }) + + var additionalText: String? + if !self.selectedItems.isEmpty { + additionalText = presentationData.strings.FolderLinkPreview_ToastLeftChatsText(Int32(self.selectedItems.count)) + } + + var chatListController: ChatListController? + if let navigationController = controller.navigationController as? NavigationController { + for viewController in navigationController.viewControllers.reversed() { + if viewController is ShareWithPeersScreen { + continue + } + + if let rootController = viewController as? TabBarController { + for c in rootController.controllers { + if let c = c as? ChatListController { + chatListController = c + break + } + } + } else if let c = viewController as? ChatListController { + chatListController = c + break + } + + break + } + } + + let context = component.context + let selectedItems = self.selectedItems + let undoOverlayController = UndoOverlayController( + presentationData: presentationData, + content: .removedChat(title: presentationData.strings.FolderLinkPreview_ToastLeftTitle(folderTitle).string, text: additionalText), + elevatedLayout: false, + action: { value in + if case .commit = value { + let _ = (context.engine.peers.leaveChatFolder(folderId: folderId, removePeerIds: Array(selectedItems)) + |> deliverOnMainQueue).start(completed: { + Queue.mainQueue().after(1.0, { + disposable.dispose() + }) + }) + return true + } else if case .undo = value { + disposable.dispose() + return true + } + return false + } + ) + + if let chatListController, chatListController.view.window != nil { + chatListController.present(undoOverlayController, in: .current) + } else { + controller.present(undoOverlayController, in: .window(.root)) + } + + controller.dismiss() + } else if allChatsAdded { + controller.dismiss() + } else if let _ = component.linkContents { + if self.joinDisposable == nil, !self.selectedItems.isEmpty { + let joinSignal: Signal + switch component.subject { + case .linkList, .remove: + return + case let .slug(slug): + joinSignal = component.context.engine.peers.joinChatFolderLink(slug: slug, peerIds: Array(self.selectedItems)) + |> map(Optional.init) + case let .updates(updates): + var result: JoinChatFolderResult? + if let localFilterId = updates.chatFolderLinkContents.localFilterId, let title = updates.chatFolderLinkContents.title { + result = JoinChatFolderResult(folderId: localFilterId, title: title, newChatCount: self.selectedItems.count) + } + joinSignal = component.context.engine.peers.joinAvailableChatsInFolder(updates: updates, peerIds: Array(self.selectedItems)) + |> map { _ -> JoinChatFolderResult? in + } + |> then(Signal.single(result)) + } + + self.inProgress = true + self.state?.updated(transition: .immediate) + + self.joinDisposable = (joinSignal + |> deliverOnMainQueue).start(next: { [weak self] result in + guard let self, let component = self.component, let controller = self.environment?.controller() else { + return + } + + if let result, let navigationController = controller.navigationController as? NavigationController { + var chatListController: ChatListController? + for viewController in navigationController.viewControllers { + if let rootController = viewController as? TabBarController { + for c in rootController.controllers { + if let c = c as? ChatListController { + chatListController = c + break + } + } + } else if let c = viewController as? ChatListController { + chatListController = c + break + } + } + + if let chatListController { + navigationController.popToRoot(animated: true) + let context = component.context + chatListController.navigateToFolder(folderId: result.folderId, completion: { [weak context, weak chatListController] in + guard let context, let chatListController else { + return + } + + let presentationData = context.sharedContext.currentPresentationData.with({ $0 }) + + var isUpdates = false + if case .updates = component.subject { + isUpdates = true + } else { + if component.linkContents?.localFilterId != nil { + isUpdates = true + } + } + + if isUpdates { + chatListController.present(UndoOverlayController(presentationData: presentationData, content: .universal(animation: "anim_add_to_folder", scale: 0.1, colors: ["__allcolors__": UIColor.white], title: presentationData.strings.FolderLinkPreview_ToastChatsAddedTitle(result.title).string, text: presentationData.strings.FolderLinkPreview_ToastChatsAddedText(Int32(result.newChatCount)), customUndoText: nil, timeout: 5), elevatedLayout: false, action: { _ in true }), in: .current) + } else if result.newChatCount != 0 { + let animationBackgroundColor: UIColor + if presentationData.theme.overallDarkAppearance { + animationBackgroundColor = presentationData.theme.rootController.tabBar.backgroundColor + } else { + animationBackgroundColor = UIColor(rgb: 0x474747) + } + chatListController.present(UndoOverlayController(presentationData: presentationData, content: .universal(animation: "anim_success", scale: 1.0, colors: ["info1.info1.stroke": animationBackgroundColor, "info2.info2.Fill": animationBackgroundColor], title: presentationData.strings.FolderLinkPreview_ToastFolderAddedTitle(result.title).string, text: presentationData.strings.FolderLinkPreview_ToastFolderAddedText(Int32(result.newChatCount)), customUndoText: nil, timeout: 5), elevatedLayout: false, action: { _ in true }), in: .current) + } else { + let animationBackgroundColor: UIColor + if presentationData.theme.overallDarkAppearance { + animationBackgroundColor = presentationData.theme.rootController.tabBar.backgroundColor + } else { + animationBackgroundColor = UIColor(rgb: 0x474747) + } + chatListController.present(UndoOverlayController(presentationData: presentationData, content: .universal(animation: "anim_success", scale: 1.0, colors: ["info1.info1.stroke": animationBackgroundColor, "info2.info2.Fill": animationBackgroundColor], title: presentationData.strings.FolderLinkPreview_ToastFolderAddedTitle(result.title).string, text: "", customUndoText: nil, timeout: 5), elevatedLayout: false, action: { _ in true }), in: .current) + } + }) + } + } + + controller.dismiss() + }, error: { [weak self] error in + guard let self, let component = self.component, let controller = self.environment?.controller() else { + return + } + + let context = component.context + let navigationController = controller.navigationController as? NavigationController + + switch error { + case .generic: + controller.dismiss() + case let .dialogFilterLimitExceeded(limit, _): + let limitController = PremiumLimitScreen(context: component.context, subject: .folders, count: limit, action: { [weak navigationController] in + guard let navigationController else { + return + } + navigationController.pushViewController(PremiumIntroScreen(context: context, source: .folders)) + }) + controller.push(limitController) + controller.dismiss() + case let .sharedFolderLimitExceeded(limit, _): + let limitController = PremiumLimitScreen(context: component.context, subject: .membershipInSharedFolders, count: limit, action: { [weak navigationController] in + guard let navigationController else { + return + } + navigationController.pushViewController(PremiumIntroScreen(context: context, source: .membershipInSharedFolders)) + }) + controller.push(limitController) + controller.dismiss() + case let .tooManyChannels(limit, _): + let limitController = PremiumLimitScreen(context: component.context, subject: .chatsPerFolder, count: limit, action: { [weak navigationController] in + guard let navigationController else { + return + } + navigationController.pushViewController(PremiumIntroScreen(context: component.context, source: .chatsPerFolder)) + }) + controller.push(limitController) + controller.dismiss() + case let .tooManyChannelsInAccount(limit, _): + let limitController = PremiumLimitScreen(context: component.context, subject: .channels, count: limit, action: { [weak navigationController] in + guard let navigationController else { + return + } + navigationController.pushViewController(PremiumIntroScreen(context: component.context, source: .groupsAndChannels)) + }) + controller.push(limitController) + controller.dismiss() + } + }) + } else { + controller.dismiss() + } + } + } + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 50.0) + ) + + var bottomPanelHeight: CGFloat = 0.0 + + if case .linkList = component.subject { + bottomPanelHeight += 30.0 + } else { + bottomPanelHeight += 14.0 + environment.safeInsets.bottom + actionButtonSize.height + let actionButtonFrame = CGRect(origin: CGPoint(x: sideInset, y: availableSize.height - bottomPanelHeight), size: actionButtonSize) + if let actionButtonView = self.actionButton.view { + if actionButtonView.superview == nil { + self.addSubview(actionButtonView) + } + transition.setFrame(view: actionButtonView, frame: actionButtonFrame) + } + + transition.setFrame(layer: self.bottomBackgroundLayer, frame: CGRect(origin: CGPoint(x: 0.0, y: availableSize.height - bottomPanelHeight - 8.0), size: CGSize(width: availableSize.width, height: bottomPanelHeight))) + transition.setFrame(layer: self.bottomSeparatorLayer, frame: CGRect(origin: CGPoint(x: 0.0, y: availableSize.height - bottomPanelHeight - 8.0 - UIScreenPixel), size: CGSize(width: availableSize.width, height: UIScreenPixel))) + } + + if let controller = environment.controller() { + let subLayout = ContainerViewLayout( + size: availableSize, metrics: environment.metrics, deviceMetrics: environment.deviceMetrics, intrinsicInsets: UIEdgeInsets(top: 0.0, left: sideInset - 12.0, bottom: bottomPanelHeight, right: sideInset), + safeInsets: UIEdgeInsets(), + additionalInsets: UIEdgeInsets(), + statusBarHeight: nil, + inputHeight: nil, + inputHeightIsInteractivellyChanging: false, + inVoiceOver: false + ) + controller.presentationContext.containerLayoutUpdated(subLayout, transition: transition.containedViewLayoutTransition) + } + + contentHeight += bottomPanelHeight + initialContentHeight += bottomPanelHeight + + let containerInset: CGFloat = environment.statusBarHeight + 10.0 + let topInset: CGFloat = max(0.0, availableSize.height - containerInset - initialContentHeight) + + let scrollContentHeight = max(topInset + contentHeight + containerInset, availableSize.height - containerInset) + + self.itemLayout = ItemLayout(containerSize: availableSize, containerInset: containerInset, bottomInset: environment.safeInsets.bottom, topInset: topInset, contentHeight: scrollContentHeight) + + transition.setFrame(view: self.scrollContentView, frame: CGRect(origin: CGPoint(x: 0.0, y: topInset + containerInset), size: CGSize(width: availableSize.width, height: contentHeight))) + + transition.setPosition(layer: self.backgroundLayer, position: CGPoint(x: availableSize.width / 2.0, y: availableSize.height / 2.0)) + transition.setBounds(layer: self.backgroundLayer, bounds: CGRect(origin: CGPoint(), size: availableSize)) + + let scrollClippingFrame: CGRect + if case .linkList = component.subject { + scrollClippingFrame = CGRect(origin: CGPoint(x: sideInset, y: containerInset + 56.0), size: CGSize(width: availableSize.width - sideInset * 2.0, height: availableSize.height - (containerInset + 56.0) + 1000.0)) + } else { + scrollClippingFrame = CGRect(origin: CGPoint(x: sideInset, y: containerInset + 56.0), size: CGSize(width: availableSize.width - sideInset * 2.0, height: availableSize.height - bottomPanelHeight - 8.0 - (containerInset + 56.0))) + } + transition.setPosition(view: self.scrollContentClippingView, position: scrollClippingFrame.center) + transition.setBounds(view: self.scrollContentClippingView, bounds: CGRect(origin: CGPoint(x: scrollClippingFrame.minX, y: scrollClippingFrame.minY), size: scrollClippingFrame.size)) + + self.ignoreScrolling = true + transition.setFrame(view: self.scrollView, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: availableSize.width, height: availableSize.height))) + let contentSize = CGSize(width: availableSize.width, height: scrollContentHeight) + if contentSize != self.scrollView.contentSize { + self.scrollView.contentSize = contentSize + } + if resetScrolling { + self.scrollView.bounds = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: availableSize) + } + self.ignoreScrolling = false + self.updateScrolling(transition: transition) + + return availableSize + } + + private func openLink(link: ExportedChatFolderLink) { + guard let component = self.component else { + return + } + guard case let .linkList(folderId, _) = component.subject else { + return + } + + let _ = (component.context.engine.peers.currentChatListFilters() + |> deliverOnMainQueue).start(next: { [weak self] filters in + guard let self, let component = self.component else { + return + } + guard let filter = filters.first(where: { $0.id == folderId }) else { + return + } + guard case let .filter(_, title, _, data) = filter else { + return + } + + let peerIds = data.includePeers.peers + let _ = (component.context.engine.data.get( + EngineDataList(peerIds.map(TelegramEngine.EngineData.Item.Peer.Peer.init(id:))) + ) + |> deliverOnMainQueue).start(next: { [weak self] peers in + guard let self, let component = self.component, let controller = self.environment?.controller() else { + return + } + + let peers = peers.compactMap({ peer -> EnginePeer? in + guard let peer else { + return nil + } + if case let .legacyGroup(group) = peer, group.migrationReference != nil { + return nil + } + return peer + }) + + let navigationController = controller.navigationController + controller.push(folderInviteLinkListController(context: component.context, filterId: folderId, title: title, allPeerIds: peers.map(\.id), currentInvitation: link, linkUpdated: { _ in }, presentController: { [weak navigationController] c in + (navigationController?.topViewController as? ViewController)?.present(c, in: .window(.root)) + })) + controller.dismiss() + }) + }) + } + + private func openCreateLink() { + guard let component = self.component else { + return + } + guard case let .linkList(folderId, _) = component.subject else { + return + } + + let _ = (component.context.engine.peers.currentChatListFilters() + |> deliverOnMainQueue).start(next: { [weak self] filters in + guard let self, let component = self.component else { + return + } + guard let filter = filters.first(where: { $0.id == folderId }) else { + return + } + guard case let .filter(_, title, _, data) = filter else { + return + } + + let peerIds = data.includePeers.peers + let _ = (component.context.engine.data.get( + EngineDataList(peerIds.map(TelegramEngine.EngineData.Item.Peer.Peer.init(id:))) + ) + |> deliverOnMainQueue).start(next: { [weak self] peers in + guard let self, let component = self.component, let controller = self.environment?.controller() else { + return + } + + let peers = peers.compactMap({ peer -> EnginePeer? in + guard let peer else { + return nil + } + if case let .legacyGroup(group) = peer, group.migrationReference != nil { + return nil + } + return peer + }) + if peers.allSatisfy({ !canShareLinkToPeer(peer: $0) }) { + let navigationController = controller.navigationController + controller.push(folderInviteLinkListController(context: component.context, filterId: folderId, title: title, allPeerIds: peers.map(\.id), currentInvitation: nil, linkUpdated: { _ in }, presentController: { [weak navigationController] c in + (navigationController?.topViewController as? ViewController)?.present(c, in: .window(.root)) + })) + } else { + var enabledPeerIds: [EnginePeer.Id] = [] + for peer in peers { + if canShareLinkToPeer(peer: peer) { + enabledPeerIds.append(peer.id) + } + } + + let _ = (component.context.engine.peers.exportChatFolder(filterId: folderId, title: "", peerIds: enabledPeerIds) + |> deliverOnMainQueue).start(next: { [weak self] link in + guard let self, let component = self.component, let controller = self.environment?.controller() else { + return + } + + self.linkListItems.insert(link, at: 0) + self.state?.updated(transition: Transition(animation: .curve(duration: 0.3, curve: .easeInOut))) + + let navigationController = controller.navigationController + controller.push(folderInviteLinkListController(context: component.context, filterId: folderId, title: title, allPeerIds: peers.map(\.id), currentInvitation: link, linkUpdated: { [weak self] updatedLink in + guard let self else { + return + } + if let index = self.linkListItems.firstIndex(where: { $0.link == link.link }) { + if let updatedLink { + self.linkListItems[index] = updatedLink + } else { + self.linkListItems.remove(at: index) + } + } else { + if let updatedLink { + self.linkListItems.insert(updatedLink, at: 0) + } + } + self.state?.updated(transition: Transition(animation: .curve(duration: 0.3, curve: .easeInOut))) + }, presentController: { [weak navigationController] c in + (navigationController?.topViewController as? ViewController)?.present(c, in: .window(.root)) + })) + + controller.dismiss() + }, error: { [weak self] error in + guard let self, let component = self.component, let controller = self.environment?.controller() else { + return + } + + let context = component.context + let navigationController = controller.navigationController as? NavigationController + + let presentationData = component.context.sharedContext.currentPresentationData.with { $0 } + + let text: String + switch error { + case .generic: + text = presentationData.strings.ChatListFilter_CreateLinkUnknownError + case let .sharedFolderLimitExceeded(limit, _): + let limitController = component.context.sharedContext.makePremiumLimitController(context: component.context, subject: .membershipInSharedFolders, count: limit, action: { [weak navigationController] in + guard let navigationController else { + return + } + navigationController.pushViewController(PremiumIntroScreen(context: context, source: .membershipInSharedFolders)) + }) + + controller.push(limitController) + + return + case let .limitExceeded(limit, _): + let limitController = component.context.sharedContext.makePremiumLimitController(context: component.context, subject: .linksPerSharedFolder, count: limit, action: { [weak navigationController] in + guard let navigationController else { + return + } + navigationController.pushViewController(PremiumIntroScreen(context: component.context, source: .linksPerSharedFolder)) + }) + controller.push(limitController) + + return + case let .tooManyChannels(limit, _): + let limitController = PremiumLimitScreen(context: component.context, subject: .chatsPerFolder, count: limit, action: { [weak navigationController] in + guard let navigationController else { + return + } + navigationController.pushViewController(PremiumIntroScreen(context: component.context, source: .chatsPerFolder)) + }) + controller.push(limitController) + controller.dismiss() + + return + case let .tooManyChannelsInAccount(limit, _): + let limitController = PremiumLimitScreen(context: component.context, subject: .channels, count: limit, action: { [weak navigationController] in + guard let navigationController else { + return + } + navigationController.pushViewController(PremiumIntroScreen(context: component.context, source: .groupsAndChannels)) + }) + controller.push(limitController) + controller.dismiss() + + return + case .someUserTooManyChannels: + text = presentationData.strings.ChatListFilter_CreateLinkErrorSomeoneHasChannelLimit + } + controller.present(standardTextAlertController(theme: AlertControllerTheme(presentationData: presentationData), title: nil, text: text, actions: [TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_OK, action: {})]), in: .window(.root)) + }) + } + }) + }) + } + } + + func makeView() -> View { + return View(frame: CGRect()) + } + + func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } +} + +public class ShareWithPeersScreen: ViewControllerComponentContainer { + public enum Subject: Equatable { + case slug(String) + case updates(ChatFolderUpdates) + case remove(folderId: Int32, defaultSelectedPeerIds: [EnginePeer.Id]) + case linkList(folderId: Int32, initialLinks: [ExportedChatFolderLink]) + } + + private let context: AccountContext + private var linkContentsDisposable: Disposable? + + private var isDismissed: Bool = false + + public init(context: AccountContext, subject: Subject, contents: ChatFolderLinkContents, completion: (() -> Void)? = nil) { + self.context = context + + super.init(context: context, component: ShareWithPeersScreenComponent(context: context, subject: subject, linkContents: contents, completion: completion), navigationBarAppearance: .none) + + self.statusBar.statusBarStyle = .Ignore + self.navigationPresentation = .flatModal + self.blocksBackgroundWhenInOverlay = true + self.automaticallyControlPresentationContextLayout = false + self.lockOrientation = true + } + + required public init(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + self.linkContentsDisposable?.dispose() + } + + override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { + super.containerLayoutUpdated(layout, transition: transition) + } + + override public func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + self.view.disablesInteractiveModalDismiss = true + + if let componentView = self.node.hostView.componentView as? ShareWithPeersScreenComponent.View { + componentView.animateIn() + } + } + + override public func dismiss(completion: (() -> Void)? = nil) { + if !self.isDismissed { + self.isDismissed = true + + if let componentView = self.node.hostView.componentView as? ShareWithPeersScreenComponent.View { + componentView.animateOut(completion: { [weak self] in + completion?() + self?.dismiss(animated: false) + }) + } else { + self.dismiss(animated: false) + } + } + } +} + +private final class LinkListContextExtractedContentSource: ContextExtractedContentSource { + let keepInPlace: Bool = false + let ignoreContentTouches: Bool = false + let blurBackground: Bool = true + + //let actionsHorizontalAlignment: ContextActionsHorizontalAlignment = .center + + private let contentView: ContextExtractedContentContainingView + + init(contentView: ContextExtractedContentContainingView) { + self.contentView = contentView + } + + func takeView() -> ContextControllerTakeViewInfo? { + return ContextControllerTakeViewInfo(containingItem: .view(self.contentView), contentAreaInScreenSpace: UIScreen.main.bounds) + } + + func putBack() -> ContextControllerPutBackViewInfo? { + return ContextControllerPutBackViewInfo(contentAreaInScreenSpace: UIScreen.main.bounds) + } +} +*/ diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/BUILD b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/BUILD index 0524dc5a4b..27cf910d61 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/BUILD +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/BUILD @@ -45,9 +45,11 @@ swift_library( "//submodules/TelegramUI/Components/LegacyInstantVideoController", "//submodules/TelegramUI/Components/EntityKeyboard", "//submodules/TelegramUI/Components/Stories/StoryFooterPanelComponent", + "//submodules/TelegramUI/Components/ShareWithPeersScreen", "//submodules/TelegramPresentationData", "//submodules/ReactionSelectionNode", "//submodules/ContextUI", + "//submodules/AvatarNode", ], visibility = [ "//visibility:public", diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContainerScreen.swift b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContainerScreen.swift index 9a497ae196..37664ed4ca 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContainerScreen.swift +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContainerScreen.swift @@ -11,31 +11,10 @@ import ShareController import TelegramCore import Postbox import UndoUI -import AttachmentUI -import TelegramUIPreferences -import MediaPickerUI -import LegacyMediaPickerUI -import LocationUI -import ChatEntityKeyboardInputNode -import WebUI -import ChatScheduleTimeController -import TextFormat -import PhoneNumberFormat -import ComposePollUI -import TelegramIntents -import LegacyUI -import WebSearchUI -import ChatTimerScreen -import PremiumUI -import ICloudResources -import LegacyComponents -import LegacyCamera -import StoryFooterPanelComponent -import TelegramPresentationData -import LegacyInstantVideoController import ReactionSelectionNode import EntityKeyboard import AsyncDisplayKit +import AttachmentUI import simd func hasFirstResponder(_ view: UIView) -> Bool { @@ -57,17 +36,20 @@ private final class StoryContainerScreenComponent: Component { let initialFocusedId: AnyHashable? let initialContent: [StoryContentItemSlice] let transitionIn: StoryContainerScreen.TransitionIn? + let transitionOut: (EnginePeer.Id) -> StoryContainerScreen.TransitionOut? init( context: AccountContext, initialFocusedId: AnyHashable?, initialContent: [StoryContentItemSlice], - transitionIn: StoryContainerScreen.TransitionIn? + transitionIn: StoryContainerScreen.TransitionIn?, + transitionOut: @escaping (EnginePeer.Id) -> StoryContainerScreen.TransitionOut? ) { self.context = context self.initialFocusedId = initialFocusedId self.initialContent = initialContent self.transitionIn = transitionIn + self.transitionOut = transitionOut } static func ==(lhs: StoryContainerScreenComponent, rhs: StoryContainerScreenComponent) -> Bool { @@ -128,82 +110,7 @@ private final class StoryContainerScreenComponent: Component { } } - private final class StoryPanRecognizer: UIPanGestureRecognizer { - private let updateIsActive: (Bool) -> Void - private var isActive: Bool = false - private var timer: Foundation.Timer? - - init(target: Any?, action: Selector?, updateIsActive: @escaping (Bool) -> Void) { - self.updateIsActive = updateIsActive - - super.init(target: target, action: action) - } - - override func reset() { - super.reset() - - self.isActive = false - self.timer?.invalidate() - self.timer = nil - } - - override func touchesBegan(_ touches: Set, with event: UIEvent) { - super.touchesBegan(touches, with: event) - - if !self.isActive { - if self.timer == nil { - self.timer = Timer.scheduledTimer(withTimeInterval: 0.2, repeats: false, block: { [weak self] timer in - guard let self, self.timer === timer else { - return - } - self.timer = nil - if !self.isActive { - self.isActive = true - self.updateIsActive(true) - } - }) - } - } - } - - override func touchesEnded(_ touches: Set, with event: UIEvent) { - if self.isActive { - self.isActive = false - self.updateIsActive(false) - - for touch in touches { - if let gestureRecognizers = touch.gestureRecognizers { - for gestureRecognizer in gestureRecognizers { - if gestureRecognizer is UITapGestureRecognizer { - gestureRecognizer.state = .cancelled - } - } - } - } - } - self.timer?.invalidate() - self.timer = nil - - super.touchesEnded(touches, with: event) - } - - override func touchesCancelled(_ touches: Set, with event: UIEvent) { - super.touchesCancelled(touches, with: event) - - if self.isActive { - self.isActive = false - self.updateIsActive(false) - } - self.timer?.invalidate() - self.timer = nil - } - } - - final class View: UIView, UIScrollViewDelegate { - private weak var attachmentController: AttachmentController? - private let controllerNavigationDisposable = MetaDisposable() - private let enqueueMediaMessageDisposable = MetaDisposable() - + final class View: UIView, UIScrollViewDelegate, UIGestureRecognizerDelegate { private var component: StoryContainerScreenComponent? private weak var state: EmptyComponentState? private var environment: ViewControllerComponentContainer.Environment? @@ -213,137 +120,39 @@ private final class StoryContainerScreenComponent: Component { private var visibleItemSetViews: [AnyHashable: ItemSetView] = [:] private var itemSetPanState: ItemSetPanState? - - private var audioRecorderValue: ManagedAudioRecorder? - private var audioRecorder = Promise() - private var audioRecorderDisposable: Disposable? - private var audioRecorderStatusDisposable: Disposable? - - private var videoRecorderValue: InstantVideoController? - private var tempVideoRecorderValue: InstantVideoController? - private var videoRecorder = Promise() - private var videoRecorderDisposable: Disposable? + private var dismissPanState: ItemSetPanState? override init(frame: CGRect) { super.init(frame: frame) self.backgroundColor = .black - self.addGestureRecognizer(StoryPanRecognizer(target: self, action: #selector(self.panGesture(_:)), updateIsActive: { [weak self] value in - guard let self else { - return + let horizontalPanRecognizer = InteractiveTransitionGestureRecognizer(target: self, action: #selector(self.panGesture(_:)), allowedDirections: { [weak self] point in + guard let self, let focusedItemSet = self.focusedItemSet, let itemSetView = self.visibleItemSetViews[focusedItemSet], let itemSetComponentView = itemSetView.view.view as? StoryItemSetContainerComponent.View else { + return [] } - if value { - if self.itemSetPanState == nil { - self.itemSetPanState = ItemSetPanState(fraction: 0.0, didBegin: false) - self.state?.updated(transition: Transition(animation: .curve(duration: 0.25, curve: .easeInOut))) - } - } else { - if let itemSetPanState = self.itemSetPanState, !itemSetPanState.didBegin { - self.itemSetPanState = nil - self.state?.updated(transition: Transition(animation: .curve(duration: 0.25, curve: .easeInOut))) - } - } - })) - - self.audioRecorderDisposable = (self.audioRecorder.get() - |> deliverOnMainQueue).start(next: { [weak self] audioRecorder in - guard let self else { - return - } - if self.audioRecorderValue !== audioRecorder { - self.audioRecorderValue = audioRecorder - self.environment?.controller()?.lockOrientation = audioRecorder != nil - - /*strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: true, { - $0.updatedInputTextPanelState { panelState in - let isLocked = strongSelf.lockMediaRecordingRequestId == strongSelf.beginMediaRecordingRequestId - if let audioRecorder = audioRecorder { - if panelState.mediaRecordingState == nil { - return panelState.withUpdatedMediaRecordingState(.audio(recorder: audioRecorder, isLocked: isLocked)) - } - } else { - if case .waitingForPreview = panelState.mediaRecordingState { - return panelState - } - return panelState.withUpdatedMediaRecordingState(nil) - } - return panelState - } - })*/ - - self.audioRecorderStatusDisposable?.dispose() - self.audioRecorderStatusDisposable = nil - - if let audioRecorder = audioRecorder { - if !audioRecorder.beginWithTone { - HapticFeedback().impact(.light) - } - audioRecorder.start() - self.audioRecorderStatusDisposable = (audioRecorder.recordingState - |> deliverOnMainQueue).start(next: { [weak self] value in - guard let self else { - return - } - if case .stopped = value { - self.stopMediaRecorder() - } - }) - } - - self.state?.updated(transition: Transition(animation: .curve(duration: 0.4, curve: .spring))) + if !itemSetComponentView.isPointInsideContentArea(point: self.convert(point, to: itemSetComponentView)) { + return [] } + return [.left, .right] }) + self.addGestureRecognizer(horizontalPanRecognizer) - self.videoRecorderDisposable = (self.videoRecorder.get() - |> deliverOnMainQueue).start(next: { [weak self] videoRecorder in - guard let self else { - return + let verticalPanRecognizer = InteractiveTransitionGestureRecognizer(target: self, action: #selector(self.dismissPanGesture(_:)), allowedDirections: { [weak self] point in + guard let self, let focusedItemSet = self.focusedItemSet, let itemSetView = self.visibleItemSetViews[focusedItemSet], let itemSetComponentView = itemSetView.view.view as? StoryItemSetContainerComponent.View else { + return [] } - if self.videoRecorderValue !== videoRecorder { - let previousVideoRecorderValue = self.videoRecorderValue - self.videoRecorderValue = videoRecorder - - if let videoRecorder = videoRecorder { - HapticFeedback().impact(.light) - - videoRecorder.onDismiss = { [weak self] isCancelled in - guard let self else { - return - } - //self?.chatDisplayNode.updateRecordedMediaDeleted(isCancelled) - //self?.beginMediaRecordingRequestId += 1 - //self?.lockMediaRecordingRequestId = nil - self.videoRecorder.set(.single(nil)) - } - videoRecorder.onStop = { [weak self] in - guard let self else { - return - } - /*if let strongSelf = self { - strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: true, { - $0.updatedInputTextPanelState { panelState in - return panelState.withUpdatedMediaRecordingState(.video(status: .editing, isLocked: false)) - } - }) - }*/ - let _ = self - //TODO:editing - } - self.environment?.controller()?.present(videoRecorder, in: .window(.root)) - - /*if strongSelf.lockMediaRecordingRequestId == strongSelf.beginMediaRecordingRequestId { - videoRecorder.lockVideo() - }*/ - } - - if let previousVideoRecorderValue { - previousVideoRecorderValue.dismissVideo() - } - - self.state?.updated(transition: .immediate) + if !itemSetComponentView.isPointInsideContentArea(point: self.convert(point, to: itemSetComponentView)) { + return [] } + + return [.down] }) + self.addGestureRecognizer(verticalPanRecognizer) + + let longPressRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(self.longPressGesture(_:))) + longPressRecognizer.delegate = self + self.addGestureRecognizer(longPressRecognizer) } required init?(coder: NSCoder) { @@ -351,10 +160,18 @@ private final class StoryContainerScreenComponent: Component { } deinit { - self.controllerNavigationDisposable.dispose() - self.enqueueMediaMessageDisposable.dispose() - self.audioRecorderDisposable?.dispose() - self.audioRecorderStatusDisposable?.dispose() + } + + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { + guard let focusedItemSet = self.focusedItemSet, let itemSetView = self.visibleItemSetViews[focusedItemSet], let itemSetComponentView = itemSetView.view.view as? StoryItemSetContainerComponent.View else { + return true + } + + if !itemSetComponentView.isPointInsideContentArea(point: touch.location(in: itemSetComponentView)) { + return false + } + + return true } @objc private func panGesture(_ recognizer: UIPanGestureRecognizer) { @@ -441,6 +258,47 @@ private final class StoryContainerScreenComponent: Component { } } + @objc private func dismissPanGesture(_ recognizer: UIPanGestureRecognizer) { + switch recognizer.state { + case .began: + self.dismissPanState = ItemSetPanState(fraction: 0.0, didBegin: true) + self.state?.updated(transition: .immediate) + case .changed: + let translation = recognizer.translation(in: self) + self.dismissPanState = ItemSetPanState(fraction: max(0.0, min(1.0, translation.y / self.bounds.height)), didBegin: true) + self.state?.updated(transition: .immediate) + case .cancelled, .ended: + let translation = recognizer.translation(in: self) + let velocity = recognizer.velocity(in: self) + + self.dismissPanState = nil + self.state?.updated(transition: Transition(animation: .curve(duration: 0.3, curve: .spring))) + + if translation.y > 100.0 || velocity.y > 10.0 { + self.environment?.controller()?.dismiss() + } + default: + break + } + } + + @objc private func longPressGesture(_ recognizer: UILongPressGestureRecognizer) { + switch recognizer.state { + case .began: + if self.itemSetPanState == nil { + self.itemSetPanState = ItemSetPanState(fraction: 0.0, didBegin: false) + self.state?.updated(transition: Transition(animation: .curve(duration: 0.25, curve: .easeInOut))) + } + case .cancelled, .ended: + if let itemSetPanState = self.itemSetPanState, !itemSetPanState.didBegin { + self.itemSetPanState = nil + self.state?.updated(transition: Transition(animation: .curve(duration: 0.25, curve: .easeInOut))) + } + default: + break + } + } + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { for subview in self.subviews.reversed() { if !subview.isUserInteractionEnabled || subview.isHidden || subview.alpha == 0.0 { @@ -478,1673 +336,19 @@ private final class StoryContainerScreenComponent: Component { } func animateOut(completion: @escaping () -> Void) { - self.layer.allowsGroupOpacity = true - self.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, removeOnCompletion: false, completion: { _ in - completion() - }) - } - - private func performSendMessageAction() { - /*guard let component = self.component else { - return - } - guard let focusedItemId = self.focusedItemId, let focusedItem = self.currentSlice?.items.first(where: { $0.id == focusedItemId }) else { - return - } - guard let targetMessageId = focusedItem.targetMessageId else { - return - } - guard let inputPanelView = self.inputPanel.view as? MessageInputPanelComponent.View else { - return - } - - switch inputPanelView.getSendMessageInput() { - case let .text(text): - if !text.isEmpty { - component.context.engine.messages.enqueueOutgoingMessage( - to: targetMessageId.peerId, - replyTo: targetMessageId, - content: .text(text) - ) - inputPanelView.clearSendMessageInput() - self.endEditing(true) - - if let controller = self.environment?.controller() { - let presentationData = component.context.sharedContext.currentPresentationData.with { $0 } - controller.present(UndoOverlayController( - presentationData: presentationData, - content: .succeed(text: "Message Sent"), - elevatedLayout: false, - animateInAsReplacement: false, - action: { _ in return false } - ), in: .current) - } - } - }*/ - } - - private func setMediaRecordingActive(isActive: Bool, isVideo: Bool, sendAction: Bool) { - /*guard let component = self.component else { - return - } - guard let focusedItemId = self.focusedItemId, let focusedItem = self.currentSlice?.items.first(where: { $0.id == focusedItemId }) else { - return - } - guard let targetMessageId = focusedItem.targetMessageId else { - return - } - let _ = (component.context.engine.data.get( - TelegramEngine.EngineData.Item.Messages.Message(id: targetMessageId) - ) - |> deliverOnMainQueue).start(next: { [weak self] targetMessage in - guard let self, let component = self.component, let environment = self.environment, let targetMessage, let peer = targetMessage.author else { - return - } + if let component = self.component, let focusedItemSet = self.focusedItemSet, let peerId = focusedItemSet.base as? EnginePeer.Id, let itemSetView = self.visibleItemSetViews[focusedItemSet], let itemSetComponentView = itemSetView.view.view as? StoryItemSetContainerComponent.View, let transitionOut = component.transitionOut(peerId) { + let currentBackgroundColor = self.layer.presentation()?.backgroundColor ?? self.layer.backgroundColor + self.layer.animate(from: currentBackgroundColor ?? UIColor.black.cgColor, to: UIColor.black.withAlphaComponent(0.0).cgColor, keyPath: "backgroundColor", timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, duration: 0.25, removeOnCompletion: false) - if isActive { - if isVideo { - if self.videoRecorderValue == nil { - if let currentInputPanelFrame = self.inputPanel.view?.frame { - self.videoRecorder.set(.single(legacyInstantVideoController(theme: environment.theme, panelFrame: self.convert(currentInputPanelFrame, to: nil), context: component.context, peerId: peer.id, slowmodeState: nil, hasSchedule: peer.id.namespace != Namespaces.Peer.SecretChat, send: { [weak self] videoController, message in - if let strongSelf = self { - guard let message = message else { - strongSelf.videoRecorder.set(.single(nil)) - return - } - - let replyMessageId = targetMessageId - let correlationId = Int64.random(in: 0 ..< Int64.max) - let updatedMessage = message - .withUpdatedReplyToMessageId(replyMessageId) - .withUpdatedCorrelationId(correlationId) - - strongSelf.videoRecorder.set(.single(nil)) - - strongSelf.sendMessages(peer: peer, messages: [updatedMessage]) - - let presentationData = component.context.sharedContext.currentPresentationData.with { $0 } - strongSelf.environment?.controller()?.present(UndoOverlayController( - presentationData: presentationData, - content: .succeed(text: "Message Sent"), - elevatedLayout: false, - animateInAsReplacement: false, - action: { _ in return false } - ), in: .current) - } - }, displaySlowmodeTooltip: { [weak self] view, rect in - //self?.interfaceInteraction?.displaySlowmodeTooltip(view, rect) - let _ = self - }, presentSchedulePicker: { [weak self] done in - guard let self else { - return - } - self.presentScheduleTimePicker(peer: peer, completion: { time in - done(time) - }) - }))) - } - } - } else { - if self.audioRecorderValue == nil { - self.audioRecorder.set(component.context.sharedContext.mediaManager.audioRecorder(beginWithTone: false, applicationBindings: component.context.sharedContext.applicationBindings, beganWithTone: { _ in - })) - } - } - } else { - if let audioRecorderValue = self.audioRecorderValue { - let _ = (audioRecorderValue.takenRecordedData() - |> deliverOnMainQueue).start(next: { [weak self] data in - guard let self, let component = self.component else { - return - } - - self.audioRecorder.set(.single(nil)) - - guard let data else { - return - } - - if data.duration < 0.5 || !sendAction { - HapticFeedback().error() - } else { - let randomId = Int64.random(in: Int64.min ... Int64.max) - - let resource = LocalFileMediaResource(fileId: randomId) - component.context.account.postbox.mediaBox.storeResourceData(resource.id, data: data.compressedData) - - let waveformBuffer: Data? = data.waveform - - self.sendMessages(peer: peer, messages: [.message(text: "", attributes: [], inlineStickers: [:], mediaReference: .standalone(media: TelegramMediaFile(fileId: EngineMedia.Id(namespace: Namespaces.Media.LocalFile, id: randomId), partialReference: nil, resource: resource, previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "audio/ogg", size: Int64(data.compressedData.count), attributes: [.Audio(isVoice: true, duration: Int(data.duration), title: nil, performer: nil, waveform: waveformBuffer)])), replyToMessageId: targetMessageId, localGroupingKey: nil, correlationId: nil, bubbleUpEmojiOrStickersets: [])]) - - HapticFeedback().tap() - - let presentationData = component.context.sharedContext.currentPresentationData.with { $0 } - self.environment?.controller()?.present(UndoOverlayController( - presentationData: presentationData, - content: .succeed(text: "Message Sent"), - elevatedLayout: false, - animateInAsReplacement: false, - action: { _ in return false } - ), in: .current) - } - }) - } else if let videoRecorderValue = self.videoRecorderValue { - let _ = videoRecorderValue - self.videoRecorder.set(.single(nil)) - } - } - })*/ - } - - private func stopMediaRecorder() { - } - - private func performInlineAction(item: StoryActionsComponent.Item) { - /*guard let component = self.component else { - return - } - guard let focusedItemId = self.focusedItemId, let focusedItem = self.currentSlice?.items.first(where: { $0.id == focusedItemId }) else { - return - } - guard let targetMessageId = focusedItem.targetMessageId else { - return - } - - switch item.kind { - case .like: - if item.isActivated { - component.context.engine.messages.setMessageReactions( - id: targetMessageId, - reactions: [ - ] - ) - } else { - component.context.engine.messages.setMessageReactions( - id: targetMessageId, - reactions: [ - .builtin("❤") - ] - ) - } - case .share: - let _ = (component.context.engine.data.get( - TelegramEngine.EngineData.Item.Messages.Message(id: targetMessageId) - ) - |> deliverOnMainQueue).start(next: { [weak self] message in - guard let self, let message, let component = self.component, let controller = self.environment?.controller() else { - return - } - let shareController = ShareController( - context: component.context, - subject: .messages([message._asMessage()]), - externalShare: false, - immediateExternalShare: false, - updatedPresentationData: (component.context.sharedContext.currentPresentationData.with({ $0 }), - component.context.sharedContext.presentationData) - ) - controller.present(shareController, in: .window(.root)) - }) - }*/ - } - - private func clearInputText() { - /*guard let inputPanelView = self.inputPanel.view as? MessageInputPanelComponent.View else { - return - } - inputPanelView.clearSendMessageInput()*/ - } - - private enum AttachMenuSubject { - case `default` - } - - /*private func presentAttachmentMenu(subject: AttachMenuSubject) { - guard let component = self.component else { - return - } - guard let focusedItemId = self.focusedItemId, let focusedItem = self.currentSlice?.items.first(where: { $0.id == focusedItemId }) else { - return - } - guard let targetMessageId = focusedItem.targetMessageId else { - return - } - guard let inputPanelView = self.inputPanel.view as? MessageInputPanelComponent.View else { - return - } - - var inputText = NSAttributedString(string: "") - switch inputPanelView.getSendMessageInput() { - case let .text(text): - inputText = NSAttributedString(string: text) - } - - let _ = (component.context.engine.data.get( - TelegramEngine.EngineData.Item.Messages.Message(id: targetMessageId) - ) - |> deliverOnMainQueue).start(next: { [weak self] targetMessage in - guard let self, let component = self.component else { - return - } - guard let targetMessage, let peer = targetMessage.author else { - return - } - - let inputIsActive = !"".isEmpty - - self.endEditing(true) - - var banSendText: (Int32, Bool)? - var bannedSendPhotos: (Int32, Bool)? - var bannedSendVideos: (Int32, Bool)? - var bannedSendFiles: (Int32, Bool)? - - let _ = bannedSendFiles - - var canSendPolls = true - if case let .user(peer) = peer, peer.botInfo == nil { - canSendPolls = false - } else if case .secretChat = peer { - canSendPolls = false - } else if case let .channel(channel) = peer { - if let value = channel.hasBannedPermission(.banSendPhotos) { - bannedSendPhotos = value - } - if let value = channel.hasBannedPermission(.banSendVideos) { - bannedSendVideos = value - } - if let value = channel.hasBannedPermission(.banSendFiles) { - bannedSendFiles = value - } - if let value = channel.hasBannedPermission(.banSendText) { - banSendText = value - } - if channel.hasBannedPermission(.banSendPolls) != nil { - canSendPolls = false - } - } else if case let .legacyGroup(group) = peer { - if group.hasBannedPermission(.banSendPhotos) { - bannedSendPhotos = (Int32.max, false) - } - if group.hasBannedPermission(.banSendVideos) { - bannedSendVideos = (Int32.max, false) - } - if group.hasBannedPermission(.banSendFiles) { - bannedSendFiles = (Int32.max, false) - } - if group.hasBannedPermission(.banSendText) { - banSendText = (Int32.max, false) - } - if group.hasBannedPermission(.banSendPolls) { - canSendPolls = false - } - } - - var availableButtons: [AttachmentButtonType] = [.gallery, .file] - if banSendText == nil { - availableButtons.append(.location) - availableButtons.append(.contact) - } - if canSendPolls { - availableButtons.insert(.poll, at: max(0, availableButtons.count - 1)) - } - - let isScheduledMessages = !"".isEmpty - - var peerType: AttachMenuBots.Bot.PeerFlags = [] - if case let .user(user) = peer { - if let _ = user.botInfo { - peerType.insert(.bot) - } else { - peerType.insert(.user) - } - } else if case .legacyGroup = peer { - peerType = .group - } else if case let .channel(channel) = peer { - if case .broadcast = channel.info { - peerType = .channel - } else { - peerType = .group - } - } - - let buttons: Signal<([AttachmentButtonType], [AttachmentButtonType], AttachmentButtonType?), NoError> - if !isScheduledMessages { - buttons = component.context.engine.messages.attachMenuBots() - |> map { attachMenuBots in - var buttons = availableButtons - var allButtons = availableButtons - var initialButton: AttachmentButtonType? - switch subject { - case .default: - initialButton = .gallery - /*case .edit: - break - case .gift: - initialButton = .gift*/ - } - - for bot in attachMenuBots.reversed() { - var peerType = peerType - if bot.peer.id == peer.id { - peerType.insert(.sameBot) - peerType.remove(.bot) - } - let button: AttachmentButtonType = .app(bot.peer, bot.shortName, bot.icons) - if !bot.peerTypes.intersection(peerType).isEmpty { - buttons.insert(button, at: 1) - - /*if case let .bot(botId, _, _) = subject { - if initialButton == nil && bot.peer.id == botId { - initialButton = button - } - }*/ - } - allButtons.insert(button, at: 1) - } - - return (buttons, allButtons, initialButton) - } - } else { - buttons = .single((availableButtons, availableButtons, .gallery)) - } - - let dataSettings = component.context.sharedContext.accountManager.transaction { transaction -> GeneratedMediaStoreSettings in - let entry = transaction.getSharedData(ApplicationSpecificSharedDataKeys.generatedMediaStoreSettings)?.get(GeneratedMediaStoreSettings.self) - return entry ?? GeneratedMediaStoreSettings.defaultSettings - } - - let premiumConfiguration = PremiumConfiguration.with(appConfiguration: component.context.currentAppConfiguration.with { $0 }) - let premiumGiftOptions: [CachedPremiumGiftOption] - if !premiumConfiguration.isPremiumDisabled && premiumConfiguration.showPremiumGiftInAttachMenu, case let .user(user) = peer, !user.isPremium && !user.isDeleted && user.botInfo == nil && !user.flags.contains(.isSupport) { - premiumGiftOptions = []//self.presentationInterfaceState.premiumGiftOptions - //TODO:premium gift options - } else { - premiumGiftOptions = [] - } - - let _ = combineLatest(queue: Queue.mainQueue(), buttons, dataSettings).start(next: { [weak self] buttonsAndInitialButton, dataSettings in - guard let self, let component = self.component, let environment = self.environment else { - return - } - - var (buttons, allButtons, initialButton) = buttonsAndInitialButton - if !premiumGiftOptions.isEmpty { - buttons.insert(.gift, at: 1) - } - let _ = allButtons - - guard let initialButton = initialButton else { - /*if case let .bot(botId, botPayload, botJustInstalled) = subject { - if let button = allButtons.first(where: { button in - if case let .app(botPeer, _, _) = button, botPeer.id == botId { - return true - } else { - return false - } - }), case let .app(_, botName, _) = button { - let presentationData = component.context.sharedContext.currentPresentationData.with { $0 } - self.environment?.controller().present(UndoOverlayController(presentationData: presentationData, content: .info(title: nil, text: botJustInstalled ? presentationData.strings.WebApp_AddToAttachmentSucceeded(botName).string : presentationData.strings.WebApp_AddToAttachmentAlreadyAddedError, timeout: nil), elevatedLayout: false, action: { _ in return false }), in: .current) - } else { - let _ = (context.engine.messages.getAttachMenuBot(botId: botId) - |> deliverOnMainQueue).start(next: { [weak self] bot in - guard let self, let component = self.component else { - return - } - - let peer = EnginePeer(bot.peer) - - let presentationData = component.context.sharedContext.currentPresentationData.with { $0 } - let controller = addWebAppToAttachmentController(context: context, peerName: peer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder), icons: bot.icons, requestWriteAccess: bot.flags.contains(.requiresWriteAccess), completion: { allowWrite in - let _ = (context.engine.messages.addBotToAttachMenu(botId: botId, allowWrite: allowWrite) - |> deliverOnMainQueue).start(error: { _ in - }, completed: { - //TODO:present attachment bot - //strongSelf.presentAttachmentBot(botId: botId, payload: botPayload, justInstalled: true) - }) - }) - self.environment?.controller().present(controller, in: .window(.root)) - }, error: { [weak self] _ in - guard let self, let component = self.component else { - return - } - let presentationData = component.context.sharedContext.currentPresentationData.with { $0 } - self.environment?.controller().present(textAlertController(context: context, updatedPresentationData: nil, title: nil, text: presentationData.strings.Login_UnknownError, actions: [TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_OK, action: {})]), in: .window(.root)) - }) - } - }*/ - return - } - - let currentMediaController = Atomic(value: nil) - let currentFilesController = Atomic(value: nil) - let currentLocationController = Atomic(value: nil) - - let theme = environment.theme - let attachmentController = AttachmentController( - context: component.context, - updatedPresentationData: (component.context.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: theme), component.context.sharedContext.presentationData |> map { $0.withUpdated(theme: theme) }), - chatLocation: .peer(id: peer.id), - buttons: buttons, - initialButton: initialButton, - makeEntityInputView: { [weak self] in - guard let self, let component = self.component else { - return nil - } - return EntityInputView( - context: component.context, - isDark: true, - areCustomEmojiEnabled: true //TODO:check custom emoji - ) - } - ) - attachmentController.didDismiss = { [weak self] in - guard let self else { - return - } - self.attachmentController = nil - self.updateIsProgressPaused() - } - attachmentController.getSourceRect = { [weak self] in - guard let self else { - return nil - } - guard let inputPanelView = self.inputPanel.view as? MessageInputPanelComponent.View else { - return nil - } - guard let attachmentButtonView = inputPanelView.getAttachmentButtonView() else { - return nil - } - return attachmentButtonView.convert(attachmentButtonView.bounds, to: self) - } - attachmentController.requestController = { [weak self, weak attachmentController] type, completion in - guard let self, let environment = self.environment else { - return - } - switch type { - case .gallery: - self.controllerNavigationDisposable.set(nil) - let existingController = currentMediaController.with { $0 } - if let controller = existingController { - completion(controller, controller.mediaPickerContext) - controller.prepareForReuse() - return - } - self.presentMediaPicker( - peer: peer, - replyToMessageId: targetMessageId, - saveEditedPhotos: dataSettings.storeEditedPhotos, - bannedSendPhotos: bannedSendPhotos, - bannedSendVideos: bannedSendVideos, - present: { controller, mediaPickerContext in - let _ = currentMediaController.swap(controller) - if !inputText.string.isEmpty { - mediaPickerContext?.setCaption(inputText) - } - completion(controller, mediaPickerContext) - }, updateMediaPickerContext: { [weak attachmentController] mediaPickerContext in - attachmentController?.mediaPickerContext = mediaPickerContext - }, completion: { [weak self] signals, silentPosting, scheduleTime, getAnimatedTransitionSource, completion in - guard let self else { - return - } - if !inputText.string.isEmpty { - self.clearInputText() - } - self.enqueueMediaMessages(peer: peer, replyToMessageId: targetMessageId, signals: signals, silentPosting: silentPosting, scheduleTime: scheduleTime, getAnimatedTransitionSource: getAnimatedTransitionSource, completion: completion) - } - ) - case .file: - self.controllerNavigationDisposable.set(nil) - let existingController = currentFilesController.with { $0 } - if let controller = existingController as? AttachmentContainable, let mediaPickerContext = controller.mediaPickerContext { - completion(controller, mediaPickerContext) - controller.prepareForReuse() - return - } - let theme = environment.theme - let controller = component.context.sharedContext.makeAttachmentFileController(context: component.context, updatedPresentationData: (component.context.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: theme), component.context.sharedContext.presentationData |> map { $0.withUpdated(theme: theme) }), bannedSendMedia: bannedSendFiles, presentGallery: { [weak self, weak attachmentController] in - guard let self else { - return - } - attachmentController?.dismiss(animated: true) - self.presentFileGallery(peer: peer, replyMessageId: targetMessageId) - }, presentFiles: { [weak self, weak attachmentController] in - guard let self else { - return - } - attachmentController?.dismiss(animated: true) - self.presentICloudFileGallery(peer: peer, replyMessageId: targetMessageId) - }, send: { [weak self] mediaReference in - guard let self, let component = self.component else { - return - } - let message: EnqueueMessage = .message(text: "", attributes: [], inlineStickers: [:], mediaReference: mediaReference, replyToMessageId: nil, localGroupingKey: nil, correlationId: nil, bubbleUpEmojiOrStickersets: []) - let _ = (enqueueMessages(account: component.context.account, peerId: peer.id, messages: [message.withUpdatedReplyToMessageId(targetMessageId)]) - |> deliverOnMainQueue).start() - - if let controller = self.environment?.controller() { - let presentationData = component.context.sharedContext.currentPresentationData.with { $0 } - controller.present(UndoOverlayController( - presentationData: presentationData, - content: .succeed(text: "Message Sent"), - elevatedLayout: false, - animateInAsReplacement: false, - action: { _ in return false } - ), in: .current) - } - }) - let _ = currentFilesController.swap(controller) - if let controller = controller as? AttachmentContainable, let mediaPickerContext = controller.mediaPickerContext { - completion(controller, mediaPickerContext) - } - case .location: - self.controllerNavigationDisposable.set(nil) - let existingController = currentLocationController.with { $0 } - if let controller = existingController { - completion(controller, controller.mediaPickerContext) - controller.prepareForReuse() - return - } - let selfPeerId: EnginePeer.Id - if case let .channel(peer) = peer, case .broadcast = peer.info { - selfPeerId = peer.id - } else if case let .channel(peer) = peer, case .group = peer.info, peer.hasPermission(.canBeAnonymous) { - selfPeerId = peer.id - } else { - selfPeerId = component.context.account.peerId - } - let _ = (component.context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: selfPeerId)) - |> deliverOnMainQueue).start(next: { [weak self] selfPeer in - guard let self, let component = self.component, let environment = self.environment, let selfPeer else { - return - } - let hasLiveLocation = peer.id.namespace != Namespaces.Peer.SecretChat && peer.id != component.context.account.peerId - let theme = environment.theme - let controller = LocationPickerController(context: component.context, updatedPresentationData: (component.context.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: theme), component.context.sharedContext.presentationData |> map { $0.withUpdated(theme: theme) }), mode: .share(peer: peer, selfPeer: selfPeer, hasLiveLocation: hasLiveLocation), completion: { [weak self] location, _ in - guard let self else { - return - } - let message: EnqueueMessage = .message(text: "", attributes: [], inlineStickers: [:], mediaReference: .standalone(media: location), replyToMessageId: targetMessageId, localGroupingKey: nil, correlationId: nil, bubbleUpEmojiOrStickersets: []) - self.sendMessages(peer: peer, messages: [message]) - }) - completion(controller, controller.mediaPickerContext) - - let _ = currentLocationController.swap(controller) - }) - case .contact: - let theme = environment.theme - let contactsController = component.context.sharedContext.makeContactSelectionController(ContactSelectionControllerParams(context: component.context, updatedPresentationData: (component.context.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: theme), component.context.sharedContext.presentationData |> map { $0.withUpdated(theme: theme) }), title: { $0.Contacts_Title }, displayDeviceContacts: true, multipleSelection: true)) - contactsController.presentScheduleTimePicker = { [weak self] completion in - guard let self else { - return - } - self.presentScheduleTimePicker(peer: peer, completion: completion) - } - contactsController.navigationPresentation = .modal - if let contactsController = contactsController as? AttachmentContainable, let mediaPickerContext = contactsController.mediaPickerContext { - completion(contactsController, mediaPickerContext) - } - self.controllerNavigationDisposable.set((contactsController.result - |> deliverOnMainQueue).start(next: { [weak self] peers in - guard let self, let (peers, _, silent, scheduleTime, text) = peers else { - return - } - - let targetPeer = peer - - var textEnqueueMessage: EnqueueMessage? - if let text = text, text.length > 0 { - var attributes: [EngineMessage.Attribute] = [] - let entities = generateTextEntities(text.string, enabledTypes: .all, currentEntities: generateChatInputTextEntities(text)) - if !entities.isEmpty { - attributes.append(TextEntitiesMessageAttribute(entities: entities)) - } - textEnqueueMessage = .message(text: text.string, attributes: attributes, inlineStickers: [:], mediaReference: nil, replyToMessageId: nil, localGroupingKey: nil, correlationId: nil, bubbleUpEmojiOrStickersets: []) - } - if peers.count > 1 { - var enqueueMessages: [EnqueueMessage] = [] - if let textEnqueueMessage = textEnqueueMessage { - enqueueMessages.append(textEnqueueMessage) - } - for peer in peers { - var media: TelegramMediaContact? - switch peer { - case let .peer(contact, _, _): - guard let contact = contact as? TelegramUser, let phoneNumber = contact.phone else { - continue - } - let contactData = DeviceContactExtendedData(basicData: DeviceContactBasicData(firstName: contact.firstName ?? "", lastName: contact.lastName ?? "", phoneNumbers: [DeviceContactPhoneNumberData(label: "_$!!$_", value: phoneNumber)]), middleName: "", prefix: "", suffix: "", organization: "", jobTitle: "", department: "", emailAddresses: [], urls: [], addresses: [], birthdayDate: nil, socialProfiles: [], instantMessagingProfiles: [], note: "") - - let phone = contactData.basicData.phoneNumbers[0].value - media = TelegramMediaContact(firstName: contactData.basicData.firstName, lastName: contactData.basicData.lastName, phoneNumber: phone, peerId: contact.id, vCardData: nil) - case let .deviceContact(_, basicData): - guard !basicData.phoneNumbers.isEmpty else { - continue - } - let contactData = DeviceContactExtendedData(basicData: basicData, middleName: "", prefix: "", suffix: "", organization: "", jobTitle: "", department: "", emailAddresses: [], urls: [], addresses: [], birthdayDate: nil, socialProfiles: [], instantMessagingProfiles: [], note: "") - - let phone = contactData.basicData.phoneNumbers[0].value - media = TelegramMediaContact(firstName: contactData.basicData.firstName, lastName: contactData.basicData.lastName, phoneNumber: phone, peerId: nil, vCardData: nil) - } - - if let media = media { - let replyMessageId = targetMessageId - /*strongSelf.chatDisplayNode.setupSendActionOnViewUpdate({ - if let strongSelf = self { - strongSelf.chatDisplayNode.collapseInput() - - strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: false, { - $0.updatedInterfaceState { $0.withUpdatedReplyMessageId(nil) } - }) - } - }, nil)*/ - let message = EnqueueMessage.message(text: "", attributes: [], inlineStickers: [:], mediaReference: .standalone(media: media), replyToMessageId: replyMessageId, localGroupingKey: nil, correlationId: nil, bubbleUpEmojiOrStickersets: []) - enqueueMessages.append(message) - } - } - - self.sendMessages(peer: peer, messages: self.transformEnqueueMessages(messages: enqueueMessages, silentPosting: silent, scheduleTime: scheduleTime)) - } else if let peer = peers.first { - let dataSignal: Signal<(EnginePeer?, DeviceContactExtendedData?), NoError> - switch peer { - case let .peer(contact, _, _): - guard let contact = contact as? TelegramUser, let phoneNumber = contact.phone else { - return - } - let contactData = DeviceContactExtendedData(basicData: DeviceContactBasicData(firstName: contact.firstName ?? "", lastName: contact.lastName ?? "", phoneNumbers: [DeviceContactPhoneNumberData(label: "_$!!$_", value: phoneNumber)]), middleName: "", prefix: "", suffix: "", organization: "", jobTitle: "", department: "", emailAddresses: [], urls: [], addresses: [], birthdayDate: nil, socialProfiles: [], instantMessagingProfiles: [], note: "") - let context = component.context - dataSignal = (component.context.sharedContext.contactDataManager?.basicData() ?? .single([:])) - |> take(1) - |> mapToSignal { basicData -> Signal<(EnginePeer?, DeviceContactExtendedData?), NoError> in - var stableId: String? - let queryPhoneNumber = formatPhoneNumber(context: context, number: phoneNumber) - outer: for (id, data) in basicData { - for phoneNumber in data.phoneNumbers { - if formatPhoneNumber(context: context, number: phoneNumber.value) == queryPhoneNumber { - stableId = id - break outer - } - } - } - - if let stableId = stableId { - return (context.sharedContext.contactDataManager?.extendedData(stableId: stableId) ?? .single(nil)) - |> take(1) - |> map { extendedData -> (EnginePeer?, DeviceContactExtendedData?) in - return (EnginePeer(contact), extendedData) - } - } else { - return .single((EnginePeer(contact), contactData)) - } - } - case let .deviceContact(id, _): - dataSignal = (component.context.sharedContext.contactDataManager?.extendedData(stableId: id) ?? .single(nil)) - |> take(1) - |> map { extendedData -> (EnginePeer?, DeviceContactExtendedData?) in - return (nil, extendedData) - } - } - self.controllerNavigationDisposable.set((dataSignal - |> deliverOnMainQueue).start(next: { [weak self] peerAndContactData in - guard let self, let contactData = peerAndContactData.1, contactData.basicData.phoneNumbers.count != 0 else { - return - } - if contactData.isPrimitive { - let phone = contactData.basicData.phoneNumbers[0].value - let media = TelegramMediaContact(firstName: contactData.basicData.firstName, lastName: contactData.basicData.lastName, phoneNumber: phone, peerId: peerAndContactData.0?.id, vCardData: nil) - let replyMessageId = targetMessageId - /*strongSelf.chatDisplayNode.setupSendActionOnViewUpdate({ - if let strongSelf = self { - strongSelf.chatDisplayNode.collapseInput() - - strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: false, { - $0.updatedInterfaceState { $0.withUpdatedReplyMessageId(nil) } - }) - } - }, nil)*/ - - var enqueueMessages: [EnqueueMessage] = [] - if let textEnqueueMessage = textEnqueueMessage { - enqueueMessages.append(textEnqueueMessage) - } - enqueueMessages.append(.message(text: "", attributes: [], inlineStickers: [:], mediaReference: .standalone(media: media), replyToMessageId: replyMessageId, localGroupingKey: nil, correlationId: nil, bubbleUpEmojiOrStickersets: [])) - - self.sendMessages(peer: targetPeer, messages: self.transformEnqueueMessages(messages: enqueueMessages, silentPosting: silent, scheduleTime: scheduleTime)) - } else { - let contactController = component.context.sharedContext.makeDeviceContactInfoController(context: component.context, subject: .filter(peer: peerAndContactData.0?._asPeer(), contactId: nil, contactData: contactData, completion: { [weak self] peer, contactData in - guard let self else { - return - } - if contactData.basicData.phoneNumbers.isEmpty { - return - } - let phone = contactData.basicData.phoneNumbers[0].value - if let vCardData = contactData.serializedVCard() { - let media = TelegramMediaContact(firstName: contactData.basicData.firstName, lastName: contactData.basicData.lastName, phoneNumber: phone, peerId: peer?.id, vCardData: vCardData) - let replyMessageId = targetMessageId - /*strongSelf.chatDisplayNode.setupSendActionOnViewUpdate({ - if let strongSelf = self { - strongSelf.chatDisplayNode.collapseInput() - - strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: false, { - $0.updatedInterfaceState { $0.withUpdatedReplyMessageId(nil) } - }) - } - }, nil)*/ - - var enqueueMessages: [EnqueueMessage] = [] - if let textEnqueueMessage = textEnqueueMessage { - enqueueMessages.append(textEnqueueMessage) - } - enqueueMessages.append(.message(text: "", attributes: [], inlineStickers: [:], mediaReference: .standalone(media: media), replyToMessageId: replyMessageId, localGroupingKey: nil, correlationId: nil, bubbleUpEmojiOrStickersets: [])) - - self.sendMessages(peer: targetPeer, messages: self.transformEnqueueMessages(messages: enqueueMessages, silentPosting: silent, scheduleTime: scheduleTime)) - } - }), completed: nil, cancelled: nil) - self.environment?.controller()?.push(contactController) - } - })) - } - })) - case .poll: - let controller = self.configurePollCreation(peer: peer, targetMessageId: targetMessageId) - completion(controller, controller?.mediaPickerContext) - self.controllerNavigationDisposable.set(nil) - case .gift: - /*let premiumGiftOptions = strongSelf.presentationInterfaceState.premiumGiftOptions - if !premiumGiftOptions.isEmpty { - let controller = PremiumGiftScreen(context: context, peerId: peer.id, options: premiumGiftOptions, source: .attachMenu, pushController: { [weak self] c in - if let strongSelf = self { - strongSelf.push(c) - } - }, completion: { [weak self] in - if let strongSelf = self { - strongSelf.hintPlayNextOutgoingGift() - strongSelf.attachmentController?.dismiss(animated: true) - } - }) - completion(controller, controller.mediaPickerContext) - strongSelf.controllerNavigationDisposable.set(nil) - - let _ = ApplicationSpecificNotice.incrementDismissedPremiumGiftSuggestion(accountManager: context.sharedContext.accountManager, peerId: peer.id).start() - }*/ - //TODO:gift controller - break - case let .app(bot, botName, _): - var payload: String? - var fromAttachMenu = true - /*if case let .bot(_, botPayload, _) = subject { - payload = botPayload - fromAttachMenu = false - }*/ - payload = nil - fromAttachMenu = true - let params = WebAppParameters(peerId: peer.id, botId: bot.id, botName: botName, url: nil, queryId: nil, payload: payload, buttonText: nil, keepAliveSignal: nil, fromMenu: false, fromAttachMenu: fromAttachMenu, isInline: false, isSimple: false) - let replyMessageId = targetMessageId - let theme = environment.theme - let controller = WebAppController(context: component.context, updatedPresentationData: (component.context.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: theme), component.context.sharedContext.presentationData |> map { $0.withUpdated(theme: theme) }), params: params, replyToMessageId: replyMessageId, threadId: nil) - controller.openUrl = { [weak self] url in - guard let self else { - return - } - let _ = self - //self?.openUrl(url, concealed: true, forceExternal: true) - } - controller.getNavigationController = { [weak self] in - guard let self, let controller = self.environment?.controller() else { - return nil - } - return controller.navigationController as? NavigationController - } - controller.completion = { [weak self] in - guard let self else { - return - } - let _ = self - /*if let strongSelf = self { - strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: false, { - $0.updatedInterfaceState { $0.withUpdatedReplyMessageId(nil) } - }) - strongSelf.chatDisplayNode.historyNode.scrollToEndOfHistory() - }*/ - } - completion(controller, controller.mediaPickerContext) - self.controllerNavigationDisposable.set(nil) - default: - break - } - } - let present = { [weak self] in - guard let self, let controller = self.environment?.controller() else { - return - } - attachmentController.navigationPresentation = .flatModal - controller.push(attachmentController) - self.attachmentController = attachmentController - self.updateIsProgressPaused() - } - - if inputIsActive { - Queue.mainQueue().after(0.15, { - present() - }) - } else { - present() - } - }) - }) - } - - private func presentMediaPicker( - peer: EnginePeer, - replyToMessageId: EngineMessage.Id?, - subject: MediaPickerScreen.Subject = .assets(nil, .default), - saveEditedPhotos: Bool, - bannedSendPhotos: (Int32, Bool)?, - bannedSendVideos: (Int32, Bool)?, - present: @escaping (MediaPickerScreen, AttachmentMediaPickerContext?) -> Void, - updateMediaPickerContext: @escaping (AttachmentMediaPickerContext?) -> Void, - completion: @escaping ([Any], Bool, Int32?, @escaping (String) -> UIView?, @escaping () -> Void) -> Void - ) { - guard let component = self.component, let environment = self.environment else { - return - } - let theme = environment.theme - let controller = MediaPickerScreen(context: component.context, updatedPresentationData: (component.context.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: theme), component.context.sharedContext.presentationData |> map { $0.withUpdated(theme: theme) }), peer: peer, threadTitle: nil, chatLocation: .peer(id: peer.id), bannedSendPhotos: bannedSendPhotos, bannedSendVideos: bannedSendVideos, subject: subject, saveEditedPhotos: saveEditedPhotos) - let mediaPickerContext = controller.mediaPickerContext - controller.openCamera = { [weak self] cameraView in - guard let self else { - return - } - self.openCamera(peer: peer, replyToMessageId: replyToMessageId, cameraView: cameraView) - } - controller.presentWebSearch = { [weak self, weak controller] mediaGroups, activateOnDisplay in - guard let self, let controller else { - return - } - self.presentWebSearch(editingMessage: false, attachment: true, activateOnDisplay: activateOnDisplay, present: { [weak controller] c, a in - controller?.present(c, in: .current) - if let webSearchController = c as? WebSearchController { - webSearchController.searchingUpdated = { [weak mediaGroups] searching in - if let mediaGroups = mediaGroups, mediaGroups.isNodeLoaded { - let transition = ContainedViewLayoutTransition.animated(duration: 0.2, curve: .easeInOut) - transition.updateAlpha(node: mediaGroups.displayNode, alpha: searching ? 0.0 : 1.0) - mediaGroups.displayNode.isUserInteractionEnabled = !searching - } - } - webSearchController.present(mediaGroups, in: .current) - webSearchController.dismissed = { - updateMediaPickerContext(mediaPickerContext) - } - controller?.webSearchController = webSearchController - updateMediaPickerContext(webSearchController.mediaPickerContext) - } + itemSetComponentView.animateOut(transitionOut: transitionOut, completion: completion) + } else { + self.layer.allowsGroupOpacity = true + self.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, removeOnCompletion: false, completion: { _ in + completion() }) } - controller.presentSchedulePicker = { [weak self] media, done in - guard let self else { - return - } - self.presentScheduleTimePicker(peer: peer, style: media ? .media : .default, completion: { time in - done(time) - }) - } - controller.presentTimerPicker = { [weak self] done in - guard let self else { - return - } - self.presentTimerPicker(peer: peer, style: .media, completion: { time in - done(time) - }) - } - controller.getCaptionPanelView = { [weak self] in - guard let self else { - return nil - } - return self.getCaptionPanelView(peer: peer) - } - controller.legacyCompletion = { signals, silently, scheduleTime, getAnimatedTransitionSource, sendCompletion in - completion(signals, silently, scheduleTime, getAnimatedTransitionSource, sendCompletion) - } - present(controller, mediaPickerContext) } - private func presentOldMediaPicker(peer: EnginePeer, replyMessageId: EngineMessage.Id?, fileMode: Bool, editingMedia: Bool, present: @escaping (AttachmentContainable, AttachmentMediaPickerContext) -> Void, completion: @escaping ([Any], Bool, Int32) -> Void) { - guard let component = self.component else { - return - } - guard let inputPanelView = self.inputPanel.view as? MessageInputPanelComponent.View else { - return - } - var inputText = NSAttributedString(string: "") - switch inputPanelView.getSendMessageInput() { - case let .text(text): - inputText = NSAttributedString(string: text) - } - - let engine = component.context.engine - let _ = (component.context.sharedContext.accountManager.transaction { transaction -> Signal<(GeneratedMediaStoreSettings, EngineConfiguration.SearchBots), NoError> in - let entry = transaction.getSharedData(ApplicationSpecificSharedDataKeys.generatedMediaStoreSettings)?.get(GeneratedMediaStoreSettings.self) - - return engine.data.get(TelegramEngine.EngineData.Item.Configuration.SearchBots()) - |> map { configuration -> (GeneratedMediaStoreSettings, EngineConfiguration.SearchBots) in - return (entry ?? GeneratedMediaStoreSettings.defaultSettings, configuration) - } - } - |> switchToLatest - |> deliverOnMainQueue).start(next: { [weak self] settings, searchBotsConfiguration in - guard let strongSelf = self, let component = strongSelf.component else { - return - } - var selectionLimit: Int = 100 - var slowModeEnabled = false - if case let .channel(channel) = peer, channel.isRestrictedBySlowmode { - selectionLimit = 10 - slowModeEnabled = true - } - - let presentationData = component.context.sharedContext.currentPresentationData.with { $0 } - - let _ = legacyAssetPicker(context: component.context, presentationData: presentationData, editingMedia: editingMedia, fileMode: fileMode, peer: peer._asPeer(), threadTitle: nil, saveEditedPhotos: settings.storeEditedPhotos, allowGrouping: true, selectionLimit: selectionLimit).start(next: { generator in - if let strongSelf = self, let component = strongSelf.component, let controller = strongSelf.environment?.controller() { - let presentationData = component.context.sharedContext.currentPresentationData.with { $0 } - - let legacyController = LegacyController(presentation: fileMode ? .navigation : .custom, theme: presentationData.theme, initialLayout: controller.currentlyAppliedLayout) - legacyController.navigationPresentation = .modal - legacyController.statusBar.statusBarStyle = presentationData.theme.rootController.statusBarStyle.style - legacyController.controllerLoaded = { [weak legacyController] in - legacyController?.view.disablesInteractiveTransitionGestureRecognizer = true - legacyController?.view.disablesInteractiveModalDismiss = true - } - let controller = generator(legacyController.context) - - legacyController.bind(controller: controller) - legacyController.deferScreenEdgeGestures = [.top] - - configureLegacyAssetPicker(controller, context: component.context, peer: peer._asPeer(), chatLocation: .peer(id: peer.id), initialCaption: inputText, hasSchedule: peer.id.namespace != Namespaces.Peer.SecretChat, presentWebSearch: editingMedia ? nil : { [weak legacyController] in - if let strongSelf = self, let component = strongSelf.component, let environment = strongSelf.environment { - let theme = environment.theme - let controller = WebSearchController(context: component.context, updatedPresentationData: (component.context.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: theme), component.context.sharedContext.presentationData |> map { $0.withUpdated(theme: theme) }), peer: peer, chatLocation: .peer(id: peer.id), configuration: searchBotsConfiguration, mode: .media(attachment: false, completion: { results, selectionState, editingState, silentPosting in - if let legacyController = legacyController { - legacyController.dismiss() - } - legacyEnqueueWebSearchMessages(selectionState, editingState, enqueueChatContextResult: { result in - if let strongSelf = self { - strongSelf.enqueueChatContextResult(peer: peer, replyMessageId: replyMessageId, results: results, result: result, hideVia: true) - } - }, enqueueMediaMessages: { signals in - if let strongSelf = self { - if editingMedia { - strongSelf.editMessageMediaWithLegacySignals(signals) - } else { - strongSelf.enqueueMediaMessages(peer: peer, replyToMessageId: replyMessageId, signals: signals, silentPosting: silentPosting) - } - } - }) - })) - controller.getCaptionPanelView = { - guard let self else { - return nil - } - return self.getCaptionPanelView(peer: peer) - } - strongSelf.environment?.controller()?.push(controller) - } - }, presentSelectionLimitExceeded: { - guard let strongSelf = self else { - return - } - - let text: String - if slowModeEnabled { - text = presentationData.strings.Chat_SlowmodeAttachmentLimitReached - } else { - text = presentationData.strings.Chat_AttachmentLimitReached - } - - strongSelf.environment?.controller()?.present(standardTextAlertController(theme: AlertControllerTheme(presentationData: presentationData), title: nil, text: text, actions: [TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_OK, action: {})]), in: .window(.root)) - }, presentSchedulePicker: { media, done in - if let strongSelf = self { - strongSelf.presentScheduleTimePicker(peer: peer, style: media ? .media : .default, completion: { time in - done(time) - }) - } - }, presentTimerPicker: { done in - if let strongSelf = self { - strongSelf.presentTimerPicker(peer: peer, style: .media, completion: { time in - done(time) - }) - } - }, getCaptionPanelView: { - guard let self else { - return nil - } - return self.getCaptionPanelView(peer: peer) - }) - controller.descriptionGenerator = legacyAssetPickerItemGenerator() - controller.completionBlock = { [weak legacyController] signals, silentPosting, scheduleTime in - if let legacyController = legacyController { - legacyController.dismiss(animated: true) - completion(signals!, silentPosting, scheduleTime) - } - } - controller.dismissalBlock = { [weak legacyController] in - if let legacyController = legacyController { - legacyController.dismiss(animated: true) - } - } - strongSelf.endEditing(true) - present(legacyController, LegacyAssetPickerContext(controller: controller)) - } - }) - }) - } - - private func presentFileGallery(peer: EnginePeer, replyMessageId: EngineMessage.Id?, editingMessage: Bool = false) { - self.presentOldMediaPicker(peer: peer, replyMessageId: replyMessageId, fileMode: true, editingMedia: editingMessage, present: { [weak self] c, _ in - self?.environment?.controller()?.push(c) - }, completion: { [weak self] signals, silentPosting, scheduleTime in - if editingMessage { - self?.editMessageMediaWithLegacySignals(signals) - } else { - self?.enqueueMediaMessages(peer: peer, replyToMessageId: replyMessageId, signals: signals, silentPosting: silentPosting, scheduleTime: scheduleTime > 0 ? scheduleTime : nil) - } - }) - } - - private func presentICloudFileGallery(peer: EnginePeer, replyMessageId: EngineMessage.Id?) { - guard let component = self.component else { - return - } - let _ = (component.context.engine.data.get( - TelegramEngine.EngineData.Item.Peer.Peer(id: component.context.account.peerId), - TelegramEngine.EngineData.Item.Configuration.UserLimits(isPremium: false), - TelegramEngine.EngineData.Item.Configuration.UserLimits(isPremium: true) - ) - |> deliverOnMainQueue).start(next: { [weak self] result in - guard let strongSelf = self else { - return - } - let (accountPeer, limits, premiumLimits) = result - let isPremium = accountPeer?.isPremium ?? false - - let presentationData = component.context.sharedContext.currentPresentationData.with { $0 } - - strongSelf.environment?.controller()?.present(legacyICloudFilePicker(theme: presentationData.theme, completion: { [weak self] urls in - if let strongSelf = self, !urls.isEmpty { - var signals: [Signal] = [] - for url in urls { - signals.append(iCloudFileDescription(url)) - } - strongSelf.enqueueMediaMessageDisposable.set((combineLatest(signals) - |> deliverOnMainQueue).start(next: { results in - if let strongSelf = self, let component = strongSelf.component { - for item in results { - if let item = item { - if item.fileSize > Int64(premiumLimits.maxUploadFileParts) * 512 * 1024 { - let controller = PremiumLimitScreen(context: component.context, subject: .files, count: 4, action: { - }) - strongSelf.environment?.controller()?.push(controller) - return - } else if item.fileSize > Int64(limits.maxUploadFileParts) * 512 * 1024 && !isPremium { - let context = component.context - var replaceImpl: ((ViewController) -> Void)? - let controller = PremiumLimitScreen(context: context, subject: .files, count: 2, action: { - replaceImpl?(PremiumIntroScreen(context: context, source: .upload)) - }) - replaceImpl = { [weak controller] c in - controller?.replace(with: c) - } - strongSelf.environment?.controller()?.push(controller) - return - } - } - } - - var groupingKey: Int64? - var fileTypes: (music: Bool, other: Bool) = (false, false) - if results.count > 1 { - for item in results { - if let item = item { - let pathExtension = (item.fileName as NSString).pathExtension.lowercased() - if ["mp3", "m4a"].contains(pathExtension) { - fileTypes.music = true - } else { - fileTypes.other = true - } - } - } - } - if fileTypes.music != fileTypes.other { - groupingKey = Int64.random(in: Int64.min ... Int64.max) - } - - var messages: [EnqueueMessage] = [] - for item in results { - if let item = item { - let fileId = Int64.random(in: Int64.min ... Int64.max) - let mimeType = guessMimeTypeByFileExtension((item.fileName as NSString).pathExtension) - var previewRepresentations: [TelegramMediaImageRepresentation] = [] - if mimeType.hasPrefix("image/") || mimeType == "application/pdf" { - previewRepresentations.append(TelegramMediaImageRepresentation(dimensions: PixelDimensions(width: 320, height: 320), resource: ICloudFileResource(urlData: item.urlData, thumbnail: true), progressiveSizes: [], immediateThumbnailData: nil, hasVideo: false, isPersonal: false)) - } - var attributes: [TelegramMediaFileAttribute] = [] - attributes.append(.FileName(fileName: item.fileName)) - if let audioMetadata = item.audioMetadata { - attributes.append(.Audio(isVoice: false, duration: audioMetadata.duration, title: audioMetadata.title, performer: audioMetadata.performer, waveform: nil)) - } - - let file = TelegramMediaFile(fileId: EngineMedia.Id(namespace: Namespaces.Media.LocalFile, id: fileId), partialReference: nil, resource: ICloudFileResource(urlData: item.urlData, thumbnail: false), previewRepresentations: previewRepresentations, videoThumbnails: [], immediateThumbnailData: nil, mimeType: mimeType, size: Int64(item.fileSize), attributes: attributes) - let message: EnqueueMessage = .message(text: "", attributes: [], inlineStickers: [:], mediaReference: .standalone(media: file), replyToMessageId: replyMessageId, localGroupingKey: groupingKey, correlationId: nil, bubbleUpEmojiOrStickersets: []) - messages.append(message) - } - if let _ = groupingKey, messages.count % 10 == 0 { - groupingKey = Int64.random(in: Int64.min ... Int64.max) - } - } - - if !messages.isEmpty { - strongSelf.sendMessages(peer: peer, messages: messages) - } - } - })) - } - }), in: .window(.root)) - }) - } - - private func enqueueChatContextResult(peer: EnginePeer, replyMessageId: EngineMessage.Id?, results: ChatContextResultCollection, result: ChatContextResult, hideVia: Bool = false, closeMediaInput: Bool = false, silentPosting: Bool = false, resetTextInputState: Bool = true) { - if !canSendMessagesToPeer(peer._asPeer()) { - return - } - - let sendMessage: (Int32?) -> Void = { [weak self] scheduleTime in - guard let self, let component = self.component else { - return - } - if component.context.engine.messages.enqueueOutgoingMessageWithChatContextResult(to: peer.id, threadId: nil, botId: results.botId, result: result, replyToMessageId: replyMessageId, hideVia: hideVia, silentPosting: silentPosting, scheduleTime: scheduleTime) { - } - - if let attachmentController = self.attachmentController { - attachmentController.dismiss(animated: true) - } - } - - sendMessage(nil) - } - - private func presentWebSearch(editingMessage: Bool, attachment: Bool, activateOnDisplay: Bool = true, present: @escaping (ViewController, Any?) -> Void) { - /*guard let peer = self.presentationInterfaceState.renderedPeer?.peer else { - return - } - - let _ = (self.context.engine.data.get(TelegramEngine.EngineData.Item.Configuration.SearchBots()) - |> deliverOnMainQueue).start(next: { [weak self] configuration in - if let strongSelf = self { - let controller = WebSearchController(context: strongSelf.context, updatedPresentationData: strongSelf.updatedPresentationData, peer: EnginePeer(peer), chatLocation: strongSelf.chatLocation, configuration: configuration, mode: .media(attachment: attachment, completion: { [weak self] results, selectionState, editingState, silentPosting in - self?.attachmentController?.dismiss(animated: true, completion: nil) - legacyEnqueueWebSearchMessages(selectionState, editingState, enqueueChatContextResult: { [weak self] result in - if let strongSelf = self { - strongSelf.enqueueChatContextResult(results, result, hideVia: true) - } - }, enqueueMediaMessages: { [weak self] signals in - if let strongSelf = self, !signals.isEmpty { - if editingMessage { - strongSelf.editMessageMediaWithLegacySignals(signals) - } else { - strongSelf.enqueueMediaMessages(signals: signals, silentPosting: silentPosting) - } - } - }) - }), activateOnDisplay: activateOnDisplay) - controller.attemptItemSelection = { [weak strongSelf] item in - guard let strongSelf, let peer = strongSelf.presentationInterfaceState.renderedPeer?.peer else { - return false - } - - enum ItemType { - case gif - case image - case video - } - - var itemType: ItemType? - switch item { - case let .internalReference(reference): - if reference.type == "gif" { - itemType = .gif - } else if reference.type == "photo" { - itemType = .image - } else if reference.type == "video" { - itemType = .video - } - case let .externalReference(reference): - if reference.type == "gif" { - itemType = .gif - } else if reference.type == "photo" { - itemType = .image - } else if reference.type == "video" { - itemType = .video - } - } - - var bannedSendPhotos: (Int32, Bool)? - var bannedSendVideos: (Int32, Bool)? - var bannedSendGifs: (Int32, Bool)? - - if let channel = peer as? TelegramChannel { - if let value = channel.hasBannedPermission(.banSendPhotos) { - bannedSendPhotos = value - } - if let value = channel.hasBannedPermission(.banSendVideos) { - bannedSendVideos = value - } - if let value = channel.hasBannedPermission(.banSendGifs) { - bannedSendGifs = value - } - } else if let group = peer as? TelegramGroup { - if group.hasBannedPermission(.banSendPhotos) { - bannedSendPhotos = (Int32.max, false) - } - if group.hasBannedPermission(.banSendVideos) { - bannedSendVideos = (Int32.max, false) - } - if group.hasBannedPermission(.banSendGifs) { - bannedSendGifs = (Int32.max, false) - } - } - - if let itemType { - switch itemType { - case .image: - if bannedSendPhotos != nil { - strongSelf.present(standardTextAlertController(theme: AlertControllerTheme(presentationData: strongSelf.presentationData), title: nil, text: strongSelf.restrictedSendingContentsText(), actions: [TextAlertAction(type: .defaultAction, title: strongSelf.presentationData.strings.Common_OK, action: {})]), in: .window(.root)) - - return false - } - case .video: - if bannedSendVideos != nil { - strongSelf.present(standardTextAlertController(theme: AlertControllerTheme(presentationData: strongSelf.presentationData), title: nil, text: strongSelf.restrictedSendingContentsText(), actions: [TextAlertAction(type: .defaultAction, title: strongSelf.presentationData.strings.Common_OK, action: {})]), in: .window(.root)) - - return false - } - case .gif: - if bannedSendGifs != nil { - strongSelf.present(standardTextAlertController(theme: AlertControllerTheme(presentationData: strongSelf.presentationData), title: nil, text: strongSelf.restrictedSendingContentsText(), actions: [TextAlertAction(type: .defaultAction, title: strongSelf.presentationData.strings.Common_OK, action: {})]), in: .window(.root)) - - return false - } - } - } - - return true - } - controller.getCaptionPanelView = { [weak strongSelf] in - return strongSelf?.getCaptionPanelView() - } - present(controller, ViewControllerPresentationArguments(presentationAnimation: .modalSheet)) - } - })*/ - } - - private func getCaptionPanelView(peer: EnginePeer) -> TGCaptionPanelView? { - guard let component = self.component else { - return nil - } - //TODO:self.presentationInterfaceState.customEmojiAvailable - return component.context.sharedContext.makeGalleryCaptionPanelView(context: component.context, chatLocation: .peer(id: peer.id), customEmojiAvailable: true, present: { [weak self] c in - guard let self else { - return - } - self.environment?.controller()?.present(c, in: .window(.root)) - }, presentInGlobalOverlay: { [weak self] c in - guard let self else { - return - } - self.environment?.controller()?.presentInGlobalOverlay(c) - }) as? TGCaptionPanelView - } - - private func openCamera(peer: EnginePeer, replyToMessageId: EngineMessage.Id?, cameraView: TGAttachmentCameraView? = nil) { - guard let component = self.component else { - return - } - guard let inputPanelView = self.inputPanel.view as? MessageInputPanelComponent.View else { - return - } - - var inputText = NSAttributedString(string: "") - switch inputPanelView.getSendMessageInput() { - case let .text(text): - inputText = NSAttributedString(string: text) - } - - let _ = (component.context.sharedContext.accountManager.transaction { transaction -> GeneratedMediaStoreSettings in - let entry = transaction.getSharedData(ApplicationSpecificSharedDataKeys.generatedMediaStoreSettings)?.get(GeneratedMediaStoreSettings.self) - return entry ?? GeneratedMediaStoreSettings.defaultSettings - } - |> deliverOnMainQueue).start(next: { [weak self] settings in - guard let self, let component = self.component, let parentController = self.environment?.controller() else { - return - } - - var enablePhoto = true - var enableVideo = true - - if let callManager = component.context.sharedContext.callManager, callManager.hasActiveCall { - enableVideo = false - } - - var bannedSendPhotos: (Int32, Bool)? - var bannedSendVideos: (Int32, Bool)? - - if case let .channel(channel) = peer { - if let value = channel.hasBannedPermission(.banSendPhotos) { - bannedSendPhotos = value - } - if let value = channel.hasBannedPermission(.banSendVideos) { - bannedSendVideos = value - } - } else if case let .legacyGroup(group) = peer { - if group.hasBannedPermission(.banSendPhotos) { - bannedSendPhotos = (Int32.max, false) - } - if group.hasBannedPermission(.banSendVideos) { - bannedSendVideos = (Int32.max, false) - } - } - - if bannedSendPhotos != nil { - enablePhoto = false - } - if bannedSendVideos != nil { - enableVideo = false - } - - let storeCapturedMedia = peer.id.namespace != Namespaces.Peer.SecretChat - - presentedLegacyCamera(context: component.context, peer: peer._asPeer(), chatLocation: .peer(id: peer.id), cameraView: cameraView, menuController: nil, parentController: parentController, attachmentController: self.attachmentController, editingMedia: false, saveCapturedPhotos: storeCapturedMedia, mediaGrouping: true, initialCaption: inputText, hasSchedule: peer.id.namespace != Namespaces.Peer.SecretChat, enablePhoto: enablePhoto, enableVideo: enableVideo, sendMessagesWithSignals: { [weak self] signals, silentPosting, scheduleTime in - guard let self else { - return - } - self.enqueueMediaMessages(peer: peer, replyToMessageId: replyToMessageId, signals: signals, silentPosting: silentPosting, scheduleTime: scheduleTime > 0 ? scheduleTime : nil) - if !inputText.string.isEmpty { - self.clearInputText() - } - }, recognizedQRCode: { _ in - }, presentSchedulePicker: { [weak self] _, done in - guard let self else { - return - } - self.presentScheduleTimePicker(peer: peer, style: .media, completion: { time in - done(time) - }) - }, presentTimerPicker: { [weak self] done in - guard let self else { - return - } - self.presentTimerPicker(peer: peer, style: .media, completion: { time in - done(time) - }) - }, getCaptionPanelView: { [weak self] in - guard let self else { - return nil - } - return self.getCaptionPanelView(peer: peer) - }, dismissedWithResult: { [weak self] in - guard let self else { - return - } - self.attachmentController?.dismiss(animated: false, completion: nil) - }, finishedTransitionIn: { [weak self] in - guard let self else { - return - } - self.attachmentController?.scrollToTop?() - }) - }) - } - - private func presentScheduleTimePicker( - peer: EnginePeer, - style: ChatScheduleTimeControllerStyle = .default, - selectedTime: Int32? = nil, - dismissByTapOutside: Bool = true, - completion: @escaping (Int32) -> Void - ) { - guard let component = self.component else { - return - } - let _ = (component.context.engine.data.get( - TelegramEngine.EngineData.Item.Peer.Presence(id: peer.id) - ) - |> deliverOnMainQueue).start(next: { [weak self] presence in - guard let self, let component = self.component, let environment = self.environment else { - return - } - - var sendWhenOnlineAvailable = false - if let presence, case .present = presence.status { - sendWhenOnlineAvailable = true - } - if peer.id.namespace == Namespaces.Peer.CloudUser && peer.id.id._internalGetInt64Value() == 777000 { - sendWhenOnlineAvailable = false - } - - let mode: ChatScheduleTimeControllerMode - if peer.id == component.context.account.peerId { - mode = .reminders - } else { - mode = .scheduledMessages(sendWhenOnlineAvailable: sendWhenOnlineAvailable) - } - let theme = environment.theme - let controller = ChatScheduleTimeController(context: component.context, updatedPresentationData: (component.context.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: theme), component.context.sharedContext.presentationData |> map { $0.withUpdated(theme: theme) }), peerId: peer.id, mode: mode, style: style, currentTime: selectedTime, minimalTime: nil, dismissByTapOutside: dismissByTapOutside, completion: { time in - completion(time) - }) - self.endEditing(true) - self.environment?.controller()?.present(controller, in: .window(.root)) - }) - } - - private func presentTimerPicker(peer: EnginePeer, style: ChatTimerScreenStyle = .default, selectedTime: Int32? = nil, dismissByTapOutside: Bool = true, completion: @escaping (Int32) -> Void) { - guard let component = self.component, let environment = self.environment else { - return - } - let theme = environment.theme - let controller = ChatTimerScreen(context: component.context, updatedPresentationData: (component.context.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: theme), component.context.sharedContext.presentationData |> map { $0.withUpdated(theme: theme) }), style: style, currentTime: selectedTime, dismissByTapOutside: dismissByTapOutside, completion: { time in - completion(time) - }) - self.endEditing(true) - self.environment?.controller()?.present(controller, in: .window(.root)) - } - - private func configurePollCreation(peer: EnginePeer, targetMessageId: EngineMessage.Id, isQuiz: Bool? = nil) -> CreatePollControllerImpl? { - guard let component = self.component, let environment = self.environment else { - return nil - } - let theme = environment.theme - return createPollController(context: component.context, updatedPresentationData: (component.context.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: theme), component.context.sharedContext.presentationData |> map { $0.withUpdated(theme: theme) }), peer: peer, isQuiz: isQuiz, completion: { [weak self] poll in - guard let self else { - return - } - let replyMessageId = targetMessageId - /*strongSelf.chatDisplayNode.setupSendActionOnViewUpdate({ - if let strongSelf = self { - strongSelf.chatDisplayNode.collapseInput() - - strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: false, { - $0.updatedInterfaceState { $0.withUpdatedReplyMessageId(nil) } - }) - } - }, nil)*/ - let message: EnqueueMessage = .message( - text: "", - attributes: [], - inlineStickers: [:], - mediaReference: .standalone(media: TelegramMediaPoll( - pollId: EngineMedia.Id(namespace: Namespaces.Media.LocalPoll, id: Int64.random(in: Int64.min ... Int64.max)), - publicity: poll.publicity, - kind: poll.kind, - text: poll.text, - options: poll.options, - correctAnswers: poll.correctAnswers, - results: poll.results, - isClosed: false, - deadlineTimeout: poll.deadlineTimeout - )), - replyToMessageId: nil, - localGroupingKey: nil, - correlationId: nil, - bubbleUpEmojiOrStickersets: [] - ) - self.sendMessages(peer: peer, messages: [message.withUpdatedReplyToMessageId(replyMessageId)]) - }) - } - - private func transformEnqueueMessages(messages: [EnqueueMessage], silentPosting: Bool, scheduleTime: Int32? = nil) -> [EnqueueMessage] { - guard let focusedItemId = self.focusedItemId, let focusedItem = self.currentSlice?.items.first(where: { $0.id == focusedItemId }) else { - return [] - } - guard let targetMessageId = focusedItem.targetMessageId else { - return [] - } - - let defaultReplyMessageId: EngineMessage.Id? = targetMessageId - - return messages.map { message in - var message = message - - if let defaultReplyMessageId = defaultReplyMessageId { - switch message { - case let .message(text, attributes, inlineStickers, mediaReference, replyToMessageId, localGroupingKey, correlationId, bubbleUpEmojiOrStickersets): - if replyToMessageId == nil { - message = .message(text: text, attributes: attributes, inlineStickers: inlineStickers, mediaReference: mediaReference, replyToMessageId: defaultReplyMessageId, localGroupingKey: localGroupingKey, correlationId: correlationId, bubbleUpEmojiOrStickersets: bubbleUpEmojiOrStickersets) - } - case .forward: - break - } - } - - return message.withUpdatedAttributes { attributes in - var attributes = attributes - if silentPosting || scheduleTime != nil { - for i in (0 ..< attributes.count).reversed() { - if attributes[i] is NotificationInfoMessageAttribute { - attributes.remove(at: i) - } else if let _ = scheduleTime, attributes[i] is OutgoingScheduleInfoMessageAttribute { - attributes.remove(at: i) - } - } - if silentPosting { - attributes.append(NotificationInfoMessageAttribute(flags: .muted)) - } - if let scheduleTime = scheduleTime { - attributes.append(OutgoingScheduleInfoMessageAttribute(scheduleTime: scheduleTime)) - } - } - return attributes - } - } - } - - private func sendMessages(peer: EnginePeer, messages: [EnqueueMessage], media: Bool = false, commit: Bool = false) { - guard let component = self.component else { - return - } - let _ = (enqueueMessages(account: component.context.account, peerId: peer.id, messages: self.transformEnqueueMessages(messages: messages, silentPosting: false)) - |> deliverOnMainQueue).start() - - donateSendMessageIntent(account: component.context.account, sharedContext: component.context.sharedContext, intentContext: .chat, peerIds: [peer.id]) - - if let attachmentController = self.attachmentController { - attachmentController.dismiss(animated: true) - } - - if let controller = self.environment?.controller() { - let presentationData = component.context.sharedContext.currentPresentationData.with { $0 } - controller.present(UndoOverlayController( - presentationData: presentationData, - content: .succeed(text: "Message Sent"), - elevatedLayout: false, - animateInAsReplacement: false, - action: { _ in return false } - ), in: .current) - } - } - - private func enqueueMediaMessages(peer: EnginePeer, replyToMessageId: EngineMessage.Id?, signals: [Any]?, silentPosting: Bool, scheduleTime: Int32? = nil, getAnimatedTransitionSource: ((String) -> UIView?)? = nil, completion: @escaping () -> Void = {}) { - guard let component = self.component else { - return - } - - self.enqueueMediaMessageDisposable.set((legacyAssetPickerEnqueueMessages(context: component.context, account: component.context.account, signals: signals!) - |> deliverOnMainQueue).start(next: { [weak self] items in - if let strongSelf = self { - var mappedMessages: [EnqueueMessage] = [] - var addedTransitions: [(Int64, [String], () -> Void)] = [] - - var groupedCorrelationIds: [Int64: Int64] = [:] - - var skipAddingTransitions = false - - for item in items { - var message = item.message - if message.groupingKey != nil { - if items.count > 10 { - skipAddingTransitions = true - } - } else if items.count > 3 { - skipAddingTransitions = true - } - - if let uniqueId = item.uniqueId, !item.isFile && !skipAddingTransitions { - let correlationId: Int64 - var addTransition = scheduleTime == nil - if let groupingKey = message.groupingKey { - if let existing = groupedCorrelationIds[groupingKey] { - correlationId = existing - addTransition = false - } else { - correlationId = Int64.random(in: 0 ..< Int64.max) - groupedCorrelationIds[groupingKey] = correlationId - } - } else { - correlationId = Int64.random(in: 0 ..< Int64.max) - } - message = message.withUpdatedCorrelationId(correlationId) - - if addTransition { - addedTransitions.append((correlationId, [uniqueId], addedTransitions.isEmpty ? completion : {})) - } else { - if let index = addedTransitions.firstIndex(where: { $0.0 == correlationId }) { - var (correlationId, uniqueIds, completion) = addedTransitions[index] - uniqueIds.append(uniqueId) - addedTransitions[index] = (correlationId, uniqueIds, completion) - } - } - } - mappedMessages.append(message) - } - - let messages = strongSelf.transformEnqueueMessages(messages: mappedMessages, silentPosting: silentPosting, scheduleTime: scheduleTime) - - strongSelf.sendMessages(peer: peer, messages: messages.map { $0.withUpdatedReplyToMessageId(replyToMessageId) }, media: true) - - if let _ = scheduleTime { - completion() - } - } - })) - } - - private func editMessageMediaWithLegacySignals(_ signals: [Any]) { - guard let component = self.component else { - return - } - let _ = (legacyAssetPickerEnqueueMessages(context: component.context, account: component.context.account, signals: signals) - |> deliverOnMainQueue).start() - }*/ - private func updatePreloads() { /*var validIds: [AnyHashable] = [] if let currentSlice = self.currentSlice, let focusedItemId = self.focusedItemId, let currentIndex = currentSlice.items.firstIndex(where: { $0.id == focusedItemId }) { @@ -2198,6 +402,20 @@ private final class StoryContainerScreenComponent: Component { if self.itemSetPanState != nil { isProgressPaused = true } + if self.dismissPanState != nil { + isProgressPaused = true + } + + var dismissPanOffset: CGFloat = 0.0 + var dismissPanScale: CGFloat = 1.0 + var dismissAlphaScale: CGFloat = 1.0 + if let dismissPanState = self.dismissPanState { + dismissPanOffset = dismissPanState.fraction * availableSize.height + dismissPanScale = 1.0 * (1.0 - dismissPanState.fraction) + 0.6 * dismissPanState.fraction + dismissAlphaScale = 1.0 * (1.0 - dismissPanState.fraction) + 0.2 * dismissPanState.fraction + } + + transition.setBackgroundColor(view: self, color: UIColor.black.withAlphaComponent(max(0.5, dismissAlphaScale))) var contentDerivedBottomInset: CGFloat = environment.safeInsets.bottom @@ -2243,12 +461,10 @@ private final class StoryContainerScreenComponent: Component { initialItemSlice: itemSet, theme: environment.theme, strings: environment.strings, - containerInsets: UIEdgeInsets(top: environment.statusBarHeight, left: 0.0, bottom: environment.inputHeight, right: 0.0), + containerInsets: UIEdgeInsets(top: environment.statusBarHeight + 12.0, left: 0.0, bottom: environment.inputHeight, right: 0.0), safeInsets: environment.safeInsets, inputHeight: environment.inputHeight, isProgressPaused: isProgressPaused || i != focusedIndex, - audioRecorder: i == focusedIndex ? self.audioRecorderValue : nil, - videoRecorder: i == focusedIndex ? self.videoRecorderValue : nil, hideUI: i == focusedIndex && self.itemSetPanState?.didBegin == false, presentController: { [weak self] c in guard let self, let environment = self.environment else { @@ -2282,8 +498,31 @@ private final class StoryContainerScreenComponent: Component { switchToIndex = max(0, min(switchToIndex, self.itemSets.count - 1)) if switchToIndex != focusedIndex { + var itemSetPanState = ItemSetPanState(fraction: 0.0, didBegin: true) + self.focusedItemSet = self.itemSets[switchToIndex].id + + if switchToIndex < focusedIndex { + itemSetPanState.fraction = 1.0 + itemSetPanState.fraction + } else { + itemSetPanState.fraction = itemSetPanState.fraction - 1.0 + } + self.itemSetPanState = itemSetPanState self.state?.updated(transition: .immediate) + + itemSetPanState.fraction = 0.0 + self.itemSetPanState = itemSetPanState + + let transition = Transition(animation: .curve(duration: 0.4, curve: .spring)) + self.state?.updated(transition: transition) + + transition.attachAnimation(view: self, id: "panState", completion: { [weak self] completed in + guard let self, completed else { + return + } + self.itemSetPanState = nil + self.state?.updated(transition: .immediate) + }) } else if switchToIndex == self.itemSets.count - 1 { environment.controller()?.dismiss() } @@ -2314,8 +553,9 @@ private final class StoryContainerScreenComponent: Component { itemSetView.layer.addSublayer(itemSetView.tintLayer) } - itemSetTransition.setPosition(view: itemSetView, position: itemFrame.center) + itemSetTransition.setPosition(view: itemSetView, position: itemFrame.center.offsetBy(dx: 0.0, dy: dismissPanOffset)) itemSetTransition.setBounds(view: itemSetView, bounds: CGRect(origin: CGPoint(), size: itemFrame.size)) + itemSetTransition.setSublayerTransform(view: itemSetView, transform: CATransform3DMakeScale(dismissPanScale, dismissPanScale, 1.0)) itemSetTransition.setPosition(view: itemSetComponentView, position: CGRect(origin: CGPoint(), size: itemFrame.size).center) itemSetTransition.setBounds(view: itemSetComponentView, bounds: CGRect(origin: CGPoint(), size: itemFrame.size)) @@ -2385,16 +625,23 @@ private final class StoryContainerScreenComponent: Component { Transition.immediate.setTransform(view: itemSetComponentView, transform: faceTransform) Transition.immediate.setTransform(layer: itemSetView.tintLayer, transform: faceTransform) - if let previousRotationFraction = itemSetView.rotationFraction, "".isEmpty { + if let previousRotationFraction = itemSetView.rotationFraction { let fromT = previousRotationFraction let toT = panFraction itemSetTransition.setTransformAsKeyframes(view: itemSetView, transform: { sourceT in let t = fromT * (1.0 - sourceT) + toT * sourceT + if abs((t + cubeAdditionalRotationFraction) - 0.0) < 0.0001 { + return CATransform3DIdentity + } return calculateCubeTransform(rotationFraction: t + cubeAdditionalRotationFraction, sideAngle: sideAngle, cubeSize: itemFrame.size) }) } else { - itemSetTransition.setTransform(view: itemSetView, transform: calculateCubeTransform(rotationFraction: panFraction + cubeAdditionalRotationFraction, sideAngle: sideAngle, cubeSize: itemFrame.size)) + if panFraction == 0.0 { + itemSetTransition.setTransform(view: itemSetView, transform: CATransform3DIdentity) + } else { + itemSetTransition.setTransform(view: itemSetView, transform: calculateCubeTransform(rotationFraction: panFraction + cubeAdditionalRotationFraction, sideAngle: sideAngle, cubeSize: itemFrame.size)) + } } itemSetView.rotationFraction = panFraction @@ -2476,6 +723,22 @@ public class StoryContainerScreen: ViewControllerComponentContainer { } } + public final class TransitionOut { + public weak var destinationView: UIView? + public let destinationRect: CGRect + public let destinationCornerRadius: CGFloat + + public init( + destinationView: UIView, + destinationRect: CGRect, + destinationCornerRadius: CGFloat + ) { + self.destinationView = destinationView + self.destinationRect = destinationRect + self.destinationCornerRadius = destinationCornerRadius + } + } + private let context: AccountContext private var isDismissed: Bool = false @@ -2483,7 +746,8 @@ public class StoryContainerScreen: ViewControllerComponentContainer { context: AccountContext, initialFocusedId: AnyHashable?, initialContent: [StoryContentItemSlice], - transitionIn: TransitionIn? + transitionIn: TransitionIn?, + transitionOut: @escaping (EnginePeer.Id) -> TransitionOut? ) { self.context = context @@ -2491,7 +755,8 @@ public class StoryContainerScreen: ViewControllerComponentContainer { context: context, initialFocusedId: initialFocusedId, initialContent: initialContent, - transitionIn: transitionIn + transitionIn: transitionIn, + transitionOut: transitionOut ), navigationBarAppearance: .none, theme: .dark) self.statusBar.statusBarStyle = .White diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContent.swift b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContent.swift index 7de7d8248b..ebc5c4c493 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContent.swift +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContent.swift @@ -41,7 +41,7 @@ public final class StoryContentItem { public let component: AnyComponent public let centerInfoComponent: AnyComponent? public let rightInfoComponent: AnyComponent? - public let targetMessageId: EngineMessage.Id? + public let peerId: EnginePeer.Id? public let storyItem: StoryListContext.Item? public let preload: Signal? public let delete: (() -> Void)? @@ -55,7 +55,7 @@ public final class StoryContentItem { component: AnyComponent, centerInfoComponent: AnyComponent?, rightInfoComponent: AnyComponent?, - targetMessageId: EngineMessage.Id?, + peerId: EnginePeer.Id?, storyItem: StoryListContext.Item?, preload: Signal?, delete: (() -> Void)?, @@ -68,7 +68,7 @@ public final class StoryContentItem { self.component = component self.centerInfoComponent = centerInfoComponent self.rightInfoComponent = rightInfoComponent - self.targetMessageId = targetMessageId + self.peerId = peerId self.storyItem = storyItem self.preload = preload self.delete = delete diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift index 3e85bf0fe1..7d2eafd07a 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift @@ -14,6 +14,8 @@ import AccountContext import LegacyInstantVideoController import UndoUI import ContextUI +import TelegramCore +import AvatarNode public final class StoryItemSetContainerComponent: Component { public final class ExternalState { @@ -37,8 +39,6 @@ public final class StoryItemSetContainerComponent: Component { public let safeInsets: UIEdgeInsets public let inputHeight: CGFloat public let isProgressPaused: Bool - public let audioRecorder: ManagedAudioRecorder? - public let videoRecorder: InstantVideoController? public let hideUI: Bool public let presentController: (ViewController) -> Void public let close: () -> Void @@ -55,8 +55,6 @@ public final class StoryItemSetContainerComponent: Component { safeInsets: UIEdgeInsets, inputHeight: CGFloat, isProgressPaused: Bool, - audioRecorder: ManagedAudioRecorder?, - videoRecorder: InstantVideoController?, hideUI: Bool, presentController: @escaping (ViewController) -> Void, close: @escaping () -> Void, @@ -72,8 +70,6 @@ public final class StoryItemSetContainerComponent: Component { self.safeInsets = safeInsets self.inputHeight = inputHeight self.isProgressPaused = isProgressPaused - self.audioRecorder = audioRecorder - self.videoRecorder = videoRecorder self.hideUI = hideUI self.presentController = presentController self.close = close @@ -106,19 +102,13 @@ public final class StoryItemSetContainerComponent: Component { if lhs.isProgressPaused != rhs.isProgressPaused { return false } - if lhs.audioRecorder !== rhs.audioRecorder { - return false - } - if lhs.videoRecorder !== rhs.videoRecorder { - return false - } if lhs.hideUI != rhs.hideUI { return false } return true } - private final class ScrollView: UIScrollView { + final class ScrollView: UIScrollView { override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { return super.hitTest(point, with: event) } @@ -128,7 +118,7 @@ public final class StoryItemSetContainerComponent: Component { } } - private struct ItemLayout { + struct ItemLayout { var size: CGSize init(size: CGSize) { @@ -136,7 +126,7 @@ public final class StoryItemSetContainerComponent: Component { } } - private final class VisibleItem { + final class VisibleItem { let externalState = StoryContentItem.ExternalState() let view = ComponentView() var currentProgress: Double = 0.0 @@ -146,7 +136,7 @@ public final class StoryItemSetContainerComponent: Component { } } - private final class InfoItem { + final class InfoItem { let component: AnyComponent let view = ComponentView() @@ -156,47 +146,57 @@ public final class StoryItemSetContainerComponent: Component { } public final class View: UIView, UIScrollViewDelegate, UIGestureRecognizerDelegate { - private let scrollView: ScrollView + let sendMessageContext: StoryItemSetContainerSendMessage - private let contentContainerView: UIView - private let topContentGradientLayer: SimpleGradientLayer - private let bottomContentGradientLayer: SimpleGradientLayer - private let contentDimLayer: SimpleLayer + let scrollView: ScrollView - private let closeButton: HighlightableButton - private let closeButtonIconView: UIImageView + let contentContainerView: UIView + let topContentGradientLayer: SimpleGradientLayer + let bottomContentGradientLayer: SimpleGradientLayer + let contentDimLayer: SimpleLayer - private let navigationStrip = ComponentView() - private let inlineActions = ComponentView() + let closeButton: HighlightableButton + let closeButtonIconView: UIImageView - private var centerInfoItem: InfoItem? - private var rightInfoItem: InfoItem? + let navigationStrip = ComponentView() + let inlineActions = ComponentView() - private let inputPanel = ComponentView() - private let footerPanel = ComponentView() - private let inputPanelExternalState = MessageInputPanelComponent.ExternalState() + var centerInfoItem: InfoItem? + var rightInfoItem: InfoItem? - private var itemLayout: ItemLayout? - private var ignoreScrolling: Bool = false + let inputPanel = ComponentView() + let footerPanel = ComponentView() + let inputPanelExternalState = MessageInputPanelComponent.ExternalState() - private var focusedItemId: AnyHashable? - private var currentSlice: StoryContentItemSlice? - private var currentSliceDisposable: Disposable? + var itemLayout: ItemLayout? + var ignoreScrolling: Bool = false - private var visibleItems: [AnyHashable: VisibleItem] = [:] + var focusedItemId: AnyHashable? + var currentSlice: StoryContentItemSlice? + var currentSliceDisposable: Disposable? - private var preloadContexts: [AnyHashable: Disposable] = [:] + var visibleItems: [AnyHashable: VisibleItem] = [:] - private var reactionItems: [ReactionItem]? - private var reactionContextNode: ReactionContextNode? + var preloadContexts: [AnyHashable: Disposable] = [:] - private weak var actionSheet: ActionSheetController? - private weak var contextController: ContextController? + var displayReactions: Bool = false + var reactionItems: [ReactionItem]? + var reactionContextNode: ReactionContextNode? + weak var disappearingReactionContextNode: ReactionContextNode? - private var component: StoryItemSetContainerComponent? - private weak var state: EmptyComponentState? + weak var actionSheet: ActionSheetController? + weak var contextController: ContextController? + + var component: StoryItemSetContainerComponent? + weak var state: EmptyComponentState? + + private var audioRecorderDisposable: Disposable? + private var audioRecorderStatusDisposable: Disposable? + private var videoRecorderDisposable: Disposable? override init(frame: CGRect) { + self.sendMessageContext = StoryItemSetContainerSendMessage() + self.scrollView = ScrollView() self.contentContainerView = UIView() @@ -240,6 +240,105 @@ public final class StoryItemSetContainerComponent: Component { let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:))) tapRecognizer.delegate = self self.contentContainerView.addGestureRecognizer(tapRecognizer) + + self.audioRecorderDisposable = (self.sendMessageContext.audioRecorder.get() + |> deliverOnMainQueue).start(next: { [weak self] audioRecorder in + guard let self else { + return + } + if self.sendMessageContext.audioRecorderValue !== audioRecorder { + self.sendMessageContext.audioRecorderValue = audioRecorder + self.component?.controller()?.lockOrientation = audioRecorder != nil + + /*strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: true, { + $0.updatedInputTextPanelState { panelState in + let isLocked = strongSelf.lockMediaRecordingRequestId == strongSelf.beginMediaRecordingRequestId + if let audioRecorder = audioRecorder { + if panelState.mediaRecordingState == nil { + return panelState.withUpdatedMediaRecordingState(.audio(recorder: audioRecorder, isLocked: isLocked)) + } + } else { + if case .waitingForPreview = panelState.mediaRecordingState { + return panelState + } + return panelState.withUpdatedMediaRecordingState(nil) + } + return panelState + } + })*/ + + self.audioRecorderStatusDisposable?.dispose() + self.audioRecorderStatusDisposable = nil + + if let audioRecorder = audioRecorder { + if !audioRecorder.beginWithTone { + HapticFeedback().impact(.light) + } + audioRecorder.start() + self.audioRecorderStatusDisposable = (audioRecorder.recordingState + |> deliverOnMainQueue).start(next: { [weak self] value in + guard let self else { + return + } + if case .stopped = value { + self.sendMessageContext.stopMediaRecorder() + } + }) + } + + self.state?.updated(transition: Transition(animation: .curve(duration: 0.4, curve: .spring))) + } + }) + + self.videoRecorderDisposable = (self.sendMessageContext.videoRecorder.get() + |> deliverOnMainQueue).start(next: { [weak self] videoRecorder in + guard let self else { + return + } + if self.sendMessageContext.videoRecorderValue !== videoRecorder { + let previousVideoRecorderValue = self.sendMessageContext.videoRecorderValue + self.sendMessageContext.videoRecorderValue = videoRecorder + + if let videoRecorder = videoRecorder { + HapticFeedback().impact(.light) + + videoRecorder.onDismiss = { [weak self] isCancelled in + guard let self else { + return + } + //self?.chatDisplayNode.updateRecordedMediaDeleted(isCancelled) + //self?.beginMediaRecordingRequestId += 1 + //self?.lockMediaRecordingRequestId = nil + self.sendMessageContext.videoRecorder.set(.single(nil)) + } + videoRecorder.onStop = { [weak self] in + guard let self else { + return + } + /*if let strongSelf = self { + strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: true, { + $0.updatedInputTextPanelState { panelState in + return panelState.withUpdatedMediaRecordingState(.video(status: .editing, isLocked: false)) + } + }) + }*/ + let _ = self + //TODO:editing + } + self.component?.controller()?.present(videoRecorder, in: .window(.root)) + + /*if strongSelf.lockMediaRecordingRequestId == strongSelf.beginMediaRecordingRequestId { + videoRecorder.lockVideo() + }*/ + } + + if let previousVideoRecorderValue { + previousVideoRecorderValue.dismissVideo() + } + + self.state?.updated(transition: .immediate) + } + }) } required init?(coder: NSCoder) { @@ -248,6 +347,13 @@ public final class StoryItemSetContainerComponent: Component { deinit { self.currentSliceDisposable?.dispose() + self.audioRecorderDisposable?.dispose() + self.audioRecorderStatusDisposable?.dispose() + self.audioRecorderStatusDisposable?.dispose() + } + + func isPointInsideContentArea(point: CGPoint) -> Bool { + return self.contentContainerView.frame.contains(point) } @objc public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRequireFailureOf otherGestureRecognizer: UIGestureRecognizer) -> Bool { @@ -260,10 +366,10 @@ public final class StoryItemSetContainerComponent: Component { @objc private func tapGesture(_ recognizer: UITapGestureRecognizer) { if case .ended = recognizer.state, let currentSlice = self.currentSlice, let focusedItemId = self.focusedItemId, let currentIndex = currentSlice.items.firstIndex(where: { $0.id == focusedItemId }), let itemLayout = self.itemLayout { if hasFirstResponder(self) { - self.reactionItems = nil + self.displayReactions = false self.endEditing(true) - } else if self.reactionItems != nil { - self.reactionItems = nil + } else if self.displayReactions { + self.displayReactions = false self.state?.updated(transition: Transition(animation: .curve(duration: 0.4, curve: .spring))) } else { let point = recognizer.location(in: self) @@ -407,7 +513,7 @@ public final class StoryItemSetContainerComponent: Component { itemTransition.setFrame(view: view, frame: CGRect(origin: CGPoint(), size: itemLayout.size)) if let view = view as? StoryContentItem.View { - view.setIsProgressPaused(self.inputPanelExternalState.isEditing || component.isProgressPaused || self.reactionItems != nil || self.actionSheet != nil || self.contextController != nil) + view.setIsProgressPaused(self.inputPanelExternalState.isEditing || component.isProgressPaused || self.displayReactions || self.actionSheet != nil || self.contextController != nil || self.sendMessageContext.audioRecorderValue != nil || self.sendMessageContext.videoRecorderValue != nil) } } } @@ -426,14 +532,14 @@ public final class StoryItemSetContainerComponent: Component { } } - private func updateIsProgressPaused() { + func updateIsProgressPaused() { guard let component = self.component else { return } for (_, visibleItem) in self.visibleItems { if let view = visibleItem.view.view { if let view = view as? StoryContentItem.View { - view.setIsProgressPaused(self.inputPanelExternalState.isEditing || component.isProgressPaused || self.reactionItems != nil || self.actionSheet != nil || self.contextController != nil) + view.setIsProgressPaused(self.inputPanelExternalState.isEditing || component.isProgressPaused || self.reactionItems != nil || self.actionSheet != nil || self.contextController != nil || self.sendMessageContext.audioRecorderValue != nil || self.sendMessageContext.videoRecorderValue != nil) } } } @@ -468,8 +574,8 @@ public final class StoryItemSetContainerComponent: Component { let innerSourceLocalFrame = CGRect(origin: CGPoint(x: sourceLocalFrame.minX - self.contentContainerView.frame.minX, y: sourceLocalFrame.minY - self.contentContainerView.frame.minY), size: sourceLocalFrame.size) if let rightInfoView = self.rightInfoItem?.view.view { - rightInfoView.layer.animatePosition(from: CGPoint(x: innerSourceLocalFrame.center.x - rightInfoView.layer.position.x, y: 0.0), to: CGPoint(), duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, additive: true) - rightInfoView.layer.animatePosition(from: CGPoint(x: 0.0, y: innerSourceLocalFrame.center.y - rightInfoView.layer.position.y), to: CGPoint(), duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, additive: true) + let positionKeyframes: [CGPoint] = generateParabollicMotionKeyframes(from: CGPoint(x: innerSourceLocalFrame.center.x - rightInfoView.layer.position.x, y: innerSourceLocalFrame.center.y - rightInfoView.layer.position.y), to: CGPoint(), elevation: 0.0, duration: 0.3, curve: .spring, reverse: false) + rightInfoView.layer.animateKeyframes(values: positionKeyframes.map { NSValue(cgPoint: $0) }, duration: 0.3, keyPath: "position", additive: true) rightInfoView.layer.animateScale(from: innerSourceLocalFrame.width / rightInfoView.bounds.width, to: 1.0, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring) } @@ -502,6 +608,76 @@ public final class StoryItemSetContainerComponent: Component { } } + func animateOut(transitionOut: StoryContainerScreen.TransitionOut, completion: @escaping () -> Void) { + self.closeButton.layer.animateScale(from: 1.0, to: 0.001, duration: 0.3, delay: 0.0, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, completion: { _ in + completion() + }) + + if let inputPanelView = self.inputPanel.view { + inputPanelView.layer.animatePosition( + from: CGPoint(), + to: CGPoint(x: 0.0, y: self.bounds.height - inputPanelView.frame.minY), + duration: 0.3, + timingFunction: kCAMediaTimingFunctionSpring, + removeOnCompletion: false, + additive: true + ) + inputPanelView.layer.animateAlpha(from: inputPanelView.alpha, to: 0.0, duration: 0.3, removeOnCompletion: false) + } + if let footerPanelView = self.footerPanel.view { + footerPanelView.layer.animatePosition( + from: CGPoint(), + to: CGPoint(x: 0.0, y: self.bounds.height - footerPanelView.frame.minY), + duration: 0.3, + timingFunction: kCAMediaTimingFunctionSpring, + removeOnCompletion: false, + additive: true + ) + footerPanelView.layer.animateAlpha(from: footerPanelView.alpha, to: 0.0, duration: 0.3, removeOnCompletion: false) + } + + if let sourceView = transitionOut.destinationView { + let sourceLocalFrame = sourceView.convert(transitionOut.destinationRect, to: self) + let innerSourceLocalFrame = CGRect(origin: CGPoint(x: sourceLocalFrame.minX - self.contentContainerView.frame.minX, y: sourceLocalFrame.minY - self.contentContainerView.frame.minY), size: sourceLocalFrame.size) + + if let rightInfoView = self.rightInfoItem?.view.view { + let positionKeyframes: [CGPoint] = generateParabollicMotionKeyframes(from: innerSourceLocalFrame.center, to: rightInfoView.layer.position, elevation: 0.0, duration: 0.3, curve: .spring, reverse: true) + rightInfoView.layer.position = positionKeyframes[positionKeyframes.count - 1] + rightInfoView.layer.animateKeyframes(values: positionKeyframes.map { NSValue(cgPoint: $0) }, duration: 0.3, keyPath: "position", removeOnCompletion: false, additive: false) + + rightInfoView.layer.animateScale(from: 1.0, to: innerSourceLocalFrame.width / rightInfoView.bounds.width, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false) + } + + self.contentContainerView.layer.animatePosition(from: self.contentContainerView.center, to: sourceLocalFrame.center, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false) + self.contentContainerView.layer.animateBounds(from: self.contentContainerView.bounds, to: CGRect(origin: CGPoint(x: innerSourceLocalFrame.minX, y: innerSourceLocalFrame.minY), size: sourceLocalFrame.size), duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false) + self.contentContainerView.layer.animate( + from: self.contentContainerView.layer.cornerRadius as NSNumber, + to: transitionOut.destinationCornerRadius as NSNumber, + keyPath: "cornerRadius", + timingFunction: kCAMediaTimingFunctionSpring, + duration: 0.3, + removeOnCompletion: false + ) + + if let focusedItemId = self.focusedItemId, let visibleItemView = self.visibleItems[focusedItemId]?.view.view { + let innerScale = innerSourceLocalFrame.width / visibleItemView.bounds.width + let innerFromFrame = CGRect(origin: CGPoint(x: innerSourceLocalFrame.minX, y: innerSourceLocalFrame.minY), size: CGSize(width: innerSourceLocalFrame.width, height: visibleItemView.bounds.height * innerScale)) + + visibleItemView.layer.animatePosition( + from: visibleItemView.layer.position, + to: CGPoint( + x: innerFromFrame.midX, + y: innerFromFrame.midY + ), + duration: 0.3, + timingFunction: kCAMediaTimingFunctionSpring, + removeOnCompletion: false + ) + visibleItemView.layer.animateScale(from: 1.0, to: innerScale, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false) + } + } + } + func update(component: StoryItemSetContainerComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { let isFirstTime = self.component == nil @@ -527,6 +703,25 @@ public final class StoryItemSetContainerComponent: Component { self.state?.updated(transition: .immediate) }) } + + let _ = (allowedStoryReactions(context: component.context) + |> deliverOnMainQueue).start(next: { [weak self] reactionItems in + guard let self, let component = self.component else { + return + } + + component.controller()?.forEachController { c in + if let c = c as? UndoOverlayController { + c.dismiss() + } + return true + } + + self.reactionItems = reactionItems + if self.displayReactions { + self.state?.updated(transition: Transition(animation: .curve(duration: 0.25, curve: .easeInOut))) + } + }) } if self.topContentGradientLayer.colors == nil { @@ -589,7 +784,7 @@ public final class StoryItemSetContainerComponent: Component { var bottomContentInset: CGFloat if !component.safeInsets.bottom.isZero { - bottomContentInset = component.safeInsets.bottom + 5.0 + bottomContentInset = component.safeInsets.bottom + 1.0 } else { bottomContentInset = 0.0 } @@ -612,22 +807,19 @@ public final class StoryItemSetContainerComponent: Component { guard let self else { return } - let _ = self - //self.performSendMessageAction() + self.sendMessageContext.performSendMessageAction(view: self) }, setMediaRecordingActive: { [weak self] isActive, isVideo, sendAction in guard let self else { return } - let _ = self - //self.setMediaRecordingActive(isActive: isActive, isVideo: isVideo, sendAction: sendAction) + self.sendMessageContext.setMediaRecordingActive(view: self, isActive: isActive, isVideo: isVideo, sendAction: sendAction) }, attachmentAction: { [weak self] in guard let self else { return } - let _ = self - //self.presentAttachmentMenu(subject: .default) + self.sendMessageContext.presentAttachmentMenu(view: self, subject: .default) }, reactionAction: { [weak self] sourceView in guard let self, let component = self.component else { @@ -647,12 +839,14 @@ public final class StoryItemSetContainerComponent: Component { return true } - self.reactionItems = reactionItems + self.displayReactions = true self.state?.updated(transition: Transition(animation: .curve(duration: 0.25, curve: .easeInOut))) }) }, - audioRecorder: component.audioRecorder, - videoRecordingStatus: component.videoRecorder?.audioStatus + audioRecorder: self.sendMessageContext.audioRecorderValue, + videoRecordingStatus: self.sendMessageContext.videoRecorderValue?.audioStatus, + displayGradient: component.inputHeight != 0.0, + bottomInset: component.inputHeight != 0.0 ? 0.0 : bottomContentInset )), environment: {}, containerSize: CGSize(width: availableSize.width, height: 200.0) @@ -759,8 +953,13 @@ public final class StoryItemSetContainerComponent: Component { items.append(.action(ContextMenuActionItem(text: "Who can see", textLayout: .secondLineWithValue("Everyone"), icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Channels"), color: theme.contextMenu.primaryColor) - }, action: { _, a in + }, action: { [weak self] _, a in a(.default) + + guard let self else { + return + } + self.openItemPrivacySettings() }))) items.append(.separator) @@ -809,7 +1008,7 @@ public final class StoryItemSetContainerComponent: Component { } self.contextController = contextController self.updateIsProgressPaused() - controller.presentInGlobalOverlay(contextController) + controller.present(contextController, in: .window(.root)) } )), environment: {}, @@ -820,7 +1019,7 @@ public final class StoryItemSetContainerComponent: Component { let inputPanelBottomInset: CGFloat let inputPanelIsOverlay: Bool - if component.inputHeight < bottomContentInset + inputPanelSize.height { + if component.inputHeight == 0.0 { inputPanelBottomInset = bottomContentInset bottomContentInset += inputPanelSize.height inputPanelIsOverlay = false @@ -832,7 +1031,7 @@ public final class StoryItemSetContainerComponent: Component { let contentFrame = CGRect(origin: CGPoint(x: 0.0, y: component.containerInsets.top), size: CGSize(width: availableSize.width, height: availableSize.height - component.containerInsets.top - bottomContentInset)) transition.setFrame(view: self.contentContainerView, frame: contentFrame) - transition.setCornerRadius(layer: self.contentContainerView.layer, cornerRadius: 14.0) + transition.setCornerRadius(layer: self.contentContainerView.layer, cornerRadius: 10.0) if self.closeButtonIconView.image == nil { self.closeButtonIconView.image = UIImage(bundleImageName: "Media Gallery/Close")?.withRenderingMode(.alwaysTemplate) @@ -909,7 +1108,7 @@ public final class StoryItemSetContainerComponent: Component { } transition.setFrame(view: view, frame: CGRect(origin: CGPoint(x: contentFrame.width - 6.0 - rightInfoItemSize.width, y: 14.0), size: rightInfoItemSize)) - if animateIn, !isFirstTime { + if animateIn, !isFirstTime, !transition.animation.isImmediate { view.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) view.layer.animateScale(from: 0.5, to: 1.0, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring) } @@ -960,7 +1159,9 @@ public final class StoryItemSetContainerComponent: Component { transition.setAlpha(view: inputPanelView, alpha: focusedItem?.isMy == true ? 0.0 : 1.0) } - if let reactionItems = self.reactionItems { + let reactionsAnchorRect = CGRect(origin: CGPoint(x: inputPanelFrame.maxX - 40.0, y: inputPanelFrame.minY + 9.0), size: CGSize(width: 32.0, height: 32.0)).insetBy(dx: -4.0, dy: -4.0) + + if let reactionItems = self.reactionItems, (self.displayReactions || self.inputPanelExternalState.isEditing) { let reactionContextNode: ReactionContextNode var reactionContextNodeTransition = transition if let current = self.reactionContextNode { @@ -1017,23 +1218,72 @@ public final class StoryItemSetContainerComponent: Component { self.state?.updated(transition: Transition(transition)) } ) + reactionContextNode.displayTail = false self.reactionContextNode = reactionContextNode reactionContextNode.reactionSelected = { [weak self] updateReaction, _ in guard let self, let component = self.component else { return } - self.reactionItems = nil - self.state?.updated(transition: Transition(animation: .curve(duration: 0.25, curve: .easeInOut))) - let presentationData = component.context.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: component.theme) - component.presentController(UndoOverlayController( - presentationData: presentationData, - content: .succeed(text: "Reaction Sent"), - elevatedLayout: false, - animateInAsReplacement: false, - action: { _ in return false } - )) + let _ = (component.context.engine.stickers.availableReactions() + |> take(1) + |> deliverOnMainQueue).start(next: { [weak self] availableReactions in + guard let self, let component = self.component, let availableReactions else { + return + } + + var selectedReaction: AvailableReactions.Reaction? + for reaction in availableReactions.reactions { + if reaction.value == updateReaction.reaction { + selectedReaction = reaction + break + } + } + + guard let reaction = selectedReaction else { + return + } + + let targetView = UIView(frame: CGRect(origin: CGPoint(x: floor((self.bounds.width - 100.0) * 0.5), y: floor((self.bounds.height - 100.0) * 0.5)), size: CGSize(width: 100.0, height: 100.0))) + targetView.isUserInteractionEnabled = false + self.addSubview(targetView) + + reactionContextNode.willAnimateOutToReaction(value: updateReaction.reaction) + reactionContextNode.animateOutToReaction(value: updateReaction.reaction, targetView: targetView, hideNode: false, animateTargetContainer: nil, addStandaloneReactionAnimation: { [weak self] standaloneReactionAnimation in + guard let self else { + return + } + standaloneReactionAnimation.frame = self.bounds + self.addSubview(standaloneReactionAnimation.view) + }, completion: { [weak targetView, weak reactionContextNode] in + targetView?.removeFromSuperview() + if let reactionContextNode { + reactionContextNode.layer.animateScale(from: 1.0, to: 0.001, duration: 0.3, removeOnCompletion: false) + reactionContextNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false, completion: { [weak reactionContextNode] _ in + reactionContextNode?.view.removeFromSuperview() + }) + } + }) + + self.displayReactions = false + if hasFirstResponder(self) { + self.endEditing(true) + } + self.state?.updated(transition: Transition(animation: .curve(duration: 0.25, curve: .easeInOut))) + + if let centerAnimation = reaction.centerAnimation { + let presentationData = component.context.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: component.theme) + component.presentController(UndoOverlayController( + presentationData: presentationData, + content: .sticker(context: component.context, file: centerAnimation, loop: false, title: nil, text: "Reaction Sent.", undoText: "View in Chat", customAction: { + }), + elevatedLayout: false, + animateInAsReplacement: false, + action: { _ in return false } + )) + } + }) } } @@ -1043,19 +1293,41 @@ public final class StoryItemSetContainerComponent: Component { self.addSubnode(reactionContextNode) } - let anchorRect = CGRect(origin: CGPoint(x: inputPanelFrame.maxX - 44.0 - 32.0, y: inputPanelFrame.minY), size: CGSize(width: 32.0, height: 32.0)).insetBy(dx: -4.0, dy: -4.0) - reactionContextNodeTransition.setFrame(view: reactionContextNode.view, frame: CGRect(origin: CGPoint(), size: availableSize)) - reactionContextNode.updateLayout(size: availableSize, insets: UIEdgeInsets(), anchorRect: anchorRect, isCoveredByInput: false, isAnimatingOut: false, transition: reactionContextNodeTransition.containedViewLayoutTransition) - - if animateReactionsIn { - reactionContextNode.animateIn(from: anchorRect) + if reactionContextNode.isAnimatingOutToReaction { + if !reactionContextNode.isAnimatingOut { + reactionContextNode.animateOut(to: reactionsAnchorRect, animatingOutToReaction: true) + } + } else { + reactionContextNodeTransition.setFrame(view: reactionContextNode.view, frame: CGRect(origin: CGPoint(), size: availableSize)) + reactionContextNode.updateLayout(size: availableSize, insets: UIEdgeInsets(), anchorRect: reactionsAnchorRect, isCoveredByInput: false, isAnimatingOut: false, transition: reactionContextNodeTransition.containedViewLayoutTransition) + + if animateReactionsIn { + reactionContextNode.animateIn(from: reactionsAnchorRect) + } } } else { if let reactionContextNode = self.reactionContextNode { + if let disappearingReactionContextNode = self.disappearingReactionContextNode { + disappearingReactionContextNode.view.removeFromSuperview() + } + self.disappearingReactionContextNode = reactionContextNode + self.reactionContextNode = nil - transition.setAlpha(view: reactionContextNode.view, alpha: 0.0, completion: { [weak reactionContextNode] _ in - reactionContextNode?.view.removeFromSuperview() - }) + if reactionContextNode.isAnimatingOutToReaction { + if !reactionContextNode.isAnimatingOut { + reactionContextNode.animateOut(to: reactionsAnchorRect, animatingOutToReaction: true) + } + } else { + transition.setAlpha(view: reactionContextNode.view, alpha: 0.0, completion: { [weak reactionContextNode] _ in + reactionContextNode?.view.removeFromSuperview() + }) + } + } + } + if let reactionContextNode = self.disappearingReactionContextNode { + if !reactionContextNode.isAnimatingOutToReaction { + transition.setFrame(view: reactionContextNode.view, frame: CGRect(origin: CGPoint(), size: availableSize)) + reactionContextNode.updateLayout(size: availableSize, insets: UIEdgeInsets(), anchorRect: reactionsAnchorRect, isCoveredByInput: false, isAnimatingOut: false, transition: transition.containedViewLayoutTransition) } } @@ -1070,7 +1342,8 @@ public final class StoryItemSetContainerComponent: Component { let bottomGradientHeight = inputPanelSize.height + 32.0 transition.setFrame(layer: self.bottomContentGradientLayer, frame: CGRect(origin: CGPoint(x: contentFrame.minX, y: availableSize.height - component.inputHeight - bottomGradientHeight), size: CGSize(width: contentFrame.width, height: bottomGradientHeight))) - transition.setAlpha(layer: self.bottomContentGradientLayer, alpha: inputPanelIsOverlay ? 1.0 : 0.0) + //transition.setAlpha(layer: self.bottomContentGradientLayer, alpha: inputPanelIsOverlay ? 1.0 : 0.0) + transition.setAlpha(layer: self.bottomContentGradientLayer, alpha: 0.0) transition.setFrame(layer: self.contentDimLayer, frame: CGRect(origin: CGPoint(), size: contentFrame.size)) transition.setAlpha(layer: self.contentDimLayer, alpha: (inputPanelIsOverlay || self.inputPanelExternalState.isEditing) ? 1.0 : 0.0) @@ -1134,8 +1407,7 @@ public final class StoryItemSetContainerComponent: Component { guard let self else { return } - let _ = self - //self.performInlineAction(item: item) + self.sendMessageContext.performInlineAction(view: self, item: item) } )), environment: {}, @@ -1148,7 +1420,7 @@ public final class StoryItemSetContainerComponent: Component { transition.setFrame(view: inlineActionsView, frame: CGRect(origin: CGPoint(x: contentFrame.width - 10.0 - inlineActionsSize.width, y: contentFrame.height - 20.0 - inlineActionsSize.height), size: inlineActionsSize)) var inlineActionsAlpha: CGFloat = inputPanelIsOverlay ? 0.0 : 1.0 - if component.audioRecorder != nil || component.videoRecorder != nil { + if self.sendMessageContext.audioRecorderValue != nil || self.sendMessageContext.videoRecorderValue != nil { inlineActionsAlpha = 0.0 } if self.reactionItems != nil { @@ -1167,6 +1439,90 @@ public final class StoryItemSetContainerComponent: Component { return contentSize } + + private func openItemPrivacySettings() { + guard let component = self.component else { + return + } + guard let focusedItemId = self.focusedItemId, let focusedItem = self.currentSlice?.items.first(where: { $0.id == focusedItemId }) else { + return + } + guard let storyItem = focusedItem.storyItem else { + return + } + + enum AdditionalCategoryId: Int { + case everyone + case contacts + case closeFriends + } + + let presentationData = component.context.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: component.theme) + + let additionalCategories: [ChatListNodeAdditionalCategory] = [ + ChatListNodeAdditionalCategory( + id: AdditionalCategoryId.everyone.rawValue, + icon: generateAvatarImage(size: CGSize(width: 40.0, height: 40.0), icon: generateTintedImage(image: UIImage(bundleImageName: "Chat List/Filters/Channel"), color: .white), cornerRadius: nil, color: .blue), + smallIcon: generateAvatarImage(size: CGSize(width: 22.0, height: 22.0), icon: generateTintedImage(image: UIImage(bundleImageName: "Chat List/Filters/Channel"), color: .white), iconScale: 0.6, cornerRadius: 6.0, circleCorners: true, color: .blue), + title: "Everyone", + appearance: .option(sectionTitle: "WHO CAN VIEW FOR 24 HOURS") + ), + ChatListNodeAdditionalCategory( + id: AdditionalCategoryId.contacts.rawValue, + icon: generateAvatarImage(size: CGSize(width: 40.0, height: 40.0), icon: generateTintedImage(image: UIImage(bundleImageName: "Chat List/Tabs/IconContacts"), color: .white), iconScale: 1.0 * 0.8, cornerRadius: nil, color: .yellow), + smallIcon: generateAvatarImage(size: CGSize(width: 22.0, height: 22.0), icon: generateTintedImage(image: UIImage(bundleImageName: "Chat List/Tabs/IconContacts"), color: .white), iconScale: 0.6 * 0.8, cornerRadius: 6.0, circleCorners: true, color: .yellow), + title: presentationData.strings.ChatListFolder_CategoryContacts, + appearance: .option(sectionTitle: "WHO CAN VIEW FOR 24 HOURS") + ), + ChatListNodeAdditionalCategory( + id: AdditionalCategoryId.closeFriends.rawValue, + icon: generateAvatarImage(size: CGSize(width: 40.0, height: 40.0), icon: generateTintedImage(image: UIImage(bundleImageName: "Call/StarHighlighted"), color: .white), iconScale: 1.0 * 0.6, cornerRadius: nil, color: .green), + smallIcon: generateAvatarImage(size: CGSize(width: 22.0, height: 22.0), icon: generateTintedImage(image: UIImage(bundleImageName: "Call/StarHighlighted"), color: .white), iconScale: 0.6 * 0.6, cornerRadius: 6.0, circleCorners: true, color: .green), + title: "Close Friends", + appearance: .option(sectionTitle: "WHO CAN VIEW FOR 24 HOURS") + ) + ] + + var selectedChats = Set() + var selectedCategories = Set() + if let privacy = storyItem.privacy { + selectedChats.formUnion(privacy.additionallyIncludePeers) + switch privacy.base { + case .everyone: + selectedCategories.insert(AdditionalCategoryId.everyone.rawValue) + case .contacts: + selectedCategories.insert(AdditionalCategoryId.contacts.rawValue) + case .closeFriends: + selectedCategories.insert(AdditionalCategoryId.closeFriends.rawValue) + } + } + + let selectionController = component.context.sharedContext.makeContactMultiselectionController(ContactMultiselectionControllerParams(context: component.context, mode: .chatSelection(ContactMultiselectionControllerMode.ChatSelection( + title: "Share Story", + searchPlaceholder: "Search contacts", + selectedChats: selectedChats, + additionalCategories: ContactMultiselectionControllerAdditionalCategories(categories: additionalCategories, selectedCategories: selectedCategories), + chatListFilters: nil, + displayPresence: true + )), options: [], filters: [.excludeSelf], alwaysEnabled: true, limit: 1000, reachedLimit: { _ in + })) + component.controller()?.present(selectionController, in: .window(.root), with: ViewControllerPresentationArguments(presentationAnimation: .modalSheet)) + + let _ = (selectionController.result + |> take(1) + |> deliverOnMainQueue).start(next: { [weak selectionController] result in + guard case let .result(peerIds, additionalCategoryIds) = result else { + selectionController?.dismiss() + return + } + + let _ = peerIds + let _ = additionalCategoryIds + + selectionController?.dismiss() + }) + } + } public func makeView() -> View { @@ -1191,6 +1547,53 @@ private final class HeaderContextReferenceContentSource: ContextReferenceContent } func transitionInfo() -> ContextControllerReferenceViewInfo? { - return ContextControllerReferenceViewInfo(referenceView: self.sourceView, contentAreaInScreenSpace: UIScreen.main.bounds) + return ContextControllerReferenceViewInfo(referenceView: self.sourceView, contentAreaInScreenSpace: UIScreen.main.bounds, actionsPosition: .top) } } + +private func generateParabollicMotionKeyframes(from sourcePoint: CGPoint, to targetPosition: CGPoint, elevation: CGFloat, duration: Double, curve: Transition.Animation.Curve, reverse: Bool) -> [CGPoint] { + let midPoint = CGPoint(x: (sourcePoint.x + targetPosition.x) / 2.0, y: sourcePoint.y - elevation) + + let x1 = sourcePoint.x + let y1 = sourcePoint.y + let x2 = midPoint.x + let y2 = midPoint.y + let x3 = targetPosition.x + let y3 = targetPosition.y + + let numPoints: Int = Int(ceil(Double(UIScreen.main.maximumFramesPerSecond) * duration)) + + var keyframes: [CGPoint] = [] + if abs(y1 - y3) < 5.0 && abs(x1 - x3) < 5.0 { + for rawI in 0 ..< numPoints { + let i = reverse ? (numPoints - 1 - rawI) : rawI + let ks = CGFloat(i) / CGFloat(numPoints - 1) + var k = curve.solve(at: reverse ? (1.0 - ks) : ks) + if reverse { + k = 1.0 - k + } + let x = sourcePoint.x * (1.0 - k) + targetPosition.x * k + let y = sourcePoint.y * (1.0 - k) + targetPosition.y * k + keyframes.append(CGPoint(x: x, y: y)) + } + } else { + let a = (x3 * (y2 - y1) + x2 * (y1 - y3) + x1 * (y3 - y2)) / ((x1 - x2) * (x1 - x3) * (x2 - x3)) + let b = (x1 * x1 * (y2 - y3) + x3 * x3 * (y1 - y2) + x2 * x2 * (y3 - y1)) / ((x1 - x2) * (x1 - x3) * (x2 - x3)) + let c = (x2 * x2 * (x3 * y1 - x1 * y3) + x2 * (x1 * x1 * y3 - x3 * x3 * y1) + x1 * x3 * (x3 - x1) * y2) / ((x1 - x2) * (x1 - x3) * (x2 - x3)) + + for rawI in 0 ..< numPoints { + let i = reverse ? (numPoints - 1 - rawI) : rawI + + let ks = CGFloat(i) / CGFloat(numPoints - 1) + var k = curve.solve(at: reverse ? (1.0 - ks) : ks) + if reverse { + k = 1.0 - k + } + let x = sourcePoint.x * (1.0 - k) + targetPosition.x * k + let y = a * x * x + b * x + c + keyframes.append(CGPoint(x: x, y: y)) + } + } + + return keyframes +} diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerViewSendMessage.swift b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerViewSendMessage.swift new file mode 100644 index 0000000000..826f991b34 --- /dev/null +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerViewSendMessage.swift @@ -0,0 +1,1660 @@ +import Foundation +import SwiftSignalKit +import TelegramCore +import AccountContext +import Display +import MessageInputPanelComponent +import UndoUI +import AttachmentUI +import TelegramUIPreferences +import MediaPickerUI +import LegacyMediaPickerUI +import LocationUI +import ChatEntityKeyboardInputNode +import WebUI +import ChatScheduleTimeController +import TextFormat +import PhoneNumberFormat +import ComposePollUI +import TelegramIntents +import LegacyUI +import WebSearchUI +import ChatTimerScreen +import PremiumUI +import ICloudResources +import LegacyComponents +import LegacyCamera +import StoryFooterPanelComponent +import TelegramPresentationData +import LegacyInstantVideoController +import TelegramPresentationData +import ShareController + +final class StoryItemSetContainerSendMessage { + weak var attachmentController: AttachmentController? + + var audioRecorderValue: ManagedAudioRecorder? + var audioRecorder = Promise() + + var videoRecorderValue: InstantVideoController? + var tempVideoRecorderValue: InstantVideoController? + var videoRecorder = Promise() + let controllerNavigationDisposable = MetaDisposable() + let enqueueMediaMessageDisposable = MetaDisposable() + + deinit { + self.controllerNavigationDisposable.dispose() + self.enqueueMediaMessageDisposable.dispose() + } + + func performSendMessageAction( + view: StoryItemSetContainerComponent.View + ) { + guard let component = view.component else { + return + } + guard let focusedItemId = view.focusedItemId, let focusedItem = view.currentSlice?.items.first(where: { $0.id == focusedItemId }) else { + return + } + guard let peerId = focusedItem.peerId else { + return + } + guard let inputPanelView = view.inputPanel.view as? MessageInputPanelComponent.View else { + return + } + + switch inputPanelView.getSendMessageInput() { + case let .text(text): + if !text.isEmpty { + component.context.engine.messages.enqueueOutgoingMessage( + to: peerId, + replyTo: nil, + content: .text(text) + ) + inputPanelView.clearSendMessageInput() + view.endEditing(true) + + if let controller = component.controller() { + let presentationData = component.context.sharedContext.currentPresentationData.with { $0 } + controller.present(UndoOverlayController( + presentationData: presentationData, + content: .succeed(text: "Message Sent"), + elevatedLayout: false, + animateInAsReplacement: false, + action: { _ in return false } + ), in: .current) + } + } + } + } + + func setMediaRecordingActive( + view: StoryItemSetContainerComponent.View, + isActive: Bool, + isVideo: Bool, + sendAction: Bool + ) { + guard let component = view.component else { + return + } + guard let focusedItemId = view.focusedItemId, let focusedItem = view.currentSlice?.items.first(where: { $0.id == focusedItemId }) else { + return + } + guard let peerId = focusedItem.peerId else { + return + } + let _ = (component.context.engine.data.get( + TelegramEngine.EngineData.Item.Peer.Peer(id: peerId) + ) + |> deliverOnMainQueue).start(next: { [weak view] peer in + guard let view, let component = view.component, let peer else { + return + } + + if isActive { + if isVideo { + if self.videoRecorderValue == nil { + if let currentInputPanelFrame = view.inputPanel.view?.frame { + self.videoRecorder.set(.single(legacyInstantVideoController(theme: component.theme, panelFrame: view.convert(currentInputPanelFrame, to: nil), context: component.context, peerId: peer.id, slowmodeState: nil, hasSchedule: peer.id.namespace != Namespaces.Peer.SecretChat, send: { [weak self, weak view] videoController, message in + guard let self, let view, let component = view.component else { + return + } + guard let message = message else { + self.videoRecorder.set(.single(nil)) + return + } + + let correlationId = Int64.random(in: 0 ..< Int64.max) + let updatedMessage = message + .withUpdatedCorrelationId(correlationId) + + self.videoRecorder.set(.single(nil)) + + self.sendMessages(view: view, peer: peer, messages: [updatedMessage]) + + let presentationData = component.context.sharedContext.currentPresentationData.with { $0 } + view.component?.controller()?.present(UndoOverlayController( + presentationData: presentationData, + content: .succeed(text: "Message Sent"), + elevatedLayout: false, + animateInAsReplacement: false, + action: { _ in return false } + ), in: .current) + }, displaySlowmodeTooltip: { [weak self] view, rect in + //self?.interfaceInteraction?.displaySlowmodeTooltip(view, rect) + let _ = self + }, presentSchedulePicker: { [weak self, weak view] done in + guard let self, let view else { + return + } + self.presentScheduleTimePicker(view: view, peer: peer, completion: { time in + done(time) + }) + }))) + } + } + } else { + if self.audioRecorderValue == nil { + self.audioRecorder.set(component.context.sharedContext.mediaManager.audioRecorder(beginWithTone: false, applicationBindings: component.context.sharedContext.applicationBindings, beganWithTone: { _ in + })) + } + } + } else { + if let audioRecorderValue = self.audioRecorderValue { + let _ = (audioRecorderValue.takenRecordedData() + |> deliverOnMainQueue).start(next: { [weak self, weak view] data in + guard let self, let view, let component = view.component else { + return + } + + self.audioRecorder.set(.single(nil)) + + guard let data else { + return + } + + if data.duration < 0.5 || !sendAction { + HapticFeedback().error() + } else { + let randomId = Int64.random(in: Int64.min ... Int64.max) + + let resource = LocalFileMediaResource(fileId: randomId) + component.context.account.postbox.mediaBox.storeResourceData(resource.id, data: data.compressedData) + + let waveformBuffer: Data? = data.waveform + + self.sendMessages(view: view, peer: peer, messages: [.message(text: "", attributes: [], inlineStickers: [:], mediaReference: .standalone(media: TelegramMediaFile(fileId: EngineMedia.Id(namespace: Namespaces.Media.LocalFile, id: randomId), partialReference: nil, resource: resource, previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "audio/ogg", size: Int64(data.compressedData.count), attributes: [.Audio(isVoice: true, duration: Int(data.duration), title: nil, performer: nil, waveform: waveformBuffer)])), replyToMessageId: nil, localGroupingKey: nil, correlationId: nil, bubbleUpEmojiOrStickersets: [])]) + + HapticFeedback().tap() + + let presentationData = component.context.sharedContext.currentPresentationData.with { $0 } + view.component?.controller()?.present(UndoOverlayController( + presentationData: presentationData, + content: .succeed(text: "Message Sent"), + elevatedLayout: false, + animateInAsReplacement: false, + action: { _ in return false } + ), in: .current) + } + }) + } else if let videoRecorderValue = self.videoRecorderValue { + let _ = videoRecorderValue + self.videoRecorder.set(.single(nil)) + } + } + }) + } + + func stopMediaRecorder() { + } + + func performInlineAction(view: StoryItemSetContainerComponent.View, item: StoryActionsComponent.Item) { + /*guard let component = view.component else { + return + } + guard let focusedItemId = view.focusedItemId, let focusedItem = view.currentSlice?.items.first(where: { $0.id == focusedItemId }) else { + return + } + + switch item.kind { + case .like: + if item.isActivated { + component.context.engine.messages.setMessageReactions( + id: targetMessageId, + reactions: [ + ] + ) + } else { + component.context.engine.messages.setMessageReactions( + id: targetMessageId, + reactions: [ + .builtin("❤") + ] + ) + } + case .share: + let _ = (component.context.engine.data.get( + TelegramEngine.EngineData.Item.Messages.Message(id: targetMessageId) + ) + |> deliverOnMainQueue).start(next: { [weak view] message in + guard let view, let message, let component = view.component, let controller = component.controller() else { + return + } + let shareController = ShareController( + context: component.context, + subject: .messages([message._asMessage()]), + externalShare: false, + immediateExternalShare: false, + updatedPresentationData: (component.context.sharedContext.currentPresentationData.with({ $0 }), + component.context.sharedContext.presentationData) + ) + controller.present(shareController, in: .window(.root)) + }) + }*/ + } + + private func clearInputText(view: StoryItemSetContainerComponent.View) { + guard let inputPanelView = view.inputPanel.view as? MessageInputPanelComponent.View else { + return + } + inputPanelView.clearSendMessageInput() + } + + enum AttachMenuSubject { + case `default` + } + + func presentAttachmentMenu( + view: StoryItemSetContainerComponent.View, + subject: AttachMenuSubject + ) { + guard let component = view.component else { + return + } + guard let focusedItemId = view.focusedItemId, let focusedItem = view.currentSlice?.items.first(where: { $0.id == focusedItemId }) else { + return + } + guard let peerId = focusedItem.peerId else { + return + } + guard let inputPanelView = view.inputPanel.view as? MessageInputPanelComponent.View else { + return + } + + var inputText = NSAttributedString(string: "") + switch inputPanelView.getSendMessageInput() { + case let .text(text): + inputText = NSAttributedString(string: text) + } + + let _ = (component.context.engine.data.get( + TelegramEngine.EngineData.Item.Peer.Peer(id: peerId) + ) + |> deliverOnMainQueue).start(next: { [weak self, weak view] peer in + guard let self, let view, let component = view.component else { + return + } + guard let peer else { + return + } + + let inputIsActive = !"".isEmpty + + view.endEditing(true) + + var banSendText: (Int32, Bool)? + var bannedSendPhotos: (Int32, Bool)? + var bannedSendVideos: (Int32, Bool)? + var bannedSendFiles: (Int32, Bool)? + + let _ = bannedSendFiles + + var canSendPolls = true + if case let .user(peer) = peer, peer.botInfo == nil { + canSendPolls = false + } else if case .secretChat = peer { + canSendPolls = false + } else if case let .channel(channel) = peer { + if let value = channel.hasBannedPermission(.banSendPhotos) { + bannedSendPhotos = value + } + if let value = channel.hasBannedPermission(.banSendVideos) { + bannedSendVideos = value + } + if let value = channel.hasBannedPermission(.banSendFiles) { + bannedSendFiles = value + } + if let value = channel.hasBannedPermission(.banSendText) { + banSendText = value + } + if channel.hasBannedPermission(.banSendPolls) != nil { + canSendPolls = false + } + } else if case let .legacyGroup(group) = peer { + if group.hasBannedPermission(.banSendPhotos) { + bannedSendPhotos = (Int32.max, false) + } + if group.hasBannedPermission(.banSendVideos) { + bannedSendVideos = (Int32.max, false) + } + if group.hasBannedPermission(.banSendFiles) { + bannedSendFiles = (Int32.max, false) + } + if group.hasBannedPermission(.banSendText) { + banSendText = (Int32.max, false) + } + if group.hasBannedPermission(.banSendPolls) { + canSendPolls = false + } + } + + var availableButtons: [AttachmentButtonType] = [.gallery, .file] + if banSendText == nil { + availableButtons.append(.location) + availableButtons.append(.contact) + } + if canSendPolls { + availableButtons.insert(.poll, at: max(0, availableButtons.count - 1)) + } + + let isScheduledMessages = !"".isEmpty + + var peerType: AttachMenuBots.Bot.PeerFlags = [] + if case let .user(user) = peer { + if let _ = user.botInfo { + peerType.insert(.bot) + } else { + peerType.insert(.user) + } + } else if case .legacyGroup = peer { + peerType = .group + } else if case let .channel(channel) = peer { + if case .broadcast = channel.info { + peerType = .channel + } else { + peerType = .group + } + } + + let buttons: Signal<([AttachmentButtonType], [AttachmentButtonType], AttachmentButtonType?), NoError> + if !isScheduledMessages { + buttons = component.context.engine.messages.attachMenuBots() + |> map { attachMenuBots in + var buttons = availableButtons + var allButtons = availableButtons + var initialButton: AttachmentButtonType? + switch subject { + case .default: + initialButton = .gallery + /*case .edit: + break + case .gift: + initialButton = .gift*/ + } + + for bot in attachMenuBots.reversed() { + var peerType = peerType + if bot.peer.id == peer.id { + peerType.insert(.sameBot) + peerType.remove(.bot) + } + let button: AttachmentButtonType = .app(bot.peer, bot.shortName, bot.icons) + if !bot.peerTypes.intersection(peerType).isEmpty { + buttons.insert(button, at: 1) + + /*if case let .bot(botId, _, _) = subject { + if initialButton == nil && bot.peer.id == botId { + initialButton = button + } + }*/ + } + allButtons.insert(button, at: 1) + } + + return (buttons, allButtons, initialButton) + } + } else { + buttons = .single((availableButtons, availableButtons, .gallery)) + } + + let dataSettings = component.context.sharedContext.accountManager.transaction { transaction -> GeneratedMediaStoreSettings in + let entry = transaction.getSharedData(ApplicationSpecificSharedDataKeys.generatedMediaStoreSettings)?.get(GeneratedMediaStoreSettings.self) + return entry ?? GeneratedMediaStoreSettings.defaultSettings + } + + let premiumConfiguration = PremiumConfiguration.with(appConfiguration: component.context.currentAppConfiguration.with { $0 }) + let premiumGiftOptions: [CachedPremiumGiftOption] + if !premiumConfiguration.isPremiumDisabled && premiumConfiguration.showPremiumGiftInAttachMenu, case let .user(user) = peer, !user.isPremium && !user.isDeleted && user.botInfo == nil && !user.flags.contains(.isSupport) { + premiumGiftOptions = []//self.presentationInterfaceState.premiumGiftOptions + //TODO:premium gift options + } else { + premiumGiftOptions = [] + } + + let _ = combineLatest(queue: Queue.mainQueue(), buttons, dataSettings).start(next: { [weak self, weak view] buttonsAndInitialButton, dataSettings in + guard let self, let view, let component = view.component else { + return + } + + var (buttons, allButtons, initialButton) = buttonsAndInitialButton + if !premiumGiftOptions.isEmpty { + buttons.insert(.gift, at: 1) + } + let _ = allButtons + + guard let initialButton = initialButton else { + return + } + + let currentMediaController = Atomic(value: nil) + let currentFilesController = Atomic(value: nil) + let currentLocationController = Atomic(value: nil) + + let theme = component.theme + let updatedPresentationData: (initial: PresentationData, signal: Signal) = (component.context.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: theme), component.context.sharedContext.presentationData |> map { $0.withUpdated(theme: theme) }) + + let attachmentController = AttachmentController( + context: component.context, + updatedPresentationData: updatedPresentationData, + chatLocation: .peer(id: peer.id), + buttons: buttons, + initialButton: initialButton, + makeEntityInputView: { [weak view] in + guard let view, let component = view.component else { + return nil + } + return EntityInputView( + context: component.context, + isDark: true, + areCustomEmojiEnabled: true //TODO:check custom emoji + ) + } + ) + attachmentController.didDismiss = { [weak self, weak view] in + guard let self, let view else { + return + } + self.attachmentController = nil + view.updateIsProgressPaused() + } + attachmentController.getSourceRect = { [weak view] in + guard let view else { + return nil + } + guard let inputPanelView = view.inputPanel.view as? MessageInputPanelComponent.View else { + return nil + } + guard let attachmentButtonView = inputPanelView.getAttachmentButtonView() else { + return nil + } + return attachmentButtonView.convert(attachmentButtonView.bounds, to: view) + } + attachmentController.requestController = { [weak self, weak view, weak attachmentController] type, completion in + guard let self, let view, let component = view.component else { + return + } + switch type { + case .gallery: + self.controllerNavigationDisposable.set(nil) + let existingController = currentMediaController.with { $0 } + if let controller = existingController { + completion(controller, controller.mediaPickerContext) + controller.prepareForReuse() + return + } + self.presentMediaPicker( + view: view, + peer: peer, + replyToMessageId: nil, + saveEditedPhotos: dataSettings.storeEditedPhotos, + bannedSendPhotos: bannedSendPhotos, + bannedSendVideos: bannedSendVideos, + present: { controller, mediaPickerContext in + let _ = currentMediaController.swap(controller) + if !inputText.string.isEmpty { + mediaPickerContext?.setCaption(inputText) + } + completion(controller, mediaPickerContext) + }, updateMediaPickerContext: { [weak attachmentController] mediaPickerContext in + attachmentController?.mediaPickerContext = mediaPickerContext + }, completion: { [weak self, weak view] signals, silentPosting, scheduleTime, getAnimatedTransitionSource, completion in + guard let self, let view else { + return + } + if !inputText.string.isEmpty { + self.clearInputText(view: view) + } + self.enqueueMediaMessages(view: view, peer: peer, replyToMessageId: nil, signals: signals, silentPosting: silentPosting, scheduleTime: scheduleTime, getAnimatedTransitionSource: getAnimatedTransitionSource, completion: completion) + } + ) + case .file: + self.controllerNavigationDisposable.set(nil) + let existingController = currentFilesController.with { $0 } + if let controller = existingController as? AttachmentContainable, let mediaPickerContext = controller.mediaPickerContext { + completion(controller, mediaPickerContext) + controller.prepareForReuse() + return + } + let theme = component.theme + let updatedPresentationData: (initial: PresentationData, signal: Signal) = (component.context.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: theme), component.context.sharedContext.presentationData |> map { $0.withUpdated(theme: theme) }) + + let controller = component.context.sharedContext.makeAttachmentFileController(context: component.context, updatedPresentationData: updatedPresentationData, bannedSendMedia: bannedSendFiles, presentGallery: { [weak self, weak view, weak attachmentController] in + guard let self, let view else { + return + } + attachmentController?.dismiss(animated: true) + self.presentFileGallery(view: view, peer: peer, replyMessageId: nil) + }, presentFiles: { [weak self, weak view, weak attachmentController] in + guard let self, let view else { + return + } + attachmentController?.dismiss(animated: true) + self.presentICloudFileGallery(view: view, peer: peer, replyMessageId: nil) + }, send: { [weak view] mediaReference in + guard let view, let component = view.component else { + return + } + let message: EnqueueMessage = .message(text: "", attributes: [], inlineStickers: [:], mediaReference: mediaReference, replyToMessageId: nil, localGroupingKey: nil, correlationId: nil, bubbleUpEmojiOrStickersets: []) + let _ = (enqueueMessages(account: component.context.account, peerId: peer.id, messages: [message.withUpdatedReplyToMessageId(nil)]) + |> deliverOnMainQueue).start() + + if let controller = component.controller() { + let presentationData = component.context.sharedContext.currentPresentationData.with { $0 } + controller.present(UndoOverlayController( + presentationData: presentationData, + content: .succeed(text: "Message Sent"), + elevatedLayout: false, + animateInAsReplacement: false, + action: { _ in return false } + ), in: .current) + } + }) + let _ = currentFilesController.swap(controller) + if let controller = controller as? AttachmentContainable, let mediaPickerContext = controller.mediaPickerContext { + completion(controller, mediaPickerContext) + } + case .location: + self.controllerNavigationDisposable.set(nil) + let existingController = currentLocationController.with { $0 } + if let controller = existingController { + completion(controller, controller.mediaPickerContext) + controller.prepareForReuse() + return + } + let selfPeerId: EnginePeer.Id + if case let .channel(peer) = peer, case .broadcast = peer.info { + selfPeerId = peer.id + } else if case let .channel(peer) = peer, case .group = peer.info, peer.hasPermission(.canBeAnonymous) { + selfPeerId = peer.id + } else { + selfPeerId = component.context.account.peerId + } + let _ = (component.context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: selfPeerId)) + |> deliverOnMainQueue).start(next: { [weak self, weak view] selfPeer in + guard let self, let view, let component = view.component, let selfPeer else { + return + } + let hasLiveLocation = peer.id.namespace != Namespaces.Peer.SecretChat && peer.id != component.context.account.peerId + let theme = component.theme + let updatedPresentationData: (initial: PresentationData, signal: Signal) = (component.context.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: theme), component.context.sharedContext.presentationData |> map { $0.withUpdated(theme: theme) }) + let controller = LocationPickerController(context: component.context, updatedPresentationData: updatedPresentationData, mode: .share(peer: peer, selfPeer: selfPeer, hasLiveLocation: hasLiveLocation), completion: { [weak self, weak view] location, _ in + guard let self, let view else { + return + } + let message: EnqueueMessage = .message(text: "", attributes: [], inlineStickers: [:], mediaReference: .standalone(media: location), replyToMessageId: nil, localGroupingKey: nil, correlationId: nil, bubbleUpEmojiOrStickersets: []) + self.sendMessages(view: view, peer: peer, messages: [message]) + }) + completion(controller, controller.mediaPickerContext) + + let _ = currentLocationController.swap(controller) + }) + case .contact: + let theme = component.theme + let updatedPresentationData: (initial: PresentationData, signal: Signal) = (component.context.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: theme), component.context.sharedContext.presentationData |> map { $0.withUpdated(theme: theme) }) + let contactsController = component.context.sharedContext.makeContactSelectionController(ContactSelectionControllerParams(context: component.context, updatedPresentationData: updatedPresentationData, title: { $0.Contacts_Title }, displayDeviceContacts: true, multipleSelection: true)) + contactsController.presentScheduleTimePicker = { [weak self, weak view] completion in + guard let self, let view else { + return + } + self.presentScheduleTimePicker(view: view, peer: peer, completion: completion) + } + contactsController.navigationPresentation = .modal + if let contactsController = contactsController as? AttachmentContainable, let mediaPickerContext = contactsController.mediaPickerContext { + completion(contactsController, mediaPickerContext) + } + self.controllerNavigationDisposable.set((contactsController.result + |> deliverOnMainQueue).start(next: { [weak self, weak view] peers in + guard let self, let view, let (peers, _, silent, scheduleTime, text) = peers else { + return + } + + let targetPeer = peer + + var textEnqueueMessage: EnqueueMessage? + if let text = text, text.length > 0 { + var attributes: [EngineMessage.Attribute] = [] + let entities = generateTextEntities(text.string, enabledTypes: .all, currentEntities: generateChatInputTextEntities(text)) + if !entities.isEmpty { + attributes.append(TextEntitiesMessageAttribute(entities: entities)) + } + textEnqueueMessage = .message(text: text.string, attributes: attributes, inlineStickers: [:], mediaReference: nil, replyToMessageId: nil, localGroupingKey: nil, correlationId: nil, bubbleUpEmojiOrStickersets: []) + } + if peers.count > 1 { + var enqueueMessages: [EnqueueMessage] = [] + if let textEnqueueMessage = textEnqueueMessage { + enqueueMessages.append(textEnqueueMessage) + } + for peer in peers { + var media: TelegramMediaContact? + switch peer { + case let .peer(contact, _, _): + guard let contact = contact as? TelegramUser, let phoneNumber = contact.phone else { + continue + } + let contactData = DeviceContactExtendedData(basicData: DeviceContactBasicData(firstName: contact.firstName ?? "", lastName: contact.lastName ?? "", phoneNumbers: [DeviceContactPhoneNumberData(label: "_$!!$_", value: phoneNumber)]), middleName: "", prefix: "", suffix: "", organization: "", jobTitle: "", department: "", emailAddresses: [], urls: [], addresses: [], birthdayDate: nil, socialProfiles: [], instantMessagingProfiles: [], note: "") + + let phone = contactData.basicData.phoneNumbers[0].value + media = TelegramMediaContact(firstName: contactData.basicData.firstName, lastName: contactData.basicData.lastName, phoneNumber: phone, peerId: contact.id, vCardData: nil) + case let .deviceContact(_, basicData): + guard !basicData.phoneNumbers.isEmpty else { + continue + } + let contactData = DeviceContactExtendedData(basicData: basicData, middleName: "", prefix: "", suffix: "", organization: "", jobTitle: "", department: "", emailAddresses: [], urls: [], addresses: [], birthdayDate: nil, socialProfiles: [], instantMessagingProfiles: [], note: "") + + let phone = contactData.basicData.phoneNumbers[0].value + media = TelegramMediaContact(firstName: contactData.basicData.firstName, lastName: contactData.basicData.lastName, phoneNumber: phone, peerId: nil, vCardData: nil) + } + + if let media = media { + let message = EnqueueMessage.message(text: "", attributes: [], inlineStickers: [:], mediaReference: .standalone(media: media), replyToMessageId: nil, localGroupingKey: nil, correlationId: nil, bubbleUpEmojiOrStickersets: []) + enqueueMessages.append(message) + } + } + + self.sendMessages(view: view, peer: peer, messages: self.transformEnqueueMessages(view: view, messages: enqueueMessages, silentPosting: silent, scheduleTime: scheduleTime)) + } else if let peer = peers.first { + let dataSignal: Signal<(EnginePeer?, DeviceContactExtendedData?), NoError> + switch peer { + case let .peer(contact, _, _): + guard let contact = contact as? TelegramUser, let phoneNumber = contact.phone else { + return + } + let contactData = DeviceContactExtendedData(basicData: DeviceContactBasicData(firstName: contact.firstName ?? "", lastName: contact.lastName ?? "", phoneNumbers: [DeviceContactPhoneNumberData(label: "_$!!$_", value: phoneNumber)]), middleName: "", prefix: "", suffix: "", organization: "", jobTitle: "", department: "", emailAddresses: [], urls: [], addresses: [], birthdayDate: nil, socialProfiles: [], instantMessagingProfiles: [], note: "") + let context = component.context + dataSignal = (component.context.sharedContext.contactDataManager?.basicData() ?? .single([:])) + |> take(1) + |> mapToSignal { basicData -> Signal<(EnginePeer?, DeviceContactExtendedData?), NoError> in + var stableId: String? + let queryPhoneNumber = formatPhoneNumber(context: context, number: phoneNumber) + outer: for (id, data) in basicData { + for phoneNumber in data.phoneNumbers { + if formatPhoneNumber(context: context, number: phoneNumber.value) == queryPhoneNumber { + stableId = id + break outer + } + } + } + + if let stableId = stableId { + return (context.sharedContext.contactDataManager?.extendedData(stableId: stableId) ?? .single(nil)) + |> take(1) + |> map { extendedData -> (EnginePeer?, DeviceContactExtendedData?) in + return (EnginePeer(contact), extendedData) + } + } else { + return .single((EnginePeer(contact), contactData)) + } + } + case let .deviceContact(id, _): + dataSignal = (component.context.sharedContext.contactDataManager?.extendedData(stableId: id) ?? .single(nil)) + |> take(1) + |> map { extendedData -> (EnginePeer?, DeviceContactExtendedData?) in + return (nil, extendedData) + } + } + self.controllerNavigationDisposable.set((dataSignal + |> deliverOnMainQueue).start(next: { [weak self, weak view] peerAndContactData in + guard let self, let view, let contactData = peerAndContactData.1, contactData.basicData.phoneNumbers.count != 0 else { + return + } + if contactData.isPrimitive { + let phone = contactData.basicData.phoneNumbers[0].value + let media = TelegramMediaContact(firstName: contactData.basicData.firstName, lastName: contactData.basicData.lastName, phoneNumber: phone, peerId: peerAndContactData.0?.id, vCardData: nil) + var enqueueMessages: [EnqueueMessage] = [] + if let textEnqueueMessage = textEnqueueMessage { + enqueueMessages.append(textEnqueueMessage) + } + enqueueMessages.append(.message(text: "", attributes: [], inlineStickers: [:], mediaReference: .standalone(media: media), replyToMessageId: nil, localGroupingKey: nil, correlationId: nil, bubbleUpEmojiOrStickersets: [])) + + self.sendMessages(view: view, peer: targetPeer, messages: self.transformEnqueueMessages(view: view, messages: enqueueMessages, silentPosting: silent, scheduleTime: scheduleTime)) + } else { + let contactController = component.context.sharedContext.makeDeviceContactInfoController(context: component.context, subject: .filter(peer: peerAndContactData.0?._asPeer(), contactId: nil, contactData: contactData, completion: { [weak self, weak view] peer, contactData in + guard let self, let view else { + return + } + if contactData.basicData.phoneNumbers.isEmpty { + return + } + let phone = contactData.basicData.phoneNumbers[0].value + if let vCardData = contactData.serializedVCard() { + let media = TelegramMediaContact(firstName: contactData.basicData.firstName, lastName: contactData.basicData.lastName, phoneNumber: phone, peerId: peer?.id, vCardData: vCardData) + + var enqueueMessages: [EnqueueMessage] = [] + if let textEnqueueMessage = textEnqueueMessage { + enqueueMessages.append(textEnqueueMessage) + } + enqueueMessages.append(.message(text: "", attributes: [], inlineStickers: [:], mediaReference: .standalone(media: media), replyToMessageId: nil, localGroupingKey: nil, correlationId: nil, bubbleUpEmojiOrStickersets: [])) + + self.sendMessages(view: view, peer: targetPeer, messages: self.transformEnqueueMessages(view: view, messages: enqueueMessages, silentPosting: silent, scheduleTime: scheduleTime)) + } + }), completed: nil, cancelled: nil) + component.controller()?.push(contactController) + } + })) + } + })) + case .poll: + let controller = self.configurePollCreation(view: view, peer: peer, targetMessageId: nil) + completion(controller, controller?.mediaPickerContext) + self.controllerNavigationDisposable.set(nil) + case .gift: + /*let premiumGiftOptions = strongSelf.presentationInterfaceState.premiumGiftOptions + if !premiumGiftOptions.isEmpty { + let controller = PremiumGiftScreen(context: context, peerId: peer.id, options: premiumGiftOptions, source: .attachMenu, pushController: { [weak self] c in + if let strongSelf = self { + strongSelf.push(c) + } + }, completion: { [weak self] in + if let strongSelf = self { + strongSelf.hintPlayNextOutgoingGift() + strongSelf.attachmentController?.dismiss(animated: true) + } + }) + completion(controller, controller.mediaPickerContext) + strongSelf.controllerNavigationDisposable.set(nil) + + let _ = ApplicationSpecificNotice.incrementDismissedPremiumGiftSuggestion(accountManager: context.sharedContext.accountManager, peerId: peer.id).start() + }*/ + //TODO:gift controller + break + case let .app(bot, botName, _): + var payload: String? + var fromAttachMenu = true + /*if case let .bot(_, botPayload, _) = subject { + payload = botPayload + fromAttachMenu = false + }*/ + payload = nil + fromAttachMenu = true + let params = WebAppParameters(peerId: peer.id, botId: bot.id, botName: botName, url: nil, queryId: nil, payload: payload, buttonText: nil, keepAliveSignal: nil, fromMenu: false, fromAttachMenu: fromAttachMenu, isInline: false, isSimple: false) + let theme = component.theme + let updatedPresentationData: (initial: PresentationData, signal: Signal) = (component.context.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: theme), component.context.sharedContext.presentationData |> map { $0.withUpdated(theme: theme) }) + let controller = WebAppController(context: component.context, updatedPresentationData: updatedPresentationData, params: params, replyToMessageId: nil, threadId: nil) + controller.openUrl = { [weak self] url in + guard let self else { + return + } + let _ = self + //self?.openUrl(url, concealed: true, forceExternal: true) + } + controller.getNavigationController = { [weak view] in + guard let view, let controller = view.component?.controller() else { + return nil + } + return controller.navigationController as? NavigationController + } + controller.completion = { [weak self] in + guard let self else { + return + } + let _ = self + /*if let strongSelf = self { + strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: false, { + $0.updatedInterfaceState { $0.withUpdatedReplyMessageId(nil) } + }) + strongSelf.chatDisplayNode.historyNode.scrollToEndOfHistory() + }*/ + } + completion(controller, controller.mediaPickerContext) + self.controllerNavigationDisposable.set(nil) + default: + break + } + } + let present = { [weak self, weak view] in + guard let self, let view, let controller = view.component?.controller() else { + return + } + attachmentController.navigationPresentation = .flatModal + controller.push(attachmentController) + self.attachmentController = attachmentController + view.updateIsProgressPaused() + } + + if inputIsActive { + Queue.mainQueue().after(0.15, { + present() + }) + } else { + present() + } + }) + }) + } + + private func presentMediaPicker( + view: StoryItemSetContainerComponent.View, + peer: EnginePeer, + replyToMessageId: EngineMessage.Id?, + subject: MediaPickerScreen.Subject = .assets(nil, .default), + saveEditedPhotos: Bool, + bannedSendPhotos: (Int32, Bool)?, + bannedSendVideos: (Int32, Bool)?, + present: @escaping (MediaPickerScreen, AttachmentMediaPickerContext?) -> Void, + updateMediaPickerContext: @escaping (AttachmentMediaPickerContext?) -> Void, + completion: @escaping ([Any], Bool, Int32?, @escaping (String) -> UIView?, @escaping () -> Void) -> Void + ) { + guard let component = view.component else { + return + } + let theme = component.theme + let controller = MediaPickerScreen(context: component.context, updatedPresentationData: (component.context.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: theme), component.context.sharedContext.presentationData |> map { $0.withUpdated(theme: theme) }), peer: peer, threadTitle: nil, chatLocation: .peer(id: peer.id), bannedSendPhotos: bannedSendPhotos, bannedSendVideos: bannedSendVideos, subject: subject, saveEditedPhotos: saveEditedPhotos) + let mediaPickerContext = controller.mediaPickerContext + controller.openCamera = { [weak self, weak view] cameraView in + guard let self, let view else { + return + } + self.openCamera(view: view, peer: peer, replyToMessageId: replyToMessageId, cameraView: cameraView) + } + controller.presentWebSearch = { [weak self, weak view, weak controller] mediaGroups, activateOnDisplay in + guard let self, let view, let controller else { + return + } + self.presentWebSearch(view: view, editingMessage: false, attachment: true, activateOnDisplay: activateOnDisplay, present: { [weak controller] c, a in + controller?.present(c, in: .current) + if let webSearchController = c as? WebSearchController { + webSearchController.searchingUpdated = { [weak mediaGroups] searching in + if let mediaGroups = mediaGroups, mediaGroups.isNodeLoaded { + let transition = ContainedViewLayoutTransition.animated(duration: 0.2, curve: .easeInOut) + transition.updateAlpha(node: mediaGroups.displayNode, alpha: searching ? 0.0 : 1.0) + mediaGroups.displayNode.isUserInteractionEnabled = !searching + } + } + webSearchController.present(mediaGroups, in: .current) + webSearchController.dismissed = { + updateMediaPickerContext(mediaPickerContext) + } + controller?.webSearchController = webSearchController + updateMediaPickerContext(webSearchController.mediaPickerContext) + } + }) + } + controller.presentSchedulePicker = { [weak self, weak view] media, done in + guard let self, let view else { + return + } + self.presentScheduleTimePicker(view: view, peer: peer, style: media ? .media : .default, completion: { time in + done(time) + }) + } + controller.presentTimerPicker = { [weak self, weak view] done in + guard let self, let view else { + return + } + self.presentTimerPicker(view: view, peer: peer, style: .media, completion: { time in + done(time) + }) + } + controller.getCaptionPanelView = { [weak self, weak view] in + guard let self, let view else { + return nil + } + return self.getCaptionPanelView(view: view, peer: peer) + } + controller.legacyCompletion = { signals, silently, scheduleTime, getAnimatedTransitionSource, sendCompletion in + completion(signals, silently, scheduleTime, getAnimatedTransitionSource, sendCompletion) + } + present(controller, mediaPickerContext) + } + + private func presentOldMediaPicker(view: StoryItemSetContainerComponent.View, peer: EnginePeer, replyMessageId: EngineMessage.Id?, fileMode: Bool, editingMedia: Bool, present: @escaping (AttachmentContainable, AttachmentMediaPickerContext) -> Void, completion: @escaping ([Any], Bool, Int32) -> Void) { + guard let component = view.component else { + return + } + guard let inputPanelView = view.inputPanel.view as? MessageInputPanelComponent.View else { + return + } + var inputText = NSAttributedString(string: "") + switch inputPanelView.getSendMessageInput() { + case let .text(text): + inputText = NSAttributedString(string: text) + } + + let engine = component.context.engine + let _ = (component.context.sharedContext.accountManager.transaction { transaction -> Signal<(GeneratedMediaStoreSettings, EngineConfiguration.SearchBots), NoError> in + let entry = transaction.getSharedData(ApplicationSpecificSharedDataKeys.generatedMediaStoreSettings)?.get(GeneratedMediaStoreSettings.self) + + return engine.data.get(TelegramEngine.EngineData.Item.Configuration.SearchBots()) + |> map { configuration -> (GeneratedMediaStoreSettings, EngineConfiguration.SearchBots) in + return (entry ?? GeneratedMediaStoreSettings.defaultSettings, configuration) + } + } + |> switchToLatest + |> deliverOnMainQueue).start(next: { [weak self, weak view] settings, searchBotsConfiguration in + guard let self, let view, let component = view.component else { + return + } + var selectionLimit: Int = 100 + var slowModeEnabled = false + if case let .channel(channel) = peer, channel.isRestrictedBySlowmode { + selectionLimit = 10 + slowModeEnabled = true + } + + let presentationData = component.context.sharedContext.currentPresentationData.with { $0 } + + let _ = legacyAssetPicker(context: component.context, presentationData: presentationData, editingMedia: editingMedia, fileMode: fileMode, peer: peer._asPeer(), threadTitle: nil, saveEditedPhotos: settings.storeEditedPhotos, allowGrouping: true, selectionLimit: selectionLimit).start(next: { [weak self, weak view] generator in + if let view, let component = view.component, let controller = component.controller() { + let presentationData = component.context.sharedContext.currentPresentationData.with { $0 } + + let legacyController = LegacyController(presentation: fileMode ? .navigation : .custom, theme: presentationData.theme, initialLayout: controller.currentlyAppliedLayout) + legacyController.navigationPresentation = .modal + legacyController.statusBar.statusBarStyle = presentationData.theme.rootController.statusBarStyle.style + legacyController.controllerLoaded = { [weak legacyController] in + legacyController?.view.disablesInteractiveTransitionGestureRecognizer = true + legacyController?.view.disablesInteractiveModalDismiss = true + } + let controller = generator(legacyController.context) + + legacyController.bind(controller: controller) + legacyController.deferScreenEdgeGestures = [.top] + + configureLegacyAssetPicker(controller, context: component.context, peer: peer._asPeer(), chatLocation: .peer(id: peer.id), initialCaption: inputText, hasSchedule: peer.id.namespace != Namespaces.Peer.SecretChat, presentWebSearch: editingMedia ? nil : { [weak view, weak legacyController] in + if let view, let component = view.component { + let theme = component.theme + let updatedPresentationData: (initial: PresentationData, signal: Signal) = (component.context.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: theme), component.context.sharedContext.presentationData |> map { $0.withUpdated(theme: theme) }) + let controller = WebSearchController(context: component.context, updatedPresentationData: updatedPresentationData, peer: peer, chatLocation: .peer(id: peer.id), configuration: searchBotsConfiguration, mode: .media(attachment: false, completion: { [weak view] results, selectionState, editingState, silentPosting in + if let legacyController = legacyController { + legacyController.dismiss() + } + guard let view else { + return + } + legacyEnqueueWebSearchMessages(selectionState, editingState, enqueueChatContextResult: { [weak view] result in + if let strongSelf = self, let view { + strongSelf.enqueueChatContextResult(view: view, peer: peer, replyMessageId: replyMessageId, results: results, result: result, hideVia: true) + } + }, enqueueMediaMessages: { [weak view] signals in + if let strongSelf = self, let view { + if editingMedia { + strongSelf.editMessageMediaWithLegacySignals(view: view, signals: signals) + } else { + strongSelf.enqueueMediaMessages(view: view, peer: peer, replyToMessageId: replyMessageId, signals: signals, silentPosting: silentPosting) + } + } + }) + })) + controller.getCaptionPanelView = { [weak view] in + guard let self, let view else { + return nil + } + return self.getCaptionPanelView(view: view, peer: peer) + } + component.controller()?.push(controller) + } + }, presentSelectionLimitExceeded: { [weak view] in + guard let view else { + return + } + + let text: String + if slowModeEnabled { + text = presentationData.strings.Chat_SlowmodeAttachmentLimitReached + } else { + text = presentationData.strings.Chat_AttachmentLimitReached + } + + view.component?.controller()?.present(standardTextAlertController(theme: AlertControllerTheme(presentationData: presentationData), title: nil, text: text, actions: [TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_OK, action: {})]), in: .window(.root)) + }, presentSchedulePicker: { [weak view] media, done in + if let strongSelf = self, let view { + strongSelf.presentScheduleTimePicker(view: view, peer: peer, style: media ? .media : .default, completion: { time in + done(time) + }) + } + }, presentTimerPicker: { [weak view] done in + if let strongSelf = self, let view { + strongSelf.presentTimerPicker(view: view, peer: peer, style: .media, completion: { time in + done(time) + }) + } + }, getCaptionPanelView: { [weak view] in + guard let self, let view else { + return nil + } + return self.getCaptionPanelView(view: view, peer: peer) + }) + controller.descriptionGenerator = legacyAssetPickerItemGenerator() + controller.completionBlock = { [weak legacyController] signals, silentPosting, scheduleTime in + if let legacyController = legacyController { + legacyController.dismiss(animated: true) + completion(signals!, silentPosting, scheduleTime) + } + } + controller.dismissalBlock = { [weak legacyController] in + if let legacyController = legacyController { + legacyController.dismiss(animated: true) + } + } + view.endEditing(true) + present(legacyController, LegacyAssetPickerContext(controller: controller)) + } + }) + }) + } + + private func presentFileGallery(view: StoryItemSetContainerComponent.View, peer: EnginePeer, replyMessageId: EngineMessage.Id?, editingMessage: Bool = false) { + self.presentOldMediaPicker(view: view, peer: peer, replyMessageId: replyMessageId, fileMode: true, editingMedia: editingMessage, present: { [weak view] c, _ in + view?.component?.controller()?.push(c) + }, completion: { [weak self, weak view] signals, silentPosting, scheduleTime in + guard let self, let view else { + return + } + if editingMessage { + self.editMessageMediaWithLegacySignals(view: view, signals: signals) + } else { + self.enqueueMediaMessages(view: view, peer: peer, replyToMessageId: replyMessageId, signals: signals, silentPosting: silentPosting, scheduleTime: scheduleTime > 0 ? scheduleTime : nil) + } + }) + } + + private func presentICloudFileGallery(view: StoryItemSetContainerComponent.View, peer: EnginePeer, replyMessageId: EngineMessage.Id?) { + guard let component = view.component else { + return + } + let _ = (component.context.engine.data.get( + TelegramEngine.EngineData.Item.Peer.Peer(id: component.context.account.peerId), + TelegramEngine.EngineData.Item.Configuration.UserLimits(isPremium: false), + TelegramEngine.EngineData.Item.Configuration.UserLimits(isPremium: true) + ) + |> deliverOnMainQueue).start(next: { [weak self, weak view] result in + guard let self, let view, let component = view.component else { + return + } + let (accountPeer, limits, premiumLimits) = result + let isPremium = accountPeer?.isPremium ?? false + + let presentationData = component.context.sharedContext.currentPresentationData.with { $0 } + + component.controller()?.present(legacyICloudFilePicker(theme: presentationData.theme, completion: { [weak self, weak view] urls in + if let strongSelf = self, let view, !urls.isEmpty { + var signals: [Signal] = [] + for url in urls { + signals.append(iCloudFileDescription(url)) + } + strongSelf.enqueueMediaMessageDisposable.set((combineLatest(signals) + |> deliverOnMainQueue).start(next: { [weak view] results in + if let strongSelf = self, let view, let component = view.component { + for item in results { + if let item = item { + if item.fileSize > Int64(premiumLimits.maxUploadFileParts) * 512 * 1024 { + let controller = PremiumLimitScreen(context: component.context, subject: .files, count: 4, action: { + }) + component.controller()?.push(controller) + return + } else if item.fileSize > Int64(limits.maxUploadFileParts) * 512 * 1024 && !isPremium { + let context = component.context + var replaceImpl: ((ViewController) -> Void)? + let controller = PremiumLimitScreen(context: context, subject: .files, count: 2, action: { + replaceImpl?(PremiumIntroScreen(context: context, source: .upload)) + }) + replaceImpl = { [weak controller] c in + controller?.replace(with: c) + } + component.controller()?.push(controller) + return + } + } + } + + var groupingKey: Int64? + var fileTypes: (music: Bool, other: Bool) = (false, false) + if results.count > 1 { + for item in results { + if let item = item { + let pathExtension = (item.fileName as NSString).pathExtension.lowercased() + if ["mp3", "m4a"].contains(pathExtension) { + fileTypes.music = true + } else { + fileTypes.other = true + } + } + } + } + if fileTypes.music != fileTypes.other { + groupingKey = Int64.random(in: Int64.min ... Int64.max) + } + + var messages: [EnqueueMessage] = [] + for item in results { + if let item = item { + let fileId = Int64.random(in: Int64.min ... Int64.max) + let mimeType = guessMimeTypeByFileExtension((item.fileName as NSString).pathExtension) + var previewRepresentations: [TelegramMediaImageRepresentation] = [] + if mimeType.hasPrefix("image/") || mimeType == "application/pdf" { + previewRepresentations.append(TelegramMediaImageRepresentation(dimensions: PixelDimensions(width: 320, height: 320), resource: ICloudFileResource(urlData: item.urlData, thumbnail: true), progressiveSizes: [], immediateThumbnailData: nil, hasVideo: false, isPersonal: false)) + } + var attributes: [TelegramMediaFileAttribute] = [] + attributes.append(.FileName(fileName: item.fileName)) + if let audioMetadata = item.audioMetadata { + attributes.append(.Audio(isVoice: false, duration: audioMetadata.duration, title: audioMetadata.title, performer: audioMetadata.performer, waveform: nil)) + } + + let file = TelegramMediaFile(fileId: EngineMedia.Id(namespace: Namespaces.Media.LocalFile, id: fileId), partialReference: nil, resource: ICloudFileResource(urlData: item.urlData, thumbnail: false), previewRepresentations: previewRepresentations, videoThumbnails: [], immediateThumbnailData: nil, mimeType: mimeType, size: Int64(item.fileSize), attributes: attributes) + let message: EnqueueMessage = .message(text: "", attributes: [], inlineStickers: [:], mediaReference: .standalone(media: file), replyToMessageId: replyMessageId, localGroupingKey: groupingKey, correlationId: nil, bubbleUpEmojiOrStickersets: []) + messages.append(message) + } + if let _ = groupingKey, messages.count % 10 == 0 { + groupingKey = Int64.random(in: Int64.min ... Int64.max) + } + } + + if !messages.isEmpty { + strongSelf.sendMessages(view: view, peer: peer, messages: messages) + } + } + })) + } + }), in: .window(.root)) + }) + } + + private func enqueueChatContextResult(view: StoryItemSetContainerComponent.View, peer: EnginePeer, replyMessageId: EngineMessage.Id?, results: ChatContextResultCollection, result: ChatContextResult, hideVia: Bool = false, closeMediaInput: Bool = false, silentPosting: Bool = false, resetTextInputState: Bool = true) { + if !canSendMessagesToPeer(peer._asPeer()) { + return + } + + let sendMessage: (Int32?) -> Void = { [weak self, weak view] scheduleTime in + guard let self, let view, let component = view.component else { + return + } + if component.context.engine.messages.enqueueOutgoingMessageWithChatContextResult(to: peer.id, threadId: nil, botId: results.botId, result: result, replyToMessageId: replyMessageId, hideVia: hideVia, silentPosting: silentPosting, scheduleTime: scheduleTime) { + } + + if let attachmentController = self.attachmentController { + attachmentController.dismiss(animated: true) + } + } + + sendMessage(nil) + } + + private func presentWebSearch(view: StoryItemSetContainerComponent.View, editingMessage: Bool, attachment: Bool, activateOnDisplay: Bool = true, present: @escaping (ViewController, Any?) -> Void) { + /*guard let peer = self.presentationInterfaceState.renderedPeer?.peer else { + return + } + + let _ = (self.context.engine.data.get(TelegramEngine.EngineData.Item.Configuration.SearchBots()) + |> deliverOnMainQueue).start(next: { [weak self] configuration in + if let strongSelf = self { + let controller = WebSearchController(context: strongSelf.context, updatedPresentationData: strongSelf.updatedPresentationData, peer: EnginePeer(peer), chatLocation: strongSelf.chatLocation, configuration: configuration, mode: .media(attachment: attachment, completion: { [weak self] results, selectionState, editingState, silentPosting in + self?.attachmentController?.dismiss(animated: true, completion: nil) + legacyEnqueueWebSearchMessages(selectionState, editingState, enqueueChatContextResult: { [weak self] result in + if let strongSelf = self { + strongSelf.enqueueChatContextResult(results, result, hideVia: true) + } + }, enqueueMediaMessages: { [weak self] signals in + if let strongSelf = self, !signals.isEmpty { + if editingMessage { + strongSelf.editMessageMediaWithLegacySignals(signals) + } else { + strongSelf.enqueueMediaMessages(signals: signals, silentPosting: silentPosting) + } + } + }) + }), activateOnDisplay: activateOnDisplay) + controller.attemptItemSelection = { [weak strongSelf] item in + guard let strongSelf, let peer = strongSelf.presentationInterfaceState.renderedPeer?.peer else { + return false + } + + enum ItemType { + case gif + case image + case video + } + + var itemType: ItemType? + switch item { + case let .internalReference(reference): + if reference.type == "gif" { + itemType = .gif + } else if reference.type == "photo" { + itemType = .image + } else if reference.type == "video" { + itemType = .video + } + case let .externalReference(reference): + if reference.type == "gif" { + itemType = .gif + } else if reference.type == "photo" { + itemType = .image + } else if reference.type == "video" { + itemType = .video + } + } + + var bannedSendPhotos: (Int32, Bool)? + var bannedSendVideos: (Int32, Bool)? + var bannedSendGifs: (Int32, Bool)? + + if let channel = peer as? TelegramChannel { + if let value = channel.hasBannedPermission(.banSendPhotos) { + bannedSendPhotos = value + } + if let value = channel.hasBannedPermission(.banSendVideos) { + bannedSendVideos = value + } + if let value = channel.hasBannedPermission(.banSendGifs) { + bannedSendGifs = value + } + } else if let group = peer as? TelegramGroup { + if group.hasBannedPermission(.banSendPhotos) { + bannedSendPhotos = (Int32.max, false) + } + if group.hasBannedPermission(.banSendVideos) { + bannedSendVideos = (Int32.max, false) + } + if group.hasBannedPermission(.banSendGifs) { + bannedSendGifs = (Int32.max, false) + } + } + + if let itemType { + switch itemType { + case .image: + if bannedSendPhotos != nil { + strongSelf.present(standardTextAlertController(theme: AlertControllerTheme(presentationData: strongSelf.presentationData), title: nil, text: strongSelf.restrictedSendingContentsText(), actions: [TextAlertAction(type: .defaultAction, title: strongSelf.presentationData.strings.Common_OK, action: {})]), in: .window(.root)) + + return false + } + case .video: + if bannedSendVideos != nil { + strongSelf.present(standardTextAlertController(theme: AlertControllerTheme(presentationData: strongSelf.presentationData), title: nil, text: strongSelf.restrictedSendingContentsText(), actions: [TextAlertAction(type: .defaultAction, title: strongSelf.presentationData.strings.Common_OK, action: {})]), in: .window(.root)) + + return false + } + case .gif: + if bannedSendGifs != nil { + strongSelf.present(standardTextAlertController(theme: AlertControllerTheme(presentationData: strongSelf.presentationData), title: nil, text: strongSelf.restrictedSendingContentsText(), actions: [TextAlertAction(type: .defaultAction, title: strongSelf.presentationData.strings.Common_OK, action: {})]), in: .window(.root)) + + return false + } + } + } + + return true + } + controller.getCaptionPanelView = { [weak strongSelf] in + return strongSelf?.getCaptionPanelView() + } + present(controller, ViewControllerPresentationArguments(presentationAnimation: .modalSheet)) + } + })*/ + } + + private func getCaptionPanelView(view: StoryItemSetContainerComponent.View, peer: EnginePeer) -> TGCaptionPanelView? { + guard let component = view.component else { + return nil + } + //TODO:self.presentationInterfaceState.customEmojiAvailable + return component.context.sharedContext.makeGalleryCaptionPanelView(context: component.context, chatLocation: .peer(id: peer.id), customEmojiAvailable: true, present: { [weak view] c in + guard let view else { + return + } + view.component?.controller()?.present(c, in: .window(.root)) + }, presentInGlobalOverlay: { [weak view] c in + guard let view else { + return + } + view.component?.controller()?.presentInGlobalOverlay(c) + }) as? TGCaptionPanelView + } + + private func openCamera(view: StoryItemSetContainerComponent.View, peer: EnginePeer, replyToMessageId: EngineMessage.Id?, cameraView: TGAttachmentCameraView? = nil) { + guard let component = view.component else { + return + } + guard let inputPanelView = view.inputPanel.view as? MessageInputPanelComponent.View else { + return + } + + var inputText = NSAttributedString(string: "") + switch inputPanelView.getSendMessageInput() { + case let .text(text): + inputText = NSAttributedString(string: text) + } + + let _ = (component.context.sharedContext.accountManager.transaction { transaction -> GeneratedMediaStoreSettings in + let entry = transaction.getSharedData(ApplicationSpecificSharedDataKeys.generatedMediaStoreSettings)?.get(GeneratedMediaStoreSettings.self) + return entry ?? GeneratedMediaStoreSettings.defaultSettings + } + |> deliverOnMainQueue).start(next: { [weak self, weak view] settings in + guard let self, let view, let component = view.component, let parentController = component.controller() else { + return + } + + var enablePhoto = true + var enableVideo = true + + if let callManager = component.context.sharedContext.callManager, callManager.hasActiveCall { + enableVideo = false + } + + var bannedSendPhotos: (Int32, Bool)? + var bannedSendVideos: (Int32, Bool)? + + if case let .channel(channel) = peer { + if let value = channel.hasBannedPermission(.banSendPhotos) { + bannedSendPhotos = value + } + if let value = channel.hasBannedPermission(.banSendVideos) { + bannedSendVideos = value + } + } else if case let .legacyGroup(group) = peer { + if group.hasBannedPermission(.banSendPhotos) { + bannedSendPhotos = (Int32.max, false) + } + if group.hasBannedPermission(.banSendVideos) { + bannedSendVideos = (Int32.max, false) + } + } + + if bannedSendPhotos != nil { + enablePhoto = false + } + if bannedSendVideos != nil { + enableVideo = false + } + + let storeCapturedMedia = peer.id.namespace != Namespaces.Peer.SecretChat + + presentedLegacyCamera(context: component.context, peer: peer._asPeer(), chatLocation: .peer(id: peer.id), cameraView: cameraView, menuController: nil, parentController: parentController, attachmentController: self.attachmentController, editingMedia: false, saveCapturedPhotos: storeCapturedMedia, mediaGrouping: true, initialCaption: inputText, hasSchedule: peer.id.namespace != Namespaces.Peer.SecretChat, enablePhoto: enablePhoto, enableVideo: enableVideo, sendMessagesWithSignals: { [weak self, weak view] signals, silentPosting, scheduleTime in + guard let self, let view else { + return + } + self.enqueueMediaMessages(view: view, peer: peer, replyToMessageId: replyToMessageId, signals: signals, silentPosting: silentPosting, scheduleTime: scheduleTime > 0 ? scheduleTime : nil) + if !inputText.string.isEmpty { + self.clearInputText(view: view) + } + }, recognizedQRCode: { _ in + }, presentSchedulePicker: { [weak self, weak view] _, done in + guard let self, let view else { + return + } + self.presentScheduleTimePicker(view: view, peer: peer, style: .media, completion: { time in + done(time) + }) + }, presentTimerPicker: { [weak self, weak view] done in + guard let self, let view else { + return + } + self.presentTimerPicker(view: view, peer: peer, style: .media, completion: { time in + done(time) + }) + }, getCaptionPanelView: { [weak self, weak view] in + guard let self, let view else { + return nil + } + return self.getCaptionPanelView(view: view, peer: peer) + }, dismissedWithResult: { [weak self] in + guard let self else { + return + } + self.attachmentController?.dismiss(animated: false, completion: nil) + }, finishedTransitionIn: { [weak self] in + guard let self else { + return + } + self.attachmentController?.scrollToTop?() + }) + }) + } + + private func presentScheduleTimePicker( + view: StoryItemSetContainerComponent.View, + peer: EnginePeer, + style: ChatScheduleTimeControllerStyle = .default, + selectedTime: Int32? = nil, + dismissByTapOutside: Bool = true, + completion: @escaping (Int32) -> Void + ) { + guard let component = view.component else { + return + } + let _ = (component.context.engine.data.get( + TelegramEngine.EngineData.Item.Peer.Presence(id: peer.id) + ) + |> deliverOnMainQueue).start(next: { [weak view] presence in + guard let view, let component = view.component else { + return + } + + var sendWhenOnlineAvailable = false + if let presence, case .present = presence.status { + sendWhenOnlineAvailable = true + } + if peer.id.namespace == Namespaces.Peer.CloudUser && peer.id.id._internalGetInt64Value() == 777000 { + sendWhenOnlineAvailable = false + } + + let mode: ChatScheduleTimeControllerMode + if peer.id == component.context.account.peerId { + mode = .reminders + } else { + mode = .scheduledMessages(sendWhenOnlineAvailable: sendWhenOnlineAvailable) + } + let theme = component.theme + let controller = ChatScheduleTimeController(context: component.context, updatedPresentationData: (component.context.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: theme), component.context.sharedContext.presentationData |> map { $0.withUpdated(theme: theme) }), peerId: peer.id, mode: mode, style: style, currentTime: selectedTime, minimalTime: nil, dismissByTapOutside: dismissByTapOutside, completion: { time in + completion(time) + }) + view.endEditing(true) + view.component?.controller()?.present(controller, in: .window(.root)) + }) + } + + private func presentTimerPicker(view: StoryItemSetContainerComponent.View, peer: EnginePeer, style: ChatTimerScreenStyle = .default, selectedTime: Int32? = nil, dismissByTapOutside: Bool = true, completion: @escaping (Int32) -> Void) { + guard let component = view.component else { + return + } + let theme = component.theme + let controller = ChatTimerScreen(context: component.context, updatedPresentationData: (component.context.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: theme), component.context.sharedContext.presentationData |> map { $0.withUpdated(theme: theme) }), style: style, currentTime: selectedTime, dismissByTapOutside: dismissByTapOutside, completion: { time in + completion(time) + }) + view.endEditing(true) + component.controller()?.present(controller, in: .window(.root)) + } + + private func configurePollCreation(view: StoryItemSetContainerComponent.View, peer: EnginePeer, targetMessageId: EngineMessage.Id?, isQuiz: Bool? = nil) -> CreatePollControllerImpl? { + guard let component = view.component else { + return nil + } + let theme = component.theme + return createPollController(context: component.context, updatedPresentationData: (component.context.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: theme), component.context.sharedContext.presentationData |> map { $0.withUpdated(theme: theme) }), peer: peer, isQuiz: isQuiz, completion: { [weak self, weak view] poll in + guard let self, let view else { + return + } + let replyMessageId = targetMessageId + /*strongSelf.chatDisplayNode.setupSendActionOnViewUpdate({ + if let strongSelf = self { + strongSelf.chatDisplayNode.collapseInput() + + strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: false, { + $0.updatedInterfaceState { $0.withUpdatedReplyMessageId(nil) } + }) + } + }, nil)*/ + let message: EnqueueMessage = .message( + text: "", + attributes: [], + inlineStickers: [:], + mediaReference: .standalone(media: TelegramMediaPoll( + pollId: EngineMedia.Id(namespace: Namespaces.Media.LocalPoll, id: Int64.random(in: Int64.min ... Int64.max)), + publicity: poll.publicity, + kind: poll.kind, + text: poll.text, + options: poll.options, + correctAnswers: poll.correctAnswers, + results: poll.results, + isClosed: false, + deadlineTimeout: poll.deadlineTimeout + )), + replyToMessageId: nil, + localGroupingKey: nil, + correlationId: nil, + bubbleUpEmojiOrStickersets: [] + ) + self.sendMessages(view: view, peer: peer, messages: [message.withUpdatedReplyToMessageId(replyMessageId)]) + }) + } + + private func transformEnqueueMessages(view: StoryItemSetContainerComponent.View, messages: [EnqueueMessage], silentPosting: Bool, scheduleTime: Int32? = nil) -> [EnqueueMessage] { + guard let focusedItemId = view.focusedItemId, let _ = view.currentSlice?.items.first(where: { $0.id == focusedItemId }) else { + return [] + } + + let defaultReplyMessageId: EngineMessage.Id? = nil + + return messages.map { message in + var message = message + + if let defaultReplyMessageId = defaultReplyMessageId { + switch message { + case let .message(text, attributes, inlineStickers, mediaReference, replyToMessageId, localGroupingKey, correlationId, bubbleUpEmojiOrStickersets): + if replyToMessageId == nil { + message = .message(text: text, attributes: attributes, inlineStickers: inlineStickers, mediaReference: mediaReference, replyToMessageId: defaultReplyMessageId, localGroupingKey: localGroupingKey, correlationId: correlationId, bubbleUpEmojiOrStickersets: bubbleUpEmojiOrStickersets) + } + case .forward: + break + } + } + + return message.withUpdatedAttributes { attributes in + var attributes = attributes + if silentPosting || scheduleTime != nil { + for i in (0 ..< attributes.count).reversed() { + if attributes[i] is NotificationInfoMessageAttribute { + attributes.remove(at: i) + } else if let _ = scheduleTime, attributes[i] is OutgoingScheduleInfoMessageAttribute { + attributes.remove(at: i) + } + } + if silentPosting { + attributes.append(NotificationInfoMessageAttribute(flags: .muted)) + } + if let scheduleTime = scheduleTime { + attributes.append(OutgoingScheduleInfoMessageAttribute(scheduleTime: scheduleTime)) + } + } + return attributes + } + } + } + + private func sendMessages(view: StoryItemSetContainerComponent.View, peer: EnginePeer, messages: [EnqueueMessage], media: Bool = false, commit: Bool = false) { + guard let component = view.component else { + return + } + let _ = (enqueueMessages(account: component.context.account, peerId: peer.id, messages: self.transformEnqueueMessages(view: view, messages: messages, silentPosting: false)) + |> deliverOnMainQueue).start() + + donateSendMessageIntent(account: component.context.account, sharedContext: component.context.sharedContext, intentContext: .chat, peerIds: [peer.id]) + + if let attachmentController = self.attachmentController { + attachmentController.dismiss(animated: true) + } + + if let controller = component.controller() { + let presentationData = component.context.sharedContext.currentPresentationData.with { $0 } + controller.present(UndoOverlayController( + presentationData: presentationData, + content: .succeed(text: "Message Sent"), + elevatedLayout: false, + animateInAsReplacement: false, + action: { _ in return false } + ), in: .current) + } + } + + private func enqueueMediaMessages(view: StoryItemSetContainerComponent.View, peer: EnginePeer, replyToMessageId: EngineMessage.Id?, signals: [Any]?, silentPosting: Bool, scheduleTime: Int32? = nil, getAnimatedTransitionSource: ((String) -> UIView?)? = nil, completion: @escaping () -> Void = {}) { + guard let component = view.component else { + return + } + + self.enqueueMediaMessageDisposable.set((legacyAssetPickerEnqueueMessages(context: component.context, account: component.context.account, signals: signals!) + |> deliverOnMainQueue).start(next: { [weak self, weak view] items in + if let strongSelf = self, let view { + var mappedMessages: [EnqueueMessage] = [] + var addedTransitions: [(Int64, [String], () -> Void)] = [] + + var groupedCorrelationIds: [Int64: Int64] = [:] + + var skipAddingTransitions = false + + for item in items { + var message = item.message + if message.groupingKey != nil { + if items.count > 10 { + skipAddingTransitions = true + } + } else if items.count > 3 { + skipAddingTransitions = true + } + + if let uniqueId = item.uniqueId, !item.isFile && !skipAddingTransitions { + let correlationId: Int64 + var addTransition = scheduleTime == nil + if let groupingKey = message.groupingKey { + if let existing = groupedCorrelationIds[groupingKey] { + correlationId = existing + addTransition = false + } else { + correlationId = Int64.random(in: 0 ..< Int64.max) + groupedCorrelationIds[groupingKey] = correlationId + } + } else { + correlationId = Int64.random(in: 0 ..< Int64.max) + } + message = message.withUpdatedCorrelationId(correlationId) + + if addTransition { + addedTransitions.append((correlationId, [uniqueId], addedTransitions.isEmpty ? completion : {})) + } else { + if let index = addedTransitions.firstIndex(where: { $0.0 == correlationId }) { + var (correlationId, uniqueIds, completion) = addedTransitions[index] + uniqueIds.append(uniqueId) + addedTransitions[index] = (correlationId, uniqueIds, completion) + } + } + } + mappedMessages.append(message) + } + + let messages = strongSelf.transformEnqueueMessages(view: view, messages: mappedMessages, silentPosting: silentPosting, scheduleTime: scheduleTime) + + strongSelf.sendMessages(view: view, peer: peer, messages: messages.map { $0.withUpdatedReplyToMessageId(replyToMessageId) }, media: true) + + if let _ = scheduleTime { + completion() + } + } + })) + } + + private func editMessageMediaWithLegacySignals(view: StoryItemSetContainerComponent.View, signals: [Any]) { + guard let component = view.component else { + return + } + let _ = (legacyAssetPickerEnqueueMessages(context: component.context, account: component.context.account, signals: signals) + |> deliverOnMainQueue).start() + } +} diff --git a/submodules/TelegramUI/Components/Stories/StoryContentComponent/Sources/StoryAvatarInfoComponent.swift b/submodules/TelegramUI/Components/Stories/StoryContentComponent/Sources/StoryAvatarInfoComponent.swift index de6cea18da..314fcb4be3 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContentComponent/Sources/StoryAvatarInfoComponent.swift +++ b/submodules/TelegramUI/Components/Stories/StoryContentComponent/Sources/StoryAvatarInfoComponent.swift @@ -33,7 +33,7 @@ final class StoryAvatarInfoComponent: Component { private weak var state: EmptyComponentState? override init(frame: CGRect) { - self.avatarNode = AvatarNode(font: avatarPlaceholderFont(size: 15.0)) + self.avatarNode = AvatarNode(font: avatarPlaceholderFont(size: 18.0)) super.init(frame: frame) diff --git a/submodules/TelegramUI/Components/Stories/StoryContentComponent/Sources/StoryChatContent.swift b/submodules/TelegramUI/Components/Stories/StoryContentComponent/Sources/StoryChatContent.swift index a499ec6a6b..7f32ede4be 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContentComponent/Sources/StoryChatContent.swift +++ b/submodules/TelegramUI/Components/Stories/StoryContentComponent/Sources/StoryChatContent.swift @@ -37,7 +37,7 @@ public enum StoryChatContent { peer: author )) }, - targetMessageId: nil, + peerId: itemSet.peerId, storyItem: item, preload: nil, delete: { [weak storyList] in diff --git a/submodules/TelegramUI/Components/Stories/StoryContentComponent/Sources/StoryItemContentComponent.swift b/submodules/TelegramUI/Components/Stories/StoryContentComponent/Sources/StoryItemContentComponent.swift index 40f619a2f4..f8552d2cdd 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContentComponent/Sources/StoryItemContentComponent.swift +++ b/submodules/TelegramUI/Components/Stories/StoryContentComponent/Sources/StoryItemContentComponent.swift @@ -213,7 +213,12 @@ final class StoryItemContentComponent: Component { if self.videoNode != nil { self.updateVideoPlaybackProgress() } else { + #if DEBUG && false + let currentProgressTimerLimit: Double = 5 * 60.0 + #else let currentProgressTimerLimit: Double = 5.0 + #endif + var currentProgressTimerValue = self.currentProgressTimerValue + 1.0 / 60.0 currentProgressTimerValue = max(0.0, min(currentProgressTimerLimit, currentProgressTimerValue)) self.currentProgressTimerValue = currentProgressTimerValue diff --git a/submodules/TelegramUI/Components/TextFieldComponent/Sources/TextFieldComponent.swift b/submodules/TelegramUI/Components/TextFieldComponent/Sources/TextFieldComponent.swift index 317bd8ec69..230bbdf369 100644 --- a/submodules/TelegramUI/Components/TextFieldComponent/Sources/TextFieldComponent.swift +++ b/submodules/TelegramUI/Components/TextFieldComponent/Sources/TextFieldComponent.swift @@ -73,7 +73,7 @@ public final class TextFieldComponent: Component { self.textView = UITextView(frame: CGRect(), textContainer: self.textContainer) self.textView.translatesAutoresizingMaskIntoConstraints = false - self.textView.textContainerInset = UIEdgeInsets(top: 6.0, left: 8.0, bottom: 7.0, right: 8.0) + self.textView.textContainerInset = UIEdgeInsets(top: 9.0, left: 8.0, bottom: 10.0, right: 8.0) self.textView.backgroundColor = nil self.textView.layer.isOpaque = false self.textView.keyboardAppearance = .dark diff --git a/submodules/TelegramUI/Sources/ChatController.swift b/submodules/TelegramUI/Sources/ChatController.swift index e67333293d..1c23744af1 100644 --- a/submodules/TelegramUI/Sources/ChatController.swift +++ b/submodules/TelegramUI/Sources/ChatController.swift @@ -4091,7 +4091,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G |> timeout(2.0, queue: Queue.mainQueue(), alternate: .single(false))).start(next: { [weak self] responded in if let strongSelf = self { if !responded { - strongSelf.present(UndoOverlayController(presentationData: strongSelf.presentationData, content: .sticker(context: strongSelf.context, file: file, title: nil, text: strongSelf.presentationData.strings.Conversation_InteractiveEmojiSyncTip(EnginePeer(peer).compactDisplayTitle).string, undoText: nil, customAction: nil), elevatedLayout: false, action: { _ in return false }), in: .current) + strongSelf.present(UndoOverlayController(presentationData: strongSelf.presentationData, content: .sticker(context: strongSelf.context, file: file, loop: true, title: nil, text: strongSelf.presentationData.strings.Conversation_InteractiveEmojiSyncTip(EnginePeer(peer).compactDisplayTitle).string, undoText: nil, customAction: nil), elevatedLayout: false, action: { _ in return false }), in: .current) let _ = ApplicationSpecificNotice.incrementInteractiveEmojiSyncTip(accountManager: strongSelf.context.sharedContext.accountManager, timestamp: currentTimestamp).start() } @@ -8601,7 +8601,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G if let firstLockedPremiumEmoji = firstLockedPremiumEmoji { let presentationData = strongSelf.context.sharedContext.currentPresentationData.with { $0 } - strongSelf.controllerInteraction?.displayUndo(.sticker(context: strongSelf.context, file: firstLockedPremiumEmoji, title: nil, text: presentationData.strings.EmojiInput_PremiumEmojiToast_Text, undoText: presentationData.strings.EmojiInput_PremiumEmojiToast_Action, customAction: { + strongSelf.controllerInteraction?.displayUndo(.sticker(context: strongSelf.context, file: firstLockedPremiumEmoji, loop: true, title: nil, text: presentationData.strings.EmojiInput_PremiumEmojiToast_Text, undoText: presentationData.strings.EmojiInput_PremiumEmojiToast_Action, customAction: { guard let strongSelf = self else { return } @@ -9748,7 +9748,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G if let strongSelf = self { switch result { case .generic: - strongSelf.presentInGlobalOverlay(UndoOverlayController(presentationData: strongSelf.presentationData, content: .sticker(context: strongSelf.context, file: stickerFile, title: nil, text: added ? strongSelf.presentationData.strings.Conversation_StickerAddedToFavorites : strongSelf.presentationData.strings.Conversation_StickerRemovedFromFavorites, undoText: nil, customAction: nil), elevatedLayout: true, action: { _ in return false }), with: nil) + strongSelf.presentInGlobalOverlay(UndoOverlayController(presentationData: strongSelf.presentationData, content: .sticker(context: strongSelf.context, file: stickerFile, loop: true, title: nil, text: added ? strongSelf.presentationData.strings.Conversation_StickerAddedToFavorites : strongSelf.presentationData.strings.Conversation_StickerRemovedFromFavorites, undoText: nil, customAction: nil), elevatedLayout: true, action: { _ in return false }), with: nil) case let .limitExceeded(limit, premiumLimit): let premiumConfiguration = PremiumConfiguration.with(appConfiguration: context.currentAppConfiguration.with { $0 }) let text: String @@ -9757,7 +9757,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } else { text = strongSelf.presentationData.strings.Premium_MaxFavedStickersText("\(premiumLimit)").string } - strongSelf.presentInGlobalOverlay(UndoOverlayController(presentationData: strongSelf.presentationData, content: .sticker(context: strongSelf.context, file: stickerFile, title: strongSelf.presentationData.strings.Premium_MaxFavedStickersTitle("\(limit)").string, text: text, undoText: nil, customAction: nil), elevatedLayout: true, action: { [weak self] action in + strongSelf.presentInGlobalOverlay(UndoOverlayController(presentationData: strongSelf.presentationData, content: .sticker(context: strongSelf.context, file: stickerFile, loop: true, title: strongSelf.presentationData.strings.Premium_MaxFavedStickersTitle("\(limit)").string, text: text, undoText: nil, customAction: nil), elevatedLayout: true, action: { [weak self] action in if let strongSelf = self { if case .info = action { let controller = PremiumIntroScreen(context: strongSelf.context, source: .savedStickers) @@ -14762,7 +14762,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G let _ = (self.context.engine.stickers.loadedStickerPack(reference: stickerPackReference, forceActualized: false) |> deliverOnMainQueue).start(next: { [weak self] stickerPack in if let strongSelf = self, case let .result(info, _, _) = stickerPack { - strongSelf.present(UndoOverlayController(presentationData: strongSelf.presentationData, content: .sticker(context: strongSelf.context, file: file, title: info.title, text: strongSelf.presentationData.strings.Stickers_PremiumPackInfoText, undoText: strongSelf.presentationData.strings.Stickers_PremiumPackView, customAction: nil), elevatedLayout: false, action: { [weak self] action in + strongSelf.present(UndoOverlayController(presentationData: strongSelf.presentationData, content: .sticker(context: strongSelf.context, file: file, loop: true, title: info.title, text: strongSelf.presentationData.strings.Stickers_PremiumPackInfoText, undoText: strongSelf.presentationData.strings.Stickers_PremiumPackView, customAction: nil), elevatedLayout: false, action: { [weak self] action in if let strongSelf = self, action == .undo { let _ = strongSelf.controllerInteraction?.openMessage(message, .default) } @@ -14814,7 +14814,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G /*let _ = (self.context.engine.stickers.loadedStickerPack(reference: stickerPackReference, forceActualized: false) |> deliverOnMainQueue).start(next: { [weak self] stickerPack in if let strongSelf = self, case let .result(info, _, _) = stickerPack { - strongSelf.present(UndoOverlayController(presentationData: strongSelf.presentationData, content: .sticker(context: strongSelf.context, file: file, title: nil, text: strongSelf.presentationData.strings.Stickers_EmojiPackInfoText(info.title).string, undoText: strongSelf.presentationData.strings.Stickers_PremiumPackView, customAction: nil), elevatedLayout: false, action: { [weak self] action in + strongSelf.present(UndoOverlayController(presentationData: strongSelf.presentationData, content: .sticker(context: strongSelf.context, file: file, loop: true, title: nil, text: strongSelf.presentationData.strings.Stickers_EmojiPackInfoText(info.title).string, undoText: strongSelf.presentationData.strings.Stickers_PremiumPackView, customAction: nil), elevatedLayout: false, action: { [weak self] action in if let strongSelf = self, action == .undo { strongSelf.presentEmojiList(references: [stickerPackReference]) } diff --git a/submodules/TelegramUI/Sources/ChatControllerNode.swift b/submodules/TelegramUI/Sources/ChatControllerNode.swift index d6bf086725..3416078f87 100644 --- a/submodules/TelegramUI/Sources/ChatControllerNode.swift +++ b/submodules/TelegramUI/Sources/ChatControllerNode.swift @@ -3150,7 +3150,7 @@ class ChatControllerNode: ASDisplayNode, UIScrollViewDelegate { if let firstLockedPremiumEmoji = firstLockedPremiumEmoji { let presentationData = self.context.sharedContext.currentPresentationData.with { $0 } - self.controllerInteraction.displayUndo(.sticker(context: context, file: firstLockedPremiumEmoji, title: nil, text: presentationData.strings.EmojiInput_PremiumEmojiToast_Text, undoText: presentationData.strings.EmojiInput_PremiumEmojiToast_Action, customAction: { [weak self] in + self.controllerInteraction.displayUndo(.sticker(context: context, file: firstLockedPremiumEmoji, loop: true, title: nil, text: presentationData.strings.EmojiInput_PremiumEmojiToast_Text, undoText: presentationData.strings.EmojiInput_PremiumEmojiToast_Action, customAction: { [weak self] in guard let strongSelf = self else { return } diff --git a/submodules/TelegramUI/Sources/ChatTextInputPanelNode.swift b/submodules/TelegramUI/Sources/ChatTextInputPanelNode.swift index ef76f3db6f..d76861b4e5 100644 --- a/submodules/TelegramUI/Sources/ChatTextInputPanelNode.swift +++ b/submodules/TelegramUI/Sources/ChatTextInputPanelNode.swift @@ -3136,7 +3136,7 @@ class ChatTextInputPanelNode: ChatInputPanelNode, ASEditableTextNodeDelegate { let presentationData = context.sharedContext.currentPresentationData.with { $0 } - let undoController = UndoOverlayController(presentationData: presentationData, content: .sticker(context: context, file: file, title: nil, text: presentationData.strings.EmojiStatus_AppliedText, undoText: nil, customAction: nil), elevatedLayout: false, animateInAsReplacement: animateInAsReplacement, action: { _ in return false }) + let undoController = UndoOverlayController(presentationData: presentationData, content: .sticker(context: context, file: file, loop: true, title: nil, text: presentationData.strings.EmojiStatus_AppliedText, undoText: nil, customAction: nil), elevatedLayout: false, animateInAsReplacement: animateInAsReplacement, action: { _ in return false }) //strongSelf.currentUndoOverlayController = controller controller.controllerInteraction?.presentController(undoController, nil) } diff --git a/submodules/TelegramUI/Sources/ContactMultiselectionController.swift b/submodules/TelegramUI/Sources/ContactMultiselectionController.swift index 471343418a..f729f1e4bb 100644 --- a/submodules/TelegramUI/Sources/ContactMultiselectionController.swift +++ b/submodules/TelegramUI/Sources/ContactMultiselectionController.swift @@ -440,7 +440,7 @@ class ContactMultiselectionControllerImpl: ViewController, ContactMultiselection return } var addedToken: EditableTokenListToken? - var removedTokenId: AnyHashable? + var removedTokenIds: [AnyHashable] = [] switch strongSelf.contactsNode.contentNode { case .contacts: break @@ -458,12 +458,23 @@ class ContactMultiselectionControllerImpl: ViewController, ContactMultiselection } chatsNode.updateState { state in var state = state - if state.selectedAdditionalCategoryIds.contains(id) { - state.selectedAdditionalCategoryIds.remove(id) - removedTokenId = id + if "".isEmpty { + if !state.selectedAdditionalCategoryIds.contains(id) { + for id in state.selectedAdditionalCategoryIds { + removedTokenIds.append(id) + state.selectedAdditionalCategoryIds.remove(id) + } + state.selectedAdditionalCategoryIds.insert(id) + addedToken = categoryToken + } } else { - state.selectedAdditionalCategoryIds.insert(id) - addedToken = categoryToken + if state.selectedAdditionalCategoryIds.contains(id) { + state.selectedAdditionalCategoryIds.remove(id) + removedTokenIds.append(id) + } else { + state.selectedAdditionalCategoryIds.insert(id) + addedToken = categoryToken + } } return state @@ -486,9 +497,13 @@ class ContactMultiselectionControllerImpl: ViewController, ContactMultiselection if !added { strongSelf.contactsNode.editableTokens.append(addedToken) } - } else if let removedTokenId = removedTokenId { + strongSelf.contactsNode.editableTokens = strongSelf.contactsNode.editableTokens.filter { token in - return token.id != removedTokenId + return !removedTokenIds.contains(token.id) + } + } else if !removedTokenIds.isEmpty { + strongSelf.contactsNode.editableTokens = strongSelf.contactsNode.editableTokens.filter { token in + return !removedTokenIds.contains(token.id) } } strongSelf.requestLayout(transition: ContainedViewLayoutTransition.animated(duration: 0.4, curve: .spring)) diff --git a/submodules/TelegramUI/Sources/ContactMultiselectionControllerNode.swift b/submodules/TelegramUI/Sources/ContactMultiselectionControllerNode.swift index da2b4d17bc..69ffe6959b 100644 --- a/submodules/TelegramUI/Sources/ContactMultiselectionControllerNode.swift +++ b/submodules/TelegramUI/Sources/ContactMultiselectionControllerNode.swift @@ -105,7 +105,7 @@ final class ContactMultiselectionControllerNode: ASDisplayNode { let chatListFilters = chatSelection.chatListFilters placeholder = placeholderValue - let chatListNode = ChatListNode(context: context, location: .chatList(groupId: .root), previewing: false, fillPreloadItems: false, mode: .peers(filter: [.excludeSecretChats], isSelecting: true, additionalCategories: additionalCategories?.categories ?? [], chatListFilters: chatListFilters, displayAutoremoveTimeout: chatSelection.displayAutoremoveTimeout), isPeerEnabled: isPeerEnabled, theme: self.presentationData.theme, fontSize: self.presentationData.listsFontSize, strings: self.presentationData.strings, dateTimeFormat: self.presentationData.dateTimeFormat, nameSortOrder: self.presentationData.nameSortOrder, nameDisplayOrder: self.presentationData.nameDisplayOrder, animationCache: self.animationCache, animationRenderer: self.animationRenderer, disableAnimations: true, isInlineMode: false) + let chatListNode = ChatListNode(context: context, location: .chatList(groupId: .root), previewing: false, fillPreloadItems: false, mode: .peers(filter: [.excludeSecretChats], isSelecting: true, additionalCategories: additionalCategories?.categories ?? [], chatListFilters: chatListFilters, displayAutoremoveTimeout: chatSelection.displayAutoremoveTimeout, displayPresence: chatSelection.displayPresence), isPeerEnabled: isPeerEnabled, theme: self.presentationData.theme, fontSize: self.presentationData.listsFontSize, strings: self.presentationData.strings, dateTimeFormat: self.presentationData.dateTimeFormat, nameSortOrder: self.presentationData.nameSortOrder, nameDisplayOrder: self.presentationData.nameDisplayOrder, animationCache: self.animationCache, animationRenderer: self.animationRenderer, disableAnimations: true, isInlineMode: false) chatListNode.passthroughPeerSelection = true chatListNode.disabledPeerSelected = { peer, _ in attemptDisabledItemSelection?(peer) diff --git a/submodules/TelegramUI/Sources/EmojisChatInputContextPanelNode.swift b/submodules/TelegramUI/Sources/EmojisChatInputContextPanelNode.swift index 52d948db0f..1544943493 100644 --- a/submodules/TelegramUI/Sources/EmojisChatInputContextPanelNode.swift +++ b/submodules/TelegramUI/Sources/EmojisChatInputContextPanelNode.swift @@ -342,7 +342,7 @@ final class EmojisChatInputContextPanelNode: ChatInputContextPanelNode { let presentationData = self.context.sharedContext.currentPresentationData.with { $0 } - let undoController = UndoOverlayController(presentationData: presentationData, content: .sticker(context: self.context, file: file, title: nil, text: presentationData.strings.EmojiStatus_AppliedText, undoText: nil, customAction: nil), elevatedLayout: false, animateInAsReplacement: animateInAsReplacement, action: { _ in return false }) + let undoController = UndoOverlayController(presentationData: presentationData, content: .sticker(context: self.context, file: file, loop: true, title: nil, text: presentationData.strings.EmojiStatus_AppliedText, undoText: nil, customAction: nil), elevatedLayout: false, animateInAsReplacement: animateInAsReplacement, action: { _ in return false }) //strongSelf.currentUndoOverlayController = controller controller.controllerInteraction?.presentController(undoController, nil) } diff --git a/submodules/TelegramUI/Sources/HorizontalStickersChatContextPanelNode.swift b/submodules/TelegramUI/Sources/HorizontalStickersChatContextPanelNode.swift index 7b5e7c96d7..a9390cb990 100755 --- a/submodules/TelegramUI/Sources/HorizontalStickersChatContextPanelNode.swift +++ b/submodules/TelegramUI/Sources/HorizontalStickersChatContextPanelNode.swift @@ -193,7 +193,7 @@ final class HorizontalStickersChatContextPanelNode: ChatInputContextPanelNode { |> deliverOnMainQueue).start(next: { result in switch result { case .generic: - strongSelf.interfaceInteraction?.presentGlobalOverlayController(UndoOverlayController(presentationData: presentationData, content: .sticker(context: strongSelf.context, file: item.file, title: nil, text: !isStarred ? strongSelf.strings.Conversation_StickerAddedToFavorites : strongSelf.strings.Conversation_StickerRemovedFromFavorites, undoText: nil, customAction: nil), elevatedLayout: false, action: { _ in return false }), nil) + strongSelf.interfaceInteraction?.presentGlobalOverlayController(UndoOverlayController(presentationData: presentationData, content: .sticker(context: strongSelf.context, file: item.file, loop: true, title: nil, text: !isStarred ? strongSelf.strings.Conversation_StickerAddedToFavorites : strongSelf.strings.Conversation_StickerRemovedFromFavorites, undoText: nil, customAction: nil), elevatedLayout: false, action: { _ in return false }), nil) case let .limitExceeded(limit, premiumLimit): let premiumConfiguration = PremiumConfiguration.with(appConfiguration: strongSelf.context.currentAppConfiguration.with { $0 }) let text: String @@ -202,7 +202,7 @@ final class HorizontalStickersChatContextPanelNode: ChatInputContextPanelNode { } else { text = strongSelf.strings.Premium_MaxFavedStickersText("\(premiumLimit)").string } - strongSelf.interfaceInteraction?.presentGlobalOverlayController(UndoOverlayController(presentationData: presentationData, content: .sticker(context: strongSelf.context, file: item.file, title: strongSelf.strings.Premium_MaxFavedStickersTitle("\(limit)").string, text: text, undoText: nil, customAction: nil), elevatedLayout: false, action: { [weak self] action in + strongSelf.interfaceInteraction?.presentGlobalOverlayController(UndoOverlayController(presentationData: presentationData, content: .sticker(context: strongSelf.context, file: item.file, loop: true, title: strongSelf.strings.Premium_MaxFavedStickersTitle("\(limit)").string, text: text, undoText: nil, customAction: nil), elevatedLayout: false, action: { [weak self] action in if let strongSelf = self { if case .info = action { let controller = PremiumIntroScreen(context: strongSelf.context, source: .savedStickers) diff --git a/submodules/TelegramUI/Sources/InlineReactionSearchPanel.swift b/submodules/TelegramUI/Sources/InlineReactionSearchPanel.swift index 206ab2bfd2..ed12f33b39 100644 --- a/submodules/TelegramUI/Sources/InlineReactionSearchPanel.swift +++ b/submodules/TelegramUI/Sources/InlineReactionSearchPanel.swift @@ -145,7 +145,7 @@ private final class InlineReactionSearchStickersNode: ASDisplayNode, UIScrollVie |> deliverOnMainQueue).start(next: { result in switch result { case .generic: - strongSelf.getControllerInteraction?()?.presentGlobalOverlayController(UndoOverlayController(presentationData: presentationData, content: .sticker(context: strongSelf.context, file: item.file, title: nil, text: !isStarred ? strongSelf.strings.Conversation_StickerAddedToFavorites : strongSelf.strings.Conversation_StickerRemovedFromFavorites, undoText: nil, customAction: nil), elevatedLayout: false, action: { _ in return false }), nil) + strongSelf.getControllerInteraction?()?.presentGlobalOverlayController(UndoOverlayController(presentationData: presentationData, content: .sticker(context: strongSelf.context, file: item.file, loop: true, title: nil, text: !isStarred ? strongSelf.strings.Conversation_StickerAddedToFavorites : strongSelf.strings.Conversation_StickerRemovedFromFavorites, undoText: nil, customAction: nil), elevatedLayout: false, action: { _ in return false }), nil) case let .limitExceeded(limit, premiumLimit): let premiumConfiguration = PremiumConfiguration.with(appConfiguration: strongSelf.context.currentAppConfiguration.with { $0 }) let text: String @@ -154,7 +154,7 @@ private final class InlineReactionSearchStickersNode: ASDisplayNode, UIScrollVie } else { text = strongSelf.strings.Premium_MaxFavedStickersText("\(premiumLimit)").string } - strongSelf.getControllerInteraction?()?.presentGlobalOverlayController(UndoOverlayController(presentationData: presentationData, content: .sticker(context: strongSelf.context, file: item.file, title: strongSelf.strings.Premium_MaxFavedStickersTitle("\(limit)").string, text: text, undoText: nil, customAction: nil), elevatedLayout: false, action: { [weak self] action in + strongSelf.getControllerInteraction?()?.presentGlobalOverlayController(UndoOverlayController(presentationData: presentationData, content: .sticker(context: strongSelf.context, file: item.file, loop: true, title: strongSelf.strings.Premium_MaxFavedStickersTitle("\(limit)").string, text: text, undoText: nil, customAction: nil), elevatedLayout: false, action: { [weak self] action in if let strongSelf = self { if case .info = action { let controller = PremiumIntroScreen(context: strongSelf.context, source: .savedStickers) diff --git a/submodules/TelegramUI/Sources/PeerInfo/PeerInfoScreen.swift b/submodules/TelegramUI/Sources/PeerInfo/PeerInfoScreen.swift index 450d31e755..695b6efa21 100644 --- a/submodules/TelegramUI/Sources/PeerInfo/PeerInfoScreen.swift +++ b/submodules/TelegramUI/Sources/PeerInfo/PeerInfoScreen.swift @@ -3762,7 +3762,7 @@ final class PeerInfoScreenNode: ViewControllerTracingNode, UIScrollViewDelegate let _ = (strongSelf.context.engine.stickers.loadedStickerPack(reference: stickerPackReference, forceActualized: false) |> deliverOnMainQueue).start(next: { [weak self] stickerPack in if let strongSelf = self, case let .result(info, _, _) = stickerPack { - strongSelf.controller?.present(UndoOverlayController(presentationData: strongSelf.presentationData, content: .sticker(context: strongSelf.context, file: file, title: nil, text: strongSelf.presentationData.strings.PeerInfo_TopicIconInfoText(info.title).string, undoText: strongSelf.presentationData.strings.Stickers_PremiumPackView, customAction: nil), elevatedLayout: false, action: { [weak self] action in + strongSelf.controller?.present(UndoOverlayController(presentationData: strongSelf.presentationData, content: .sticker(context: strongSelf.context, file: file, loop: true, title: nil, text: strongSelf.presentationData.strings.PeerInfo_TopicIconInfoText(info.title).string, undoText: strongSelf.presentationData.strings.Stickers_PremiumPackView, customAction: nil), elevatedLayout: false, action: { [weak self] action in if let strongSelf = self, action == .undo { strongSelf.presentEmojiList(packReference: stickerPackReference) } diff --git a/submodules/TelegramUI/Sources/PeerSelectionControllerNode.swift b/submodules/TelegramUI/Sources/PeerSelectionControllerNode.swift index b79df3c602..d6cc99ffc2 100644 --- a/submodules/TelegramUI/Sources/PeerSelectionControllerNode.swift +++ b/submodules/TelegramUI/Sources/PeerSelectionControllerNode.swift @@ -199,7 +199,7 @@ final class PeerSelectionControllerNode: ASDisplayNode { if let requestPeerType = self.requestPeerType { chatListMode = .peerType(type: requestPeerType, hasCreate: hasCreation) } else { - chatListMode = .peers(filter: filter, isSelecting: false, additionalCategories: chatListCategories, chatListFilters: nil, displayAutoremoveTimeout: false) + chatListMode = .peers(filter: filter, isSelecting: false, additionalCategories: chatListCategories, chatListFilters: nil, displayAutoremoveTimeout: false, displayPresence: false) } if hasFilters { diff --git a/submodules/TelegramUI/Sources/TelegramRootController.swift b/submodules/TelegramUI/Sources/TelegramRootController.swift index d98b628fad..e954cfeef5 100644 --- a/submodules/TelegramUI/Sources/TelegramRootController.swift +++ b/submodules/TelegramUI/Sources/TelegramRootController.swift @@ -21,6 +21,7 @@ import CameraScreen import LegacyComponents import LegacyMediaPickerUI import LegacyCamera +import AvatarNode private class DetailsChatPlaceholderNode: ASDisplayNode, NavigationDetailsPlaceholderNode { private var presentationData: PresentationData @@ -269,25 +270,114 @@ public final class TelegramRootController: NavigationController { item = TGMediaAsset(phAsset: asset) } let context = self.context - legacyStoryMediaEditor(context: self.context, item: item, getCaptionPanelView: { return nil }, completion: { result in + legacyStoryMediaEditor(context: self.context, item: item, getCaptionPanelView: { return nil }, completion: { [weak self] mediaResult in dismissCameraImpl?() - switch result { - case let .image(image): - _ = image - case let .video(path): - _ = path - case let .asset(asset): - let options = PHImageRequestOptions() - options.deliveryMode = .highQualityFormat - options.isNetworkAccessAllowed = true - PHImageManager.default().requestImageData(for: asset, options:options, resultHandler: { data, _, _, _ in - if let data, let image = UIImage(data: data) { - Queue.mainQueue().async { - let _ = context.engine.messages.uploadStory(media: .image(dimensions: PixelDimensions(image.size), data: data)).start() - } - } - }) + + guard let self else { + return } + + enum AdditionalCategoryId: Int { + case everyone + case contacts + case closeFriends + } + + let presentationData = self.context.sharedContext.currentPresentationData.with({ $0 }) + + let additionalCategories: [ChatListNodeAdditionalCategory] = [ + ChatListNodeAdditionalCategory( + id: AdditionalCategoryId.everyone.rawValue, + icon: generateAvatarImage(size: CGSize(width: 40.0, height: 40.0), icon: generateTintedImage(image: UIImage(bundleImageName: "Chat List/Filters/Channel"), color: .white), cornerRadius: nil, color: .blue), + smallIcon: generateAvatarImage(size: CGSize(width: 22.0, height: 22.0), icon: generateTintedImage(image: UIImage(bundleImageName: "Chat List/Filters/Channel"), color: .white), iconScale: 0.6, cornerRadius: 6.0, circleCorners: true, color: .blue), + title: "Everyone", + appearance: .option(sectionTitle: "WHO CAN VIEW FOR 24 HOURS") + ), + ChatListNodeAdditionalCategory( + id: AdditionalCategoryId.contacts.rawValue, + icon: generateAvatarImage(size: CGSize(width: 40.0, height: 40.0), icon: generateTintedImage(image: UIImage(bundleImageName: "Chat List/Tabs/IconContacts"), color: .white), iconScale: 1.0 * 0.8, cornerRadius: nil, color: .yellow), + smallIcon: generateAvatarImage(size: CGSize(width: 22.0, height: 22.0), icon: generateTintedImage(image: UIImage(bundleImageName: "Chat List/Tabs/IconContacts"), color: .white), iconScale: 0.6 * 0.8, cornerRadius: 6.0, circleCorners: true, color: .yellow), + title: presentationData.strings.ChatListFolder_CategoryContacts, + appearance: .option(sectionTitle: "WHO CAN VIEW FOR 24 HOURS") + ), + ChatListNodeAdditionalCategory( + id: AdditionalCategoryId.closeFriends.rawValue, + icon: generateAvatarImage(size: CGSize(width: 40.0, height: 40.0), icon: generateTintedImage(image: UIImage(bundleImageName: "Call/StarHighlighted"), color: .white), iconScale: 1.0 * 0.6, cornerRadius: nil, color: .green), + smallIcon: generateAvatarImage(size: CGSize(width: 22.0, height: 22.0), icon: generateTintedImage(image: UIImage(bundleImageName: "Call/StarHighlighted"), color: .white), iconScale: 0.6 * 0.6, cornerRadius: 6.0, circleCorners: true, color: .green), + title: "Close Friends", + appearance: .option(sectionTitle: "WHO CAN VIEW FOR 24 HOURS") + ) + ] + + let selectionController = self.context.sharedContext.makeContactMultiselectionController(ContactMultiselectionControllerParams(context: self.context, mode: .chatSelection(ContactMultiselectionControllerMode.ChatSelection( + title: "Share Story", + searchPlaceholder: "Search contacts", + selectedChats: Set(), + additionalCategories: ContactMultiselectionControllerAdditionalCategories(categories: additionalCategories, selectedCategories: Set([AdditionalCategoryId.everyone.rawValue])), + chatListFilters: nil, + displayPresence: true + )), options: [], filters: [.excludeSelf], alwaysEnabled: true, limit: 1000, reachedLimit: { _ in + })) + selectionController.navigationPresentation = .modal + self.pushViewController(selectionController) + + let _ = (selectionController.result + |> take(1) + |> deliverOnMainQueue).start(next: { [weak self, weak selectionController] result in + guard let self else { + return + } + guard case let .result(peerIds, additionalCategoryIds) = result else { + selectionController?.dismiss() + return + } + + var privacy = EngineStoryPrivacy(base: .everyone, additionallyIncludePeers: []) + if additionalCategoryIds.contains(AdditionalCategoryId.everyone.rawValue) { + privacy.base = .everyone + } else if additionalCategoryIds.contains(AdditionalCategoryId.contacts.rawValue) { + privacy.base = .contacts + } else if additionalCategoryIds.contains(AdditionalCategoryId.closeFriends.rawValue) { + privacy.base = .closeFriends + } + privacy.additionallyIncludePeers = peerIds.compactMap { id -> EnginePeer.Id? in + switch id { + case let .peer(peerId): + return peerId + default: + return nil + } + } + + selectionController?.displayProgress = true + + switch mediaResult { + case let .image(image): + _ = image + selectionController?.dismiss() + case let .video(path): + _ = path + selectionController?.dismiss() + case let .asset(asset): + let options = PHImageRequestOptions() + options.deliveryMode = .highQualityFormat + options.isNetworkAccessAllowed = true + PHImageManager.default().requestImageData(for: asset, options:options, resultHandler: { [weak self] data, _, _, _ in + if let data, let image = UIImage(data: data) { + Queue.mainQueue().async { + let _ = (context.engine.messages.uploadStory(media: .image(dimensions: PixelDimensions(image.size), data: data), privacy: privacy) + |> deliverOnMainQueue).start(completed: { + guard let self else { + return + } + let _ = self + selectionController?.dismiss() + }) + } + } + }) + } + }) }, present: { c, a in presentImpl?(c) }) diff --git a/submodules/UndoUI/Sources/UndoOverlayController.swift b/submodules/UndoUI/Sources/UndoOverlayController.swift index 84788257c3..077ba2e7fa 100644 --- a/submodules/UndoUI/Sources/UndoOverlayController.swift +++ b/submodules/UndoUI/Sources/UndoOverlayController.swift @@ -34,7 +34,7 @@ public enum UndoOverlayContent { case voiceChatRecording(text: String) case voiceChatFlag(text: String) case voiceChatCanSpeak(text: String) - case sticker(context: AccountContext, file: TelegramMediaFile, title: String?, text: String, undoText: String?, customAction: (() -> Void)?) + case sticker(context: AccountContext, file: TelegramMediaFile, loop: Bool, title: String?, text: String, undoText: String?, customAction: (() -> Void)?) case copy(text: String) case mediaSaved(text: String) case paymentSent(currencyValue: String, itemTitle: String) diff --git a/submodules/UndoUI/Sources/UndoOverlayControllerNode.swift b/submodules/UndoUI/Sources/UndoOverlayControllerNode.swift index d1c2f5c7fd..7295bce55a 100644 --- a/submodules/UndoUI/Sources/UndoOverlayControllerNode.swift +++ b/submodules/UndoUI/Sources/UndoOverlayControllerNode.swift @@ -672,7 +672,7 @@ final class UndoOverlayControllerNode: ViewControllerTracingNode { displayUndo = false self.originalRemainingSeconds = 3 - case let .sticker(context, file, title, text, customUndoText, _): + case let .sticker(context, file, loop, title, text, customUndoText, _): self.avatarNode = nil self.iconNode = nil self.iconCheckNode = nil @@ -769,7 +769,7 @@ final class UndoOverlayControllerNode: ViewControllerTracingNode { case let .animated(resource): let animatedStickerNode = DefaultAnimatedStickerNodeImpl() self.animatedStickerNode = animatedStickerNode - animatedStickerNode.setup(source: AnimatedStickerResourceSource(account: context.account, resource: resource._asResource(), isVideo: file.isVideoSticker), width: 80, height: 80, mode: .cached) + animatedStickerNode.setup(source: AnimatedStickerResourceSource(account: context.account, resource: resource._asResource(), isVideo: file.isVideoSticker), width: 80, height: 80, playbackMode: loop ? .loop : .once, mode: .cached) } } case let .copy(text): @@ -1088,7 +1088,7 @@ final class UndoOverlayControllerNode: ViewControllerTracingNode { @objc private func undoButtonPressed() { switch self.content { - case let .sticker(_, _, _, _, _, customAction): + case let .sticker(_, _, _, _, _, _, customAction): if let customAction = customAction { customAction() } else {