diff --git a/Telegram/Telegram-iOS/en.lproj/Localizable.strings b/Telegram/Telegram-iOS/en.lproj/Localizable.strings index 06999e3f6a..97075e32bf 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/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/Camera/Sources/CameraOutput.swift b/submodules/Camera/Sources/CameraOutput.swift index 6372cc0bb3..1933b559ed 100644 --- a/submodules/Camera/Sources/CameraOutput.swift +++ b/submodules/Camera/Sources/CameraOutput.swift @@ -177,8 +177,8 @@ final class CameraOutput: NSObject { } let outputFileName = NSUUID().uuidString - let outputFileURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(outputFileName).appendingPathExtension("mp4") - let outputFilePath = outputFileURL.absoluteString + let outputFilePath = NSTemporaryDirectory() + outputFileName + ".mp4" + let outputFileURL = URL(fileURLWithPath: outputFilePath) let videoRecorder = VideoRecorder(preset: MediaPreset(videoSettings: videoSettings, audioSettings: audioSettings), videoTransform: CGAffineTransform(rotationAngle: .pi / 2.0), fileUrl: outputFileURL, completion: { [weak self] result in if case .success = result { self?.recordingCompletionPipe.putNext(outputFilePath) 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/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/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 68c2f5cc2b..bb3603a82c 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,11 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController let storyContainerScreen = StoryContainerScreen( context: self.context, initialFocusedId: AnyHashable(peerId), - initialContent: initialContent + initialContent: initialContent, + transitionIn: nil, + transitionOut: { _ in + return nil + } ) self.push(storyContainerScreen) }) @@ -2067,20 +2090,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 +2353,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 +2373,76 @@ 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, + 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) + }) + } + )), + environment: {}, + containerSize: size + ) + } + private func updateLayout(layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { var tabContainerOffset: CGFloat = 0.0 if !self.displayNavigationBar { @@ -2343,10 +2451,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 +2929,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 +3306,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 +3382,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/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/ComponentFlow/Source/Base/Transition.swift b/submodules/ComponentFlow/Source/Base/Transition.swift index 9c68843c30..4777b81033 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/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/DisplayLinkAnimator.swift b/submodules/Display/Source/DisplayLinkAnimator.swift index b38a16a046..2762374341 100644 --- a/submodules/Display/Source/DisplayLinkAnimator.swift +++ b/submodules/Display/Source/DisplayLinkAnimator.swift @@ -186,7 +186,9 @@ public final class SharedDisplayLinkDriver { var removeIndices: [Int]? for i in 0 ..< self.requests.count { if let link = self.requests[i].link, link.isValid { - link.update() + if !link.isPaused { + link.update() + } } else { if removeIndices == nil { removeIndices = [i] diff --git a/submodules/Display/Source/GenerateImage.swift b/submodules/Display/Source/GenerateImage.swift index 59c4234b5c..114b2929c7 100644 --- a/submodules/Display/Source/GenerateImage.swift +++ b/submodules/Display/Source/GenerateImage.swift @@ -374,11 +374,11 @@ public enum GradientImageDirection { case horizontal } -public func generateGradientImage(size: CGSize, colors: [UIColor], locations: [CGFloat], direction: GradientImageDirection = .vertical) -> UIImage? { +public func generateGradientImage(size: CGSize, scale: CGFloat = 0.0, colors: [UIColor], locations: [CGFloat], direction: GradientImageDirection = .vertical) -> UIImage? { guard colors.count == locations.count else { return nil } - UIGraphicsBeginImageContextWithOptions(size, false, 0.0) + UIGraphicsBeginImageContextWithOptions(size, false, scale) if let context = UIGraphicsGetCurrentContext() { let gradientColors = colors.map { $0.cgColor } as CFArray let colorSpace = DeviceGraphicsContextSettings.shared.colorSpace 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 d033d54219..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)? @@ -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/DrawingUI/Sources/DrawingBubbleEntity.swift b/submodules/DrawingUI/Sources/DrawingBubbleEntity.swift index 7c100b5ab9..1713d16546 100644 --- a/submodules/DrawingUI/Sources/DrawingBubbleEntity.swift +++ b/submodules/DrawingUI/Sources/DrawingBubbleEntity.swift @@ -43,6 +43,10 @@ public final class DrawingBubbleEntity: DrawingEntity, Codable { public var renderImage: UIImage? + public var isMedia: Bool { + return false + } + init(drawType: DrawType, color: DrawingColor, lineWidth: CGFloat) { self.uuid = UUID() self.isAnimated = false diff --git a/submodules/DrawingUI/Sources/DrawingEntitiesView.swift b/submodules/DrawingUI/Sources/DrawingEntitiesView.swift index 32a9ff9f5a..fe9cab12f7 100644 --- a/submodules/DrawingUI/Sources/DrawingEntitiesView.swift +++ b/submodules/DrawingUI/Sources/DrawingEntitiesView.swift @@ -10,6 +10,8 @@ public protocol DrawingEntity: AnyObject { var isAnimated: Bool { get } var center: CGPoint { get } + var isMedia: Bool { get } + var lineWidth: CGFloat { get set } var color: DrawingColor { get set } @@ -250,7 +252,7 @@ public final class DrawingEntitiesView: UIView, TGPhotoDrawingEntitiesView { let entitiesData = self.entitiesData return entitiesData != initialEntitiesData } else { - let filteredEntities = self.entities.filter { !($0 is DrawingMediaEntity) } + let filteredEntities = self.entities.filter { !$0.isMedia } return !filteredEntities.isEmpty } } @@ -267,7 +269,7 @@ public final class DrawingEntitiesView: UIView, TGPhotoDrawingEntitiesView { while true { var occupied = false for case let view as DrawingEntityView in self.subviews { - if view is DrawingMediaEntityView { + if view.entity.isMedia { continue } let location = view.entity.center @@ -466,7 +468,7 @@ public final class DrawingEntitiesView: UIView, TGPhotoDrawingEntitiesView { private func clear(animated: Bool = false) { if animated { for case let view as DrawingEntityView in self.subviews { - if view is DrawingMediaEntityView { + if view.entity.isMedia { continue } if let selectionView = view.selectionView { @@ -484,7 +486,7 @@ public final class DrawingEntitiesView: UIView, TGPhotoDrawingEntitiesView { } else { for case let view as DrawingEntityView in self.subviews { - if view is DrawingMediaEntityView { + if view.entity.isMedia { continue } view.selectionView?.removeFromSuperview() @@ -554,7 +556,7 @@ public final class DrawingEntitiesView: UIView, TGPhotoDrawingEntitiesView { } func selectEntity(_ entity: DrawingEntity?) { - if entity is DrawingMediaEntity { + if entity?.isMedia == true { return } if entity !== self.selectedEntityView?.entity { @@ -631,13 +633,13 @@ public final class DrawingEntitiesView: UIView, TGPhotoDrawingEntitiesView { } public func handlePan(_ gestureRecognizer: UIPanGestureRecognizer) { - if !self.hasSelection, let mediaEntityView = self.subviews.first(where: { $0 is DrawingMediaEntityView }) as? DrawingMediaEntityView { + if !self.hasSelection, let mediaEntityView = self.subviews.first(where: { $0 is DrawingEntityMediaView }) as? DrawingEntityMediaView { mediaEntityView.handlePan(gestureRecognizer) } } public func handlePinch(_ gestureRecognizer: UIPinchGestureRecognizer) { - if !self.hasSelection, let mediaEntityView = self.subviews.first(where: { $0 is DrawingMediaEntityView }) as? DrawingMediaEntityView { + if !self.hasSelection, let mediaEntityView = self.subviews.first(where: { $0 is DrawingEntityMediaView }) as? DrawingEntityMediaView { mediaEntityView.handlePinch(gestureRecognizer) } else if let selectedEntityView = self.selectedEntityView, let selectionView = selectedEntityView.selectionView { selectionView.handlePinch(gestureRecognizer) @@ -645,7 +647,7 @@ public final class DrawingEntitiesView: UIView, TGPhotoDrawingEntitiesView { } public func handleRotate(_ gestureRecognizer: UIRotationGestureRecognizer) { - if !self.hasSelection, let mediaEntityView = self.subviews.first(where: { $0 is DrawingMediaEntityView }) as? DrawingMediaEntityView { + if !self.hasSelection, let mediaEntityView = self.subviews.first(where: { $0 is DrawingEntityMediaView }) as? DrawingEntityMediaView { mediaEntityView.handleRotate(gestureRecognizer) } else if let selectedEntityView = self.selectedEntityView, let selectionView = selectedEntityView.selectionView { selectionView.handleRotate(gestureRecognizer) @@ -653,6 +655,12 @@ public final class DrawingEntitiesView: UIView, TGPhotoDrawingEntitiesView { } } +protocol DrawingEntityMediaView: DrawingEntityView { + func handlePan(_ gestureRecognizer: UIPanGestureRecognizer) + func handlePinch(_ gestureRecognizer: UIPinchGestureRecognizer) + func handleRotate(_ gestureRecognizer: UIRotationGestureRecognizer) +} + public class DrawingEntityView: UIView { let context: AccountContext let entity: DrawingEntity diff --git a/submodules/DrawingUI/Sources/DrawingMediaEntity.swift b/submodules/DrawingUI/Sources/DrawingMediaEntity.swift index 24c796548a..935b64e88a 100644 --- a/submodules/DrawingUI/Sources/DrawingMediaEntity.swift +++ b/submodules/DrawingUI/Sources/DrawingMediaEntity.swift @@ -70,6 +70,10 @@ public final class DrawingMediaEntity: DrawingEntity, Codable { } } + public var isMedia: Bool { + return true + } + public init(content: Content, size: CGSize) { self.uuid = UUID() self.content = content @@ -149,7 +153,7 @@ public final class DrawingMediaEntity: DrawingEntity, Codable { } } -public final class DrawingMediaEntityView: DrawingEntityView { +public final class DrawingMediaEntityView: DrawingEntityView, DrawingEntityMediaView { private var mediaEntity: DrawingMediaEntity { return self.entity as! DrawingMediaEntity } @@ -164,6 +168,7 @@ public final class DrawingMediaEntityView: DrawingEntityView { didSet { if let previewView = self.previewView { previewView.isUserInteractionEnabled = false + previewView.layer.allowsEdgeAntialiasing = true self.addSubview(previewView) } } @@ -171,6 +176,8 @@ public final class DrawingMediaEntityView: DrawingEntityView { init(context: AccountContext, entity: DrawingMediaEntity) { super.init(context: context, entity: entity) + + self.layer.allowsEdgeAntialiasing = true } required init?(coder: NSCoder) { @@ -224,25 +231,12 @@ public final class DrawingMediaEntityView: DrawingEntityView { if size.width > 0 && self.currentSize != size { self.currentSize = size self.previewView?.frame = CGRect(origin: .zero, size: size) -// let sideSize: CGFloat = size.width -// let boundingSize = CGSize(width: sideSize, height: sideSize) -// -// let imageSize = self.dimensions.aspectFitted(boundingSize) -// self.imageNode.asyncLayout()(TransformImageArguments(corners: ImageCorners(), imageSize: imageSize, boundingSize: imageSize, intrinsicInsets: UIEdgeInsets()))() -// self.imageNode.frame = CGRect(origin: CGPoint(x: floor((size.width - imageSize.width) / 2.0), y: (size.height - imageSize.height) / 2.0), size: imageSize) -// if let animationNode = self.animationNode { -// animationNode.frame = CGRect(origin: CGPoint(x: floor((size.width - imageSize.width) / 2.0), y: (size.height - imageSize.height) / 2.0), size: imageSize) -// animationNode.updateLayout(size: imageSize) -// -// if !self.didApplyVisibility { -// self.didApplyVisibility = true -// self.applyVisibility() -// } -// } + self.update(animated: false) } } + public var updated: (() -> Void)? override func update(animated: Bool) { self.center = self.mediaEntity.position @@ -256,6 +250,8 @@ public final class DrawingMediaEntityView: DrawingEntityView { self.previewView?.frame = self.bounds super.update(animated: animated) + + self.updated?() } override func updateSelectionView() { @@ -319,242 +315,3 @@ public final class DrawingMediaEntityView: DrawingEntityView { self.update(animated: false) } } - -//final class DrawingStickerEntititySelectionView: DrawingEntitySelectionView, UIGestureRecognizerDelegate { -// private let border = SimpleShapeLayer() -// private let leftHandle = SimpleShapeLayer() -// private let rightHandle = SimpleShapeLayer() -// -// private var panGestureRecognizer: UIPanGestureRecognizer! -// -// override init(frame: CGRect) { -// let handleBounds = CGRect(origin: .zero, size: entitySelectionViewHandleSize) -// let handles = [ -// self.leftHandle, -// self.rightHandle -// ] -// -// super.init(frame: frame) -// -// self.backgroundColor = .clear -// self.isOpaque = false -// -// self.border.lineCap = .round -// self.border.fillColor = UIColor.clear.cgColor -// self.border.strokeColor = UIColor(rgb: 0xffffff, alpha: 0.5).cgColor -// self.border.shadowColor = UIColor.black.cgColor -// self.border.shadowRadius = 1.0 -// self.border.shadowOpacity = 0.5 -// self.border.shadowOffset = CGSize() -// self.layer.addSublayer(self.border) -// -// for handle in handles { -// handle.bounds = handleBounds -// handle.fillColor = UIColor(rgb: 0x0a60ff).cgColor -// handle.strokeColor = UIColor(rgb: 0xffffff).cgColor -// handle.rasterizationScale = UIScreen.main.scale -// handle.shouldRasterize = true -// -// self.layer.addSublayer(handle) -// } -// -// let panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(self.handlePan(_:))) -// panGestureRecognizer.delegate = self -// self.addGestureRecognizer(panGestureRecognizer) -// self.panGestureRecognizer = panGestureRecognizer -// -// self.snapTool.onSnapXUpdated = { [weak self] snapped in -// if let strongSelf = self, let entityView = strongSelf.entityView { -// entityView.onSnapToXAxis(snapped) -// } -// } -// -// self.snapTool.onSnapYUpdated = { [weak self] snapped in -// if let strongSelf = self, let entityView = strongSelf.entityView { -// entityView.onSnapToYAxis(snapped) -// } -// } -// -// self.snapTool.onSnapRotationUpdated = { [weak self] snappedAngle in -// if let strongSelf = self, let entityView = strongSelf.entityView { -// entityView.onSnapToAngle(snappedAngle) -// } -// } -// } -// -// required init?(coder: NSCoder) { -// fatalError("init(coder:) has not been implemented") -// } -// -// var scale: CGFloat = 1.0 { -// didSet { -// self.setNeedsLayout() -// } -// } -// -// override var selectionInset: CGFloat { -// return 18.0 -// } -// -// override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { -// return true -// } -// -// private let snapTool = DrawingEntitySnapTool() -// -// private var currentHandle: CALayer? -// @objc private func handlePan(_ gestureRecognizer: UIPanGestureRecognizer) { -// guard let entityView = self.entityView, let entity = entityView.entity as? DrawingStickerEntity else { -// return -// } -// let location = gestureRecognizer.location(in: self) -// -// switch gestureRecognizer.state { -// case .began: -// self.snapTool.maybeSkipFromStart(entityView: entityView, position: entity.position) -// -// if let sublayers = self.layer.sublayers { -// for layer in sublayers { -// if layer.frame.contains(location) { -// self.currentHandle = layer -// self.snapTool.maybeSkipFromStart(entityView: entityView, rotation: entity.rotation) -// return -// } -// } -// } -// self.currentHandle = self.layer -// case .changed: -// let delta = gestureRecognizer.translation(in: entityView.superview) -// let parentLocation = gestureRecognizer.location(in: self.superview) -// let velocity = gestureRecognizer.velocity(in: entityView.superview) -// -// var updatedPosition = entity.position -// var updatedScale = entity.scale -// var updatedRotation = entity.rotation -// if self.currentHandle === self.leftHandle || self.currentHandle === self.rightHandle { -// var deltaX = gestureRecognizer.translation(in: self).x -// if self.currentHandle === self.leftHandle { -// deltaX *= -1.0 -// } -// let scaleDelta = (self.bounds.size.width + deltaX * 2.0) / self.bounds.size.width -// updatedScale *= scaleDelta -// -// let newAngle: CGFloat -// if self.currentHandle === self.leftHandle { -// newAngle = atan2(self.center.y - parentLocation.y, self.center.x - parentLocation.x) -// } else { -// newAngle = atan2(parentLocation.y - self.center.y, parentLocation.x - self.center.x) -// } -// -// // let delta = newAngle - updatedRotation -// updatedRotation = newAngle// self.snapTool.update(entityView: entityView, velocity: 0.0, delta: delta, updatedRotation: newAngle) -// } else if self.currentHandle === self.layer { -// updatedPosition.x += delta.x -// updatedPosition.y += delta.y -// -// updatedPosition = self.snapTool.update(entityView: entityView, velocity: velocity, delta: delta, updatedPosition: updatedPosition) -// } -// -// entity.position = updatedPosition -// entity.scale = updatedScale -// entity.rotation = updatedRotation -// entityView.update() -// -// gestureRecognizer.setTranslation(.zero, in: entityView) -// case .ended, .cancelled: -// self.snapTool.reset() -// if self.currentHandle != nil { -// self.snapTool.rotationReset() -// } -// default: -// break -// } -// -// entityView.onPositionUpdated(entity.position) -// } -// -// override func handlePinch(_ gestureRecognizer: UIPinchGestureRecognizer) { -// guard let entityView = self.entityView, let entity = entityView.entity as? DrawingStickerEntity else { -// return -// } -// -// switch gestureRecognizer.state { -// case .began, .changed: -// let scale = gestureRecognizer.scale -// entity.scale = entity.scale * scale -// entityView.update() -// -// gestureRecognizer.scale = 1.0 -// default: -// break -// } -// } -// -// override func handleRotate(_ gestureRecognizer: UIRotationGestureRecognizer) { -// guard let entityView = self.entityView, let entity = entityView.entity as? DrawingStickerEntity else { -// return -// } -// -// let velocity = gestureRecognizer.velocity -// var updatedRotation = entity.rotation -// var rotation: CGFloat = 0.0 -// -// switch gestureRecognizer.state { -// case .began: -// self.snapTool.maybeSkipFromStart(entityView: entityView, rotation: entity.rotation) -// case .changed: -// rotation = gestureRecognizer.rotation -// updatedRotation += rotation -// -// gestureRecognizer.rotation = 0.0 -// case .ended, .cancelled: -// self.snapTool.rotationReset() -// default: -// break -// } -// -// updatedRotation = self.snapTool.update(entityView: entityView, velocity: velocity, delta: rotation, updatedRotation: updatedRotation) -// entity.rotation = updatedRotation -// entityView.update() -// -// entityView.onPositionUpdated(entity.position) -// } -// -// override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { -// return self.bounds.insetBy(dx: -22.0, dy: -22.0).contains(point) -// } -// -// override func layoutSubviews() { -// let inset = self.selectionInset - 10.0 -// -// let bounds = CGRect(origin: .zero, size: CGSize(width: entitySelectionViewHandleSize.width / self.scale, height: entitySelectionViewHandleSize.height / self.scale)) -// let handleSize = CGSize(width: 9.0 / self.scale, height: 9.0 / self.scale) -// let handlePath = CGPath(ellipseIn: CGRect(origin: CGPoint(x: (bounds.width - handleSize.width) / 2.0, y: (bounds.height - handleSize.height) / 2.0), size: handleSize), transform: nil) -// let lineWidth = (1.0 + UIScreenPixel) / self.scale -// -// let handles = [ -// self.leftHandle, -// self.rightHandle -// ] -// -// for handle in handles { -// handle.path = handlePath -// handle.bounds = bounds -// handle.lineWidth = lineWidth -// } -// -// self.leftHandle.position = CGPoint(x: inset, y: self.bounds.midY) -// self.rightHandle.position = CGPoint(x: self.bounds.maxX - inset, y: self.bounds.midY) -// -// -// let radius = (self.bounds.width - inset * 2.0) / 2.0 -// let circumference: CGFloat = 2.0 * .pi * radius -// let count = 10 -// let relativeDashLength: CGFloat = 0.25 -// let dashLength = circumference / CGFloat(count) -// self.border.lineDashPattern = [dashLength * relativeDashLength, dashLength * relativeDashLength] as [NSNumber] -// -// self.border.lineWidth = 2.0 / self.scale -// self.border.path = UIBezierPath(ovalIn: CGRect(origin: CGPoint(x: inset, y: inset), size: CGSize(width: self.bounds.width - inset * 2.0, height: self.bounds.height - inset * 2.0))).cgPath -// } -//} diff --git a/submodules/DrawingUI/Sources/DrawingSimpleShapeEntity.swift b/submodules/DrawingUI/Sources/DrawingSimpleShapeEntity.swift index 2730975f87..f543905058 100644 --- a/submodules/DrawingUI/Sources/DrawingSimpleShapeEntity.swift +++ b/submodules/DrawingUI/Sources/DrawingSimpleShapeEntity.swift @@ -49,6 +49,10 @@ public final class DrawingSimpleShapeEntity: DrawingEntity, Codable { public var renderImage: UIImage? + public var isMedia: Bool { + return false + } + init(shapeType: ShapeType, drawType: DrawType, color: DrawingColor, lineWidth: CGFloat) { self.uuid = UUID() self.isAnimated = false diff --git a/submodules/DrawingUI/Sources/DrawingStickerEntity.swift b/submodules/DrawingUI/Sources/DrawingStickerEntity.swift index 3e7adffde8..0297d7e258 100644 --- a/submodules/DrawingUI/Sources/DrawingStickerEntity.swift +++ b/submodules/DrawingUI/Sources/DrawingStickerEntity.swift @@ -54,6 +54,10 @@ public final class DrawingStickerEntity: DrawingEntity, Codable { } } + public var isMedia: Bool { + return false + } + public init(content: Content) { self.uuid = UUID() self.content = content diff --git a/submodules/DrawingUI/Sources/DrawingTextEntity.swift b/submodules/DrawingUI/Sources/DrawingTextEntity.swift index 3b07c2530d..6a1da8e795 100644 --- a/submodules/DrawingUI/Sources/DrawingTextEntity.swift +++ b/submodules/DrawingUI/Sources/DrawingTextEntity.swift @@ -130,6 +130,10 @@ public final class DrawingTextEntity: DrawingEntity, Codable { public var renderImage: UIImage? public var renderSubEntities: [DrawingStickerEntity]? + public var isMedia: Bool { + return false + } + public class AnimationFrame: Codable { private enum CodingKeys: String, CodingKey { case timestamp diff --git a/submodules/DrawingUI/Sources/DrawingVectorEntity.swift b/submodules/DrawingUI/Sources/DrawingVectorEntity.swift index 6fcac34040..6fc2c6da0d 100644 --- a/submodules/DrawingUI/Sources/DrawingVectorEntity.swift +++ b/submodules/DrawingUI/Sources/DrawingVectorEntity.swift @@ -55,6 +55,10 @@ public final class DrawingVectorEntity: DrawingEntity, Codable { public var renderImage: UIImage? + public var isMedia: Bool { + return false + } + init(type: VectorType, color: DrawingColor, lineWidth: CGFloat) { self.uuid = UUID() self.isAnimated = false 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/LegacyComponents/Sources/TGMediaVideoConverter.m b/submodules/LegacyComponents/Sources/TGMediaVideoConverter.m index 8ecbf96976..db56b95fbf 100644 --- a/submodules/LegacyComponents/Sources/TGMediaVideoConverter.m +++ b/submodules/LegacyComponents/Sources/TGMediaVideoConverter.m @@ -113,6 +113,8 @@ avAsset = [[AVURLAsset alloc] initWithURL:(NSURL *)avAsset options:nil]; } SQueue *queue = [[SQueue alloc] init]; + + double start = CACurrentMediaTime(); return [[SSignal alloc] initWithGenerator:^id(SSubscriber *subscriber) { @@ -179,6 +181,10 @@ videoStartValue = adjustments.videoStartValue - adjustments.trimStartValue; } + + + double end = CACurrentMediaTime(); + [resultContext.imageGenerator generateCGImagesAsynchronouslyForTimes:@[ [NSValue valueWithCMTime:CMTimeMakeWithSeconds(videoStartValue, NSEC_PER_SEC)] ] completionHandler:^(__unused CMTime requestedTime, CGImageRef _Nullable image, __unused CMTime actualTime, AVAssetImageGeneratorResult result, __unused NSError * _Nullable error) { UIImage *coverImage = nil; @@ -192,6 +198,9 @@ if (watcher != nil) liveUploadData = [watcher fileUpdated:true]; + NSLog(@"%lf seconds", end - start); + NSLog(@"%lf speed", (end - start) / CMTimeGetSeconds(resultContext.timeRange.duration)); + NSUInteger fileSize = [[[NSFileManager defaultManager] attributesOfItemAtPath:outputUrl.path error:nil] fileSize]; contextResult = [TGMediaVideoConversionResult resultWithFileURL:outputUrl fileSize:fileSize duration:CMTimeGetSeconds(resultContext.timeRange.duration) dimensions:resultContext.dimensions coverImage:coverImage liveUploadData:liveUploadData]; return [resultContext finishedContext]; 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/SettingsUI/Sources/Privacy and Security/PrivacyAndSecurityController.swift b/submodules/SettingsUI/Sources/Privacy and Security/PrivacyAndSecurityController.swift index d5282ccca1..bec2dda328 100644 --- a/submodules/SettingsUI/Sources/Privacy and Security/PrivacyAndSecurityController.swift +++ b/submodules/SettingsUI/Sources/Privacy and Security/PrivacyAndSecurityController.swift @@ -459,11 +459,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 8f49fa2baa..be11403600 100644 --- a/submodules/SettingsUI/Sources/Privacy and Security/SelectivePrivacySettingsController.swift +++ b/submodules/SettingsUI/Sources/Privacy and Security/SelectivePrivacySettingsController.swift @@ -488,6 +488,7 @@ private struct SelectivePrivacySettingsControllerState: Equatable { let setting: SelectivePrivacySettingType let enableFor: [EnginePeer.Id: SelectivePrivacyPeer] let disableFor: [EnginePeer.Id: SelectivePrivacyPeer] + let enableForCloseFriends: Bool let saving: Bool @@ -495,21 +496,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 @@ -526,6 +530,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 } @@ -541,6 +548,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 } @@ -558,43 +568,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) } } @@ -770,9 +788,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 @@ -781,11 +801,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 @@ -795,7 +817,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) @@ -1128,21 +1150,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/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/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 61dc05ae6f..d683a76c26 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 { @@ -163,10 +174,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 { @@ -206,10 +218,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..1f1c05d624 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,65 @@ 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, privacy, 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)) + } + } + } + + 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, + privacy: parsedPrivacy + ))) } } } } + 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..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(media: inputMedia, 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 } } } @@ -105,3 +153,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..b4623bc03d 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,19 @@ 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 let privacy: EngineStoryPrivacy? - public init(id: Int64, timestamp: Int32, media: EngineMedia) { + 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 { @@ -35,6 +44,18 @@ 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 + } + if lhs.privacy != rhs.privacy { + return false + } return true } } @@ -137,6 +158,8 @@ public final class StoryListContext { } case .deleted: break + case .read: + break } } return peers @@ -144,6 +167,9 @@ public final class StoryListContext { guard let self else { return } + if self.isLoadingMore { + return + } var itemSets: [PeerItemSet] = self.stateValue.itemSets @@ -151,7 +177,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 +201,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 +221,30 @@ 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, + privacy: item.privacy + ) + itemSets[i] = PeerItemSet( + peerId: itemSets[i].peerId, + peer: itemSets[i].peer, + items: items, + totalCount: items.count + ) + } + } + } } } @@ -263,10 +313,58 @@ public final class StoryListContext { for apiStory in apiStories { switch apiStory { - case let .storyItem(id, date, media): + case let .storyItem(flags, id, date, _, _, media, privacy, 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)) + } + } + } + + 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, + privacy: parsedPrivacy + ) 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 +464,58 @@ 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, privacy, 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)) + } + } + } + + 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, + privacy: parsedPrivacy + ) if !parsedItemSets.isEmpty && parsedItemSets[parsedItemSets.count - 1].peerId == peerId { parsedItemSets[parsedItemSets.count - 1].items.append(item) } else { @@ -406,9 +552,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 +564,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..d7f233feb1 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift @@ -577,12 +577,16 @@ 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 { - 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 b5158fc87f..3562eb148e 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Privacy/UpdatedAccountPrivacySettings.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Privacy/UpdatedAccountPrivacySettings.swift @@ -285,7 +285,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 { @@ -296,6 +296,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/CameraScreen/Sources/ShutterBlobView.swift b/submodules/TelegramUI/Components/CameraScreen/Sources/ShutterBlobView.swift index 9af55d8965..ccc76a634b 100644 --- a/submodules/TelegramUI/Components/CameraScreen/Sources/ShutterBlobView.swift +++ b/submodules/TelegramUI/Components/CameraScreen/Sources/ShutterBlobView.swift @@ -210,6 +210,7 @@ final class ShutterBlobView: MTKView, MTKViewDelegate { self.backgroundColor = .clear self.framebufferOnly = true + self.colorPixelFormat = .bgra8Unorm self.displayLink = SharedDisplayLinkDriver.shared.add { [weak self] in self?.tick() @@ -346,9 +347,10 @@ final class ShutterBlobView: MTKView, MTKViewDelegate { renderEncoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 6, instanceCount: 1) renderEncoder.endEncoding() - - commandBuffer.present(drawable) + drawable.present() + //commandBuffer.present(drawable) commandBuffer.commit() + commandBuffer.waitUntilScheduled() } override func layoutSubviews() { 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/MediaEditor/MetalResources/EditorEnhance.metal b/submodules/TelegramUI/Components/MediaEditor/MetalResources/EditorEnhance.metal index 37f45ec976..64c16902ae 100644 --- a/submodules/TelegramUI/Components/MediaEditor/MetalResources/EditorEnhance.metal +++ b/submodules/TelegramUI/Components/MediaEditor/MetalResources/EditorEnhance.metal @@ -11,30 +11,6 @@ typedef struct { uint numberOfLUTs; } MediaEditorEnhanceLUTGeneratorParameters; -METAL_FUNC half3 rgb2hsl(half3 inputColor) { - half3 color = saturate(inputColor); - - //Compute min and max component values - half MAX = max(color.r, max(color.g, color.b)); - half MIN = min(color.r, min(color.g, color.b)); - - //Make sure MAX > MIN to avoid division by zero later - MAX = max(MIN + 1e-6h, MAX); - - //Compute luminosity - half l = (MIN + MAX) / 2.0h; - - //Compute saturation - half s = (l < 0.5h ? (MAX - MIN) / (MIN + MAX) : (MAX - MIN) / (2.0h - MAX - MIN)); - - //Compute hue - half h = (MAX == color.r ? (color.g - color.b) / (MAX - MIN) : (MAX == color.g ? 2.0h + (color.b - color.r) / (MAX - MIN) : 4.0h + (color.r - color.g) / (MAX - MIN))); - h /= 6.0h; - h = (h < 0.0h ? 1.0h + h : h); - - return half3(h, s, l); -} - fragment half rgbToLightnessFragmentShader(RasterizerData in [[ stage_in ]], texture2d sourceTexture [[ texture(0) ]], sampler colorSampler [[ sampler(0) ]], @@ -45,10 +21,10 @@ fragment half rgbToLightnessFragmentShader(RasterizerData in [[ stage_in ]], return hsl.b; } -kernel void CLAHEGenerateLUT(texture2d outTexture [[texture(0)]], - device uint * histogramBuffer [[buffer(0)]], - constant MediaEditorEnhanceLUTGeneratorParameters & parameters [[buffer(1)]], - uint gid [[thread_position_in_grid]]) +kernel void enhanceGenerateLUT(texture2d outTexture [[texture(0)]], + device uint * histogramBuffer [[buffer(0)]], + constant MediaEditorEnhanceLUTGeneratorParameters & parameters [[buffer(1)]], + uint gid [[thread_position_in_grid]]) { if (gid >= parameters.numberOfLUTs) { return; @@ -87,7 +63,7 @@ kernel void CLAHEGenerateLUT(texture2d outTexture [[textur } } -half CLAHELookup(texture2d lutTexture, sampler lutSamper, float index, float x) { +half enhanceLookup(texture2d lutTexture, sampler lutSamper, float index, float x) { return lutTexture.sample(lutSamper, float2(x, (index + 0.5)/lutTexture.get_height())).r; } @@ -130,10 +106,10 @@ fragment half4 enhanceColorLookupFragmentShader(RasterizerData in [[stage_in]], float srcVal = hslColor.b; float x = (srcVal * 255.0 + 0.5) / lutTexture.get_width(); - half lutPlane1_ind1 = CLAHELookup(lutTexture, lutSampler, ty1 * tileGridSize.x + tx1, x); - half lutPlane1_ind2 = CLAHELookup(lutTexture, lutSampler, ty1 * tileGridSize.x + tx2, x); - half lutPlane2_ind1 = CLAHELookup(lutTexture, lutSampler, ty2 * tileGridSize.x + tx1, x); - half lutPlane2_ind2 = CLAHELookup(lutTexture, lutSampler, ty2 * tileGridSize.x + tx2, x); + half lutPlane1_ind1 = enhanceLookup(lutTexture, lutSampler, ty1 * tileGridSize.x + tx1, x); + half lutPlane1_ind2 = enhanceLookup(lutTexture, lutSampler, ty1 * tileGridSize.x + tx2, x); + half lutPlane2_ind1 = enhanceLookup(lutTexture, lutSampler, ty2 * tileGridSize.x + tx1, x); + half lutPlane2_ind2 = enhanceLookup(lutTexture, lutSampler, ty2 * tileGridSize.x + tx2, x); half res = (lutPlane1_ind1 * xa1_p + lutPlane1_ind2 * xa_p) * ya1 + (lutPlane2_ind1 * xa1_p + lutPlane2_ind2 * xa_p) * ya; diff --git a/submodules/TelegramUI/Components/MediaEditor/Sources/EnhanceRenderPass.swift b/submodules/TelegramUI/Components/MediaEditor/Sources/EnhanceRenderPass.swift index 8479418a52..9187437060 100644 --- a/submodules/TelegramUI/Components/MediaEditor/Sources/EnhanceRenderPass.swift +++ b/submodules/TelegramUI/Components/MediaEditor/Sources/EnhanceRenderPass.swift @@ -123,7 +123,7 @@ private final class EnhanceLUTGeneratorPass: RenderPass { self.calculation = calculation let pipelineDescriptor = MTLComputePipelineDescriptor() - pipelineDescriptor.computeFunction = library.makeFunction(name: "CLAHEGenerateLUT") + pipelineDescriptor.computeFunction = library.makeFunction(name: "enhanceGenerateLUT") do { self.pipelineState = try device.makeComputePipelineState(descriptor: pipelineDescriptor, options: .argumentInfo, reflection: nil) diff --git a/submodules/TelegramUI/Components/MediaEditor/Sources/ImageTextureSource.swift b/submodules/TelegramUI/Components/MediaEditor/Sources/ImageTextureSource.swift index 40dc7823b4..d4b7f549c5 100644 --- a/submodules/TelegramUI/Components/MediaEditor/Sources/ImageTextureSource.swift +++ b/submodules/TelegramUI/Components/MediaEditor/Sources/ImageTextureSource.swift @@ -36,8 +36,7 @@ final class ImageTextureSource: TextureSource { } } -func pixelBufferToMTLTexture(pixelBuffer:CVPixelBuffer, textureCache: CVMetalTextureCache) -> MTLTexture? -{ +func pixelBufferToMTLTexture(pixelBuffer: CVPixelBuffer, textureCache: CVMetalTextureCache) -> MTLTexture? { let width = CVPixelBufferGetWidth(pixelBuffer) let height = CVPixelBufferGetHeight(pixelBuffer) diff --git a/submodules/TelegramUI/Components/MediaEditor/Sources/MediaEditor.swift b/submodules/TelegramUI/Components/MediaEditor/Sources/MediaEditor.swift index 9939635185..54852701d6 100644 --- a/submodules/TelegramUI/Components/MediaEditor/Sources/MediaEditor.swift +++ b/submodules/TelegramUI/Components/MediaEditor/Sources/MediaEditor.swift @@ -7,7 +7,6 @@ import Photos import SwiftSignalKit import Display import TelegramCore -import AccountContext import TelegramPresentationData public final class MediaEditor { @@ -26,9 +25,7 @@ public final class MediaEditor { } } - private let context: AccountContext private let subject: Subject - private let renderer: MediaEditorRenderer private var player: AVPlayer? private var didPlayToEndTimeObserver: NSObjectProtocol? @@ -36,14 +33,12 @@ public final class MediaEditor { public var values: MediaEditorValues { didSet { - self.updateRenderValues() + self.updateRenderChain() } } - private let enhancePass = EnhanceRenderPass() - private let sharpenPass = SharpenRenderPass() - private let blurPass = BlurRenderPass() - private let adjustmentsPass = AdjustmentsRenderPass() + private let renderer = MediaEditorRenderer() + private let renderChain = MediaEditorRenderChain() private let histogramCalculationPass = HistogramCalculationPass() private var textureSourceDisposable: Disposable? @@ -58,15 +53,15 @@ public final class MediaEditor { } } - public let histogramPipe = ValuePipe() + private let histogramPromise = Promise() public var histogram: Signal { - return self.histogramPipe.signal() + return self.histogramPromise.get() } - var textureCache: CVMetalTextureCache! + private var textureCache: CVMetalTextureCache! public var hasPortraitMask: Bool { - return self.blurPass.maskTexture != nil + return self.renderChain.blurPass.maskTexture != nil } public var resultIsVideo: Bool { @@ -78,8 +73,7 @@ public final class MediaEditor { return self.renderer.finalRenderedImage() } - public init(context: AccountContext, subject: Subject, values: MediaEditorValues? = nil) { - self.context = context + public init(subject: Subject, values: MediaEditorValues? = nil, hasHistogram: Bool = false) { self.subject = subject if let values { self.values = values @@ -91,22 +85,22 @@ public final class MediaEditor { cropScale: 1.0, cropRotation: 0.0, cropMirroring: false, + gradientColors: nil, videoTrimRange: nil, + videoIsMuted: false, drawing: nil, toolValues: [:] ) } + + self.renderer.addRenderChain(self.renderChain) + if hasHistogram { + self.renderer.addRenderPass(self.histogramCalculationPass) + } - self.renderer = MediaEditorRenderer() - self.renderer.addRenderPass(self.enhancePass) - //self.renderer.addRenderPass(self.sharpenPass) - self.renderer.addRenderPass(self.blurPass) - self.renderer.addRenderPass(self.adjustmentsPass) - self.renderer.addRenderPass(self.histogramCalculationPass) - self.histogramCalculationPass.updated = { [weak self] data in if let self { - self.histogramPipe.putNext(data) + self.histogramPromise.set(.single(data)) } } } @@ -226,6 +220,7 @@ public final class MediaEditor { self.renderer.textureSource = source self.player = player self.gradientColorsValue = (topColor, bottomColor) + self.setGradientColors([topColor, bottomColor]) self.maybeGeneratePersonSegmentation(image) @@ -252,6 +247,10 @@ public final class MediaEditor { self.setupSource() } + public func setCrop(offset: CGPoint, scale: CGFloat, rotation: CGFloat, mirroring: Bool) { + self.values = self.values.withUpdatedCrop(offset: offset, scale: scale, rotation: rotation, mirroring: mirroring) + } + public func getToolValue(_ key: EditorToolKey) -> Any? { return self.values.toolValues[key] } @@ -260,11 +259,75 @@ public final class MediaEditor { var updatedToolValues = self.values.toolValues updatedToolValues[key] = value self.values = self.values.withUpdatedToolValues(updatedToolValues) - self.updateRenderValues() + self.updateRenderChain() } - func updateRenderValues() { - for (key, value) in self.values.toolValues { + public func setVideoIsMuted(_ videoIsMuted: Bool) { + self.player?.isMuted = videoIsMuted + self.values = self.values.withUpdatedVideoIsMuted(videoIsMuted) + } + + public func setGradientColors(_ gradientColors: [UIColor]) { + self.values = self.values.withUpdatedGradientColors(gradientColors: gradientColors) + } + + private func updateRenderChain() { + self.renderChain.update(values: self.values) + if let player = self.player, player.rate > 0.0 { + } else { + self.previewView?.scheduleFrame() + } + } + + private func maybeGeneratePersonSegmentation(_ image: UIImage?) { + if #available(iOS 15.0, *), let cgImage = image?.cgImage { + let faceRequest = VNDetectFaceRectanglesRequest { [weak self] request, _ in + guard let _ = request.results?.first as? VNFaceObservation else { return } + + let personRequest = VNGeneratePersonSegmentationRequest(completionHandler: { [weak self] request, error in + if let self, let result = (request as? VNGeneratePersonSegmentationRequest)?.results?.first { + Queue.mainQueue().async { + self.renderChain.blurPass.maskTexture = pixelBufferToMTLTexture(pixelBuffer: result.pixelBuffer, textureCache: self.textureCache) + } + } + }) + personRequest.qualityLevel = .accurate + personRequest.outputPixelFormat = kCVPixelFormatType_OneComponent8 + + let handler = VNImageRequestHandler(cgImage: cgImage, options: [:]) + do { + try handler.perform([personRequest]) + } catch { + print(error) + } + } + let handler = VNImageRequestHandler(cgImage: cgImage, options: [:]) + do { + try handler.perform([faceRequest]) + } catch { + print(error) + } + } + } +} + +final class MediaEditorRenderChain { + fileprivate let enhancePass = EnhanceRenderPass() + fileprivate let sharpenPass = SharpenRenderPass() + fileprivate let blurPass = BlurRenderPass() + fileprivate let adjustmentsPass = AdjustmentsRenderPass() + + var renderPasses: [RenderPass] { + return [ + self.enhancePass, + self.sharpenPass, + self.blurPass, + self.adjustmentsPass + ] + } + + func update(values: MediaEditorValues) { + for (key, value) in values.toolValues { switch key { case .enhance: if let value = value as? Float { @@ -371,37 +434,5 @@ public final class MediaEditor { self.adjustmentsPass.blueCurve = blueDataPoints } } - self.previewView?.scheduleFrame() - } - - private func maybeGeneratePersonSegmentation(_ image: UIImage?) { - if #available(iOS 15.0, *), let cgImage = image?.cgImage { - let faceRequest = VNDetectFaceRectanglesRequest { [weak self] request, _ in - guard let _ = request.results?.first as? VNFaceObservation else { return } - - let personRequest = VNGeneratePersonSegmentationRequest(completionHandler: { [weak self] request, error in - if let self, let result = (request as? VNGeneratePersonSegmentationRequest)?.results?.first { - Queue.mainQueue().async { - self.blurPass.maskTexture = pixelBufferToMTLTexture(pixelBuffer: result.pixelBuffer, textureCache: self.textureCache) - } - } - }) - personRequest.qualityLevel = .accurate - personRequest.outputPixelFormat = kCVPixelFormatType_OneComponent8 - - let handler = VNImageRequestHandler(cgImage: cgImage, options: [:]) - do { - try handler.perform([personRequest]) - } catch { - print(error) - } - } - let handler = VNImageRequestHandler(cgImage: cgImage, options: [:]) - do { - try handler.perform([faceRequest]) - } catch { - print(error) - } - } } } diff --git a/submodules/TelegramUI/Components/MediaEditor/Sources/MediaEditorComposer.swift b/submodules/TelegramUI/Components/MediaEditor/Sources/MediaEditorComposer.swift new file mode 100644 index 0000000000..2acd446f93 --- /dev/null +++ b/submodules/TelegramUI/Components/MediaEditor/Sources/MediaEditorComposer.swift @@ -0,0 +1,181 @@ +import Foundation +import AVFoundation +import CoreImage +import Metal +import Display + +final class MediaEditorComposer { + let device: MTLDevice? + + private let values: MediaEditorValues + private let dimensions: CGSize + + private let ciContext: CIContext? + + private let renderer = MediaEditorRenderer() + private let renderChain = MediaEditorRenderChain() + + private var gradientImage: CIImage + + private var textureCache: CVMetalTextureCache? + + let semaphore = DispatchSemaphore(value: 1) + + init(values: MediaEditorValues, dimensions: CGSize) { + self.values = values + self.dimensions = dimensions + + self.renderer.externalSemaphore = self.semaphore + self.renderer.addRenderChain(self.renderChain) + self.renderer.addRenderPass(ComposerRenderPass()) + + if let gradientColors = values.gradientColors { + let image = generateGradientImage(size: dimensions, scale: 1.0, colors: gradientColors, locations: [0.0, 1.0])! + self.gradientImage = CIImage(image: image)! + } else { + self.gradientImage = CIImage(color: .black) + } + + self.device = MTLCreateSystemDefaultDevice() + if let device = self.device { + self.ciContext = CIContext(mtlDevice: device, options: [.workingColorSpace : NSNull()]) + + CVMetalTextureCacheCreate(kCFAllocatorDefault, nil, device, nil, &self.textureCache) + } else { + self.ciContext = nil + } + + self.renderer.setupForComposer(composer: self) + self.renderChain.update(values: self.values) + } + + private var processedPixelBuffer: CVPixelBuffer? + func processSampleBuffer(_ sampleBuffer: CMSampleBuffer, pool: CVPixelBufferPool?) -> CVPixelBuffer? { + guard let textureCache = self.textureCache, let imageBuffer = CMSampleBufferGetImageBuffer(sampleBuffer), let pool = pool else { + return nil + } + let width = CVPixelBufferGetWidth(imageBuffer) + let height = CVPixelBufferGetHeight(imageBuffer) + let format: MTLPixelFormat = .bgra8Unorm + var textureRef : CVMetalTexture? + let status = CVMetalTextureCacheCreateTextureFromImage(nil, textureCache, imageBuffer, nil, format, width, height, 0, &textureRef) + var texture: MTLTexture? + if status == kCVReturnSuccess { + texture = CVMetalTextureGetTexture(textureRef!) + } + + if let texture { + self.renderer.consumeTexture(texture, rotation: .rotate90Degrees) + self.renderer.renderFrame() + + if let finalTexture = self.renderer.finalTexture, var ciImage = CIImage(mtlTexture: finalTexture) { + ciImage = ciImage.transformed(by: CGAffineTransformMakeScale(1.0, -1.0).translatedBy(x: 0.0, y: -ciImage.extent.height)) + + var pixelBuffer: CVPixelBuffer? + CVPixelBufferPoolCreatePixelBuffer(kCFAllocatorDefault, pool, &pixelBuffer) + + if let composition = makeFrameComposition(inputImage: ciImage), let pixelBuffer { + self.ciContext?.render(composition, to: pixelBuffer) + + return pixelBuffer + } else { + return nil + } + } else { + return nil + } + } else { + return nil + } + } + + func makeFrameComposition(inputImage: CIImage) -> CIImage? { + var resultImage = CIImage(color: .black).cropped(to: CGRect(origin: .zero, size: self.dimensions)).transformed(by: CGAffineTransform(translationX: -self.dimensions.width / 2.0, y: -self.dimensions.height / 2.0)) + resultImage = self.gradientImage.composited(over: resultImage) + + var mediaImage = inputImage.transformed(by: CGAffineTransform(translationX: -inputImage.extent.midX, y: -inputImage.extent.midY)) + + var cropTransform = CGAffineTransform(translationX: self.values.cropOffset.x, y: self.values.cropOffset.y * -1.0) + cropTransform = cropTransform.rotated(by: -self.values.cropRotation) + cropTransform = cropTransform.scaledBy(x: self.values.cropScale, y: self.values.cropScale) + mediaImage = mediaImage.transformed(by: cropTransform) + + resultImage = mediaImage.composited(over: resultImage) + + resultImage = resultImage.cropped(to: CGRect(origin: CGPoint(x: -self.dimensions.width / 2.0, y: -self.dimensions.height / 2.0), size: self.dimensions)) + + return resultImage + } +} + +extension CMSampleBuffer { + func newSampleBufferWithReplacedImageBuffer(_ imageBuffer: CVImageBuffer) -> CMSampleBuffer? { + guard let _ = CMSampleBufferGetImageBuffer(self) else { + return nil + } + var timingInfo = CMSampleTimingInfo() + guard CMSampleBufferGetSampleTimingInfo(self, at: 0, timingInfoOut: &timingInfo) == 0 else { + return nil + } + var outputSampleBuffer: CMSampleBuffer? + var newFormatDescription: CMFormatDescription? + CMVideoFormatDescriptionCreateForImageBuffer(allocator: nil, imageBuffer: imageBuffer, formatDescriptionOut: &newFormatDescription) + guard let formatDescription = newFormatDescription else { + return nil + } + CMSampleBufferCreateReadyWithImageBuffer(allocator: nil, imageBuffer: imageBuffer, formatDescription: formatDescription, sampleTiming: &timingInfo, sampleBufferOut: &outputSampleBuffer) + return outputSampleBuffer + } +} + + +final class ComposerRenderPass: DefaultRenderPass { + fileprivate var cachedTexture: MTLTexture? + + override func process(input: MTLTexture, rotation: TextureRotation, device: MTLDevice, commandBuffer: MTLCommandBuffer) -> MTLTexture? { + self.setupVerticesBuffer(device: device, rotation: rotation) + + let (width, height) = textureDimensionsForRotation(texture: input, rotation: rotation) + + if self.cachedTexture == nil || self.cachedTexture?.width != width || self.cachedTexture?.height != height { + let textureDescriptor = MTLTextureDescriptor() + textureDescriptor.textureType = .type2D + textureDescriptor.width = width + textureDescriptor.height = height + textureDescriptor.pixelFormat = input.pixelFormat + textureDescriptor.storageMode = .shared + textureDescriptor.usage = [.shaderRead, .shaderWrite, .renderTarget] + guard let texture = device.makeTexture(descriptor: textureDescriptor) else { + return input + } + self.cachedTexture = texture + texture.label = "composerTexture" + } + + let renderPassDescriptor = MTLRenderPassDescriptor() + renderPassDescriptor.colorAttachments[0].texture = self.cachedTexture! + renderPassDescriptor.colorAttachments[0].loadAction = .dontCare + renderPassDescriptor.colorAttachments[0].storeAction = .store + renderPassDescriptor.colorAttachments[0].clearColor = MTLClearColor(red: 0.0, green: 0.0, blue: 0.0, alpha: 1.0) + guard let renderCommandEncoder = commandBuffer.makeRenderCommandEncoder(descriptor: renderPassDescriptor) else { + return input + } + + renderCommandEncoder.setViewport(MTLViewport( + originX: 0, originY: 0, + width: Double(width), height: Double(height), + znear: -1.0, zfar: 1.0) + ) + + renderCommandEncoder.setFragmentTexture(input, index: 0) + + var texCoordScales = simd_float2(x: 1.0, y: 1.0) + renderCommandEncoder.setFragmentBytes(&texCoordScales, length: MemoryLayout.stride, index: 0) + + self.encodeDefaultCommands(using: renderCommandEncoder) + + renderCommandEncoder.endEncoding() + + return self.cachedTexture! + } +} diff --git a/submodules/TelegramUI/Components/MediaEditor/Sources/MediaEditorRenderer.swift b/submodules/TelegramUI/Components/MediaEditor/Sources/MediaEditorRenderer.swift index 354e3003a7..0419070b77 100644 --- a/submodules/TelegramUI/Components/MediaEditor/Sources/MediaEditorRenderer.swift +++ b/submodules/TelegramUI/Components/MediaEditor/Sources/MediaEditorRenderer.swift @@ -28,8 +28,6 @@ protocol RenderPass: AnyObject { } protocol TextureSource { - func pause() - func start() func connect(to: TextureConsumer) } @@ -46,12 +44,8 @@ protocol RenderTarget: AnyObject { final class MediaEditorRenderer: TextureConsumer { var textureSource: TextureSource? { - willSet { - self.textureSource?.pause() - } didSet { self.textureSource?.connect(to: self) - self.textureSource?.start() } } @@ -69,7 +63,9 @@ final class MediaEditorRenderer: TextureConsumer { private var currentRotation: TextureRotation = .rotate0Degrees private var library: MTLLibrary? - private weak var finalTexture: MTLTexture? + var finalTexture: MTLTexture? + + var externalSemaphore: DispatchSemaphore? public init() { @@ -88,7 +84,13 @@ final class MediaEditorRenderer: TextureConsumer { } } - func setup() { + func addRenderChain(_ renderChain: MediaEditorRenderChain) { + for renderPass in renderChain.renderPasses { + self.addRenderPass(renderPass) + } + } + + private func setup() { guard let device = self.renderTarget?.mtlDevice else { return } @@ -112,9 +114,42 @@ final class MediaEditorRenderer: TextureConsumer { self.outputRenderPass.setup(device: device, library: defaultLibrary) } + private var currentDevice: MTLDevice? + func setupForComposer(composer: MediaEditorComposer) { + guard let device = composer.device else { + return + } + self.currentDevice = device + + let mainBundle = Bundle(for: MediaEditorRenderer.self) + guard let path = mainBundle.path(forResource: "MediaEditorBundle", ofType: "bundle") else { + return + } + guard let bundle = Bundle(path: path) else { + return + } + + guard let defaultLibrary = try? device.makeDefaultLibrary(bundle: bundle) else { + return + } + self.library = defaultLibrary + + self.commandQueue = device.makeCommandQueue() + self.commandQueue?.label = "Media Editor Command Queue" + self.renderPasses.forEach { $0.setup(device: device, library: defaultLibrary) } + } + + private var currentCommandBuffer: MTLCommandBuffer? func renderFrame() { - guard let renderTarget = self.renderTarget, - let device = renderTarget.mtlDevice, + let device: MTLDevice? + if let renderTarget = self.renderTarget { + device = renderTarget.mtlDevice + } else if let currentDevice = self.currentDevice { + device = currentDevice + } else { + device = nil + } + guard let device = device, let commandQueue = self.commandQueue, var texture = self.currentTexture else { return @@ -133,13 +168,30 @@ final class MediaEditorRenderer: TextureConsumer { texture = nextTexture } } - let _ = self.outputRenderPass.process(input: texture, rotation: rotation, device: device, commandBuffer: commandBuffer) + if self.renderTarget != nil { + let _ = self.outputRenderPass.process(input: texture, rotation: rotation, device: device, commandBuffer: commandBuffer) + } self.finalTexture = texture commandBuffer.addCompletedHandler { [weak self] _ in self?.semaphore.signal() + self?.externalSemaphore?.signal() + } + + if let _ = self.renderTarget { + commandBuffer.commit() + commandBuffer.waitUntilScheduled() + } else { + commandBuffer.commit() + commandBuffer.waitUntilCompleted() + } + } + + func commit() { + if let commandBuffer = self.currentCommandBuffer { + commandBuffer.commit() + self.currentCommandBuffer = nil } - commandBuffer.commit() } func consumeTexture(_ texture: MTLTexture, rotation: TextureRotation) { diff --git a/submodules/TelegramUI/Components/MediaEditor/Sources/MediaEditorValues.swift b/submodules/TelegramUI/Components/MediaEditor/Sources/MediaEditorValues.swift index dbc172a0ac..dd4d24b6a2 100644 --- a/submodules/TelegramUI/Components/MediaEditor/Sources/MediaEditorValues.swift +++ b/submodules/TelegramUI/Components/MediaEditor/Sources/MediaEditorValues.swift @@ -20,6 +20,19 @@ public enum EditorToolKey { case blur case curves } +private let adjustmentToolsKeys: [EditorToolKey] = [ + .enhance, + .brightness, + .contrast, + .saturation, + .warmth, + .fade, + .highlights, + .shadows, + .vignette, + .grain, + .sharpen +] public struct TintValue: Equatable { public static let initial = TintValue( @@ -288,32 +301,118 @@ public struct CurvesValue: Equatable { } public class MediaEditorValues { - let originalDimensions: PixelDimensions - let cropOffset: CGPoint - let cropSize: CGSize? - let cropScale: CGFloat - let cropRotation: CGFloat - let cropMirroring: Bool + public let originalDimensions: PixelDimensions + public let cropOffset: CGPoint + public let cropSize: CGSize? + public let cropScale: CGFloat + public let cropRotation: CGFloat + public let cropMirroring: Bool - let videoTrimRange: Range? + public let gradientColors: [UIColor]? - let drawing: UIImage? - let toolValues: [EditorToolKey: Any] + public let videoTrimRange: Range? + public let videoIsMuted: Bool - init(originalDimensions: PixelDimensions, cropOffset: CGPoint, cropSize: CGSize?, cropScale: CGFloat, cropRotation: CGFloat, cropMirroring: Bool, videoTrimRange: Range?, drawing: UIImage?, toolValues: [EditorToolKey: Any]) { + public let drawing: UIImage? + public let toolValues: [EditorToolKey: Any] + + init( + originalDimensions: PixelDimensions, + cropOffset: CGPoint, + cropSize: CGSize?, + cropScale: CGFloat, + cropRotation: CGFloat, + cropMirroring: Bool, + gradientColors: [UIColor]?, + videoTrimRange: Range?, + videoIsMuted: Bool, + drawing: UIImage?, + toolValues: [EditorToolKey: Any] + ) { self.originalDimensions = originalDimensions self.cropOffset = cropOffset self.cropSize = cropSize self.cropScale = cropScale self.cropRotation = cropRotation self.cropMirroring = cropMirroring + self.gradientColors = gradientColors self.videoTrimRange = videoTrimRange + self.videoIsMuted = videoIsMuted self.drawing = drawing self.toolValues = toolValues } + func withUpdatedCrop(offset: CGPoint, scale: CGFloat, rotation: CGFloat, mirroring: Bool) -> MediaEditorValues { + return MediaEditorValues(originalDimensions: self.originalDimensions, cropOffset: offset, cropSize: self.cropSize, cropScale: scale, cropRotation: rotation, cropMirroring: mirroring, gradientColors: self.gradientColors, videoTrimRange: self.videoTrimRange, videoIsMuted: self.videoIsMuted, drawing: self.drawing, toolValues: self.toolValues) + } + + func withUpdatedGradientColors(gradientColors: [UIColor]) -> MediaEditorValues { + return MediaEditorValues(originalDimensions: self.originalDimensions, cropOffset: self.cropOffset, cropSize: self.cropSize, cropScale: self.cropScale, cropRotation: self.cropRotation, cropMirroring: self.cropMirroring, gradientColors: gradientColors, videoTrimRange: self.videoTrimRange, videoIsMuted: self.videoIsMuted, drawing: self.drawing, toolValues: self.toolValues) + } + + func withUpdatedVideoIsMuted(_ videoIsMuted: Bool) -> MediaEditorValues { + return MediaEditorValues(originalDimensions: self.originalDimensions, cropOffset: self.cropOffset, cropSize: self.cropSize, cropScale: self.cropScale, cropRotation: self.cropRotation, cropMirroring: self.cropMirroring, gradientColors: self.gradientColors, videoTrimRange: self.videoTrimRange, videoIsMuted: videoIsMuted, drawing: self.drawing, toolValues: self.toolValues) + } + func withUpdatedToolValues(_ toolValues: [EditorToolKey: Any]) -> MediaEditorValues { - return MediaEditorValues(originalDimensions: self.originalDimensions, cropOffset: self.cropOffset, cropSize: self.cropSize, cropScale: self.cropScale, cropRotation: self.cropRotation, cropMirroring: self.cropMirroring, videoTrimRange: self.videoTrimRange, drawing: self.drawing, toolValues: toolValues) + return MediaEditorValues(originalDimensions: self.originalDimensions, cropOffset: self.cropOffset, cropSize: self.cropSize, cropScale: self.cropScale, cropRotation: self.cropRotation, cropMirroring: self.cropMirroring, gradientColors: self.gradientColors, videoTrimRange: self.videoTrimRange, videoIsMuted: self.videoIsMuted, drawing: self.drawing, toolValues: toolValues) + } +} + +private let toolEpsilon: Float = 0.005 +public extension MediaEditorValues { + var hasAdjustments: Bool { + for key in adjustmentToolsKeys { + if let value = self.toolValues[key] as? Float, abs(value) > toolEpsilon { + return true + } + } + return false + } + + var hasTint: Bool { + if let tintValue = self.toolValues[.shadowsTint] as? TintValue, tintValue.color != .clear && tintValue.intensity > toolEpsilon { + return true + } else if let tintValue = self.toolValues[.highlightsTint] as? TintValue, tintValue.color != .clear && tintValue.intensity > toolEpsilon { + return true + } else { + return false + } + } + + var hasBlur: Bool { + if let blurValue = self.toolValues[.blur] as? BlurValue, blurValue.mode != .off || blurValue.intensity > toolEpsilon { + return true + } else { + return false + } + } + + var hasCurves: Bool { + if let curvesValue = self.toolValues[.curves] as? CurvesValue, curvesValue != CurvesValue.initial { + return true + } else { + return false + } + } + + var requiresComposing: Bool { + if self.hasAdjustments { + return true + } + if self.hasTint { + return true + } + if self.hasBlur { + return true + } + if self.hasCurves { + return true + } + if self.drawing != nil { + return true + } + return false } } diff --git a/submodules/TelegramUI/Components/MediaEditor/Sources/MediaEditorVideoExport.swift b/submodules/TelegramUI/Components/MediaEditor/Sources/MediaEditorVideoExport.swift new file mode 100644 index 0000000000..8539fe9b0f --- /dev/null +++ b/submodules/TelegramUI/Components/MediaEditor/Sources/MediaEditorVideoExport.swift @@ -0,0 +1,591 @@ +import Foundation +import AVFoundation +import MetalKit +import SwiftSignalKit + +enum ExportWriterStatus { + case unknown + case writing + case completed + case failed + case cancelled +} + +protocol MediaEditorVideoExportWriter { + func setup(configuration: MediaEditorVideoExport.Configuration, outputPath: String) + func setupVideoInput(configuration: MediaEditorVideoExport.Configuration, inputTransform: CGAffineTransform?) + func setupAudioInput(configuration: MediaEditorVideoExport.Configuration) + + func startWriting() -> Bool + func startSession(atSourceTime time: CMTime) + + func finishWriting(completion: @escaping () -> Void) + func cancelWriting() + + func requestVideoDataWhenReady(on queue: DispatchQueue, using block: @escaping () -> Void) + func requestAudioDataWhenReady(on queue: DispatchQueue, using block: @escaping () -> Void) + + var isReadyForMoreVideoData: Bool { get } + func appendVideoBuffer(_ buffer: CMSampleBuffer) -> Bool + func appendPixelBuffer(_ buffer: CVPixelBuffer, at time: CMTime) -> Bool + func markVideoAsFinished() + + var pixelBufferPool: CVPixelBufferPool? { get } + + var isReadyForMoreAudioData: Bool { get } + func appendAudioBuffer(_ buffer: CMSampleBuffer) -> Bool + func markAudioAsFinished() + + var status: ExportWriterStatus { get } +} + +public final class MediaEditorVideoAVAssetWriter: MediaEditorVideoExportWriter { + private var writer: AVAssetWriter? + private var videoInput: AVAssetWriterInput? + private var audioInput: AVAssetWriterInput? + + private var adaptor: AVAssetWriterInputPixelBufferAdaptor! + + func setup(configuration: MediaEditorVideoExport.Configuration, outputPath: String) { + let url = URL(fileURLWithPath: outputPath) + self.writer = try? AVAssetWriter(url: url, fileType: .mp4) + guard let writer = self.writer else { + return + } + writer.shouldOptimizeForNetworkUse = configuration.shouldOptimizeForNetworkUse + } + + func setupVideoInput(configuration: MediaEditorVideoExport.Configuration, inputTransform: CGAffineTransform?) { + guard let writer = self.writer else { + return + } + let videoInput: AVAssetWriterInput + if let transform = inputTransform { + let size = CGSize(width: configuration.videoSettings[AVVideoWidthKey] as! Int, height: configuration.videoSettings[AVVideoHeightKey] as! Int) + let transformedSize = size.applying(transform.inverted()) + var videoSettings = configuration.videoSettings + videoSettings[AVVideoWidthKey] = abs(transformedSize.width) + videoSettings[AVVideoHeightKey] = abs(transformedSize.height) + videoInput = AVAssetWriterInput(mediaType: .video, outputSettings: videoSettings) + videoInput.transform = transform + } else { + videoInput = AVAssetWriterInput(mediaType: .video, outputSettings: configuration.videoSettings) + } + videoInput.expectsMediaDataInRealTime = false + + let sourcePixelBufferAttributes = [ + kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA, + kCVPixelBufferWidthKey as String: 1080, + kCVPixelBufferHeightKey as String: 1920 + ] + self.adaptor = AVAssetWriterInputPixelBufferAdaptor(assetWriterInput: videoInput, sourcePixelBufferAttributes: sourcePixelBufferAttributes) + + if writer.canAdd(videoInput) { + writer.add(videoInput) + } else { + //throw Error.cannotAddVideoInput + } + self.videoInput = videoInput + } + + func setupAudioInput(configuration: MediaEditorVideoExport.Configuration) { + guard let writer = self.writer else { + return + } + let audioInput = AVAssetWriterInput(mediaType: .audio, outputSettings: configuration.audioSettings) + audioInput.expectsMediaDataInRealTime = false + if writer.canAdd(audioInput) { + writer.add(audioInput) + } + self.audioInput = audioInput + } + + func startWriting() -> Bool { + return self.writer?.startWriting() ?? false + } + + func startSession(atSourceTime time: CMTime) { + self.writer?.startSession(atSourceTime: time) + } + + func finishWriting(completion: @escaping () -> Void) { + self.writer?.finishWriting(completionHandler: completion) + } + + func cancelWriting() { + self.writer?.cancelWriting() + } + + func requestVideoDataWhenReady(on queue: DispatchQueue, using block: @escaping () -> Void) { + self.videoInput?.requestMediaDataWhenReady(on: queue, using: block) + } + + func requestAudioDataWhenReady(on queue: DispatchQueue, using block: @escaping () -> Void) { + self.audioInput?.requestMediaDataWhenReady(on: queue, using: block) + } + + var isReadyForMoreVideoData: Bool { + return self.videoInput?.isReadyForMoreMediaData ?? false + } + + func appendVideoBuffer(_ buffer: CMSampleBuffer) -> Bool { + return self.videoInput?.append(buffer) ?? false + } + + func appendPixelBuffer(_ pixelBuffer: CVPixelBuffer, at time: CMTime) -> Bool { + return self.adaptor.append(pixelBuffer, withPresentationTime: time) + } + + var pixelBufferPool: CVPixelBufferPool? { + return self.adaptor.pixelBufferPool + } + + func markVideoAsFinished() { + self.videoInput?.markAsFinished() + } + + var isReadyForMoreAudioData: Bool { + return self.audioInput?.isReadyForMoreMediaData ?? false + } + + func appendAudioBuffer(_ buffer: CMSampleBuffer) -> Bool { + return self.audioInput?.append(buffer) ?? false + } + + func markAudioAsFinished() { + self.audioInput?.markAsFinished() + } + + var status: ExportWriterStatus { + if let writer = self.writer { + switch writer.status { + case .unknown: + return .unknown + case .writing: + return .writing + case .completed: + return .completed + case .failed: + return .failed + case .cancelled: + return .cancelled + @unknown default: + fatalError() + } + } else { + return .unknown + } + } +} + +public final class MediaEditorVideoExport { + public enum Subject { + case image(UIImage) + case video(AVAsset) + } + + public struct Configuration { + public var shouldOptimizeForNetworkUse: Bool = true + public var videoSettings: [String: Any] + public var audioSettings: [String: Any] + public var values: MediaEditorValues + + public init(videoSettings: [String: Any], audioSettings: [String: Any], values: MediaEditorValues) { + self.videoSettings = videoSettings + self.audioSettings = audioSettings + self.values = values + } + + var timeRange: CMTimeRange? { + if let videoTrimRange = self.values.videoTrimRange { + return CMTimeRange(start: CMTime(seconds: videoTrimRange.lowerBound, preferredTimescale: 1), end: CMTime(seconds: videoTrimRange.upperBound, preferredTimescale: 1)) + } else { + return nil + } + } + + var dimensions: CGSize { + if let width = self.videoSettings[AVVideoWidthKey] as? Int, let height = self.videoSettings[AVVideoHeightKey] as? Int { + return CGSize(width: width, height: height) + } else { + return CGSize(width: 1920.0, height: 1080.0) + } + } + } + + public enum Status { + case idle + case paused + case exporting + case finished + } + + public enum ExportError { + case noTracksFound + case addVideoOutput + case addAudioOutput + case writing(Error?) + case reading(Error?) + case invalid + case cancelled + } + + public enum ExportStatus { + case unknown + case progress(Double) + case completed + case failed(ExportError) + } + + public private(set) var internalStatus: Status = .idle + + private let subject: Subject + private let configuration: Configuration + private let outputPath: String + + private var previousSampleTime: CMTime = .zero + private var processedPixelBuffer: CVPixelBuffer? + + private var reader: AVAssetReader? + + private var videoOutput: AVAssetReaderOutput? + private var audioOutput: AVAssetReaderAudioMixOutput? + private let queue = Queue() + + private var writer: MediaEditorVideoExportWriter? + private var composer: MediaEditorComposer? + + private let duration = ValuePromise() + + private let pauseDispatchGroup = DispatchGroup() + private var cancelled = false + + private var startTimestamp = CACurrentMediaTime() + + public init(subject: Subject, configuration: Configuration, outputPath: String) { + self.subject = subject + self.configuration = configuration + self.outputPath = outputPath + + self.setup() + } + + private func setup() { + if case let .video(asset) = self.subject { + if let trimmedVideoDuration = self.configuration.timeRange?.duration { + self.duration.set(trimmedVideoDuration) + } else { + asset.loadValuesAsynchronously(forKeys: ["tracks", "duration"]) { + self.duration.set(asset.duration) + } + } + } else { + self.duration.set(CMTime(seconds: 3, preferredTimescale: 1)) + } + + if self.configuration.values.requiresComposing { + self.composer = MediaEditorComposer(values: self.configuration.values, dimensions: self.configuration.dimensions) + } + self.setupVideoInput() + } + + private func setupVideoInput() { + guard case let .video(asset) = self.subject else { + return + } + + self.reader = try? AVAssetReader(asset: asset) + guard let reader = self.reader else { + return + } + if let timeRange = self.configuration.timeRange { + reader.timeRange = timeRange + } + + self.writer = MediaEditorVideoAVAssetWriter() + guard let writer = self.writer else { + return + } + + writer.setup(configuration: self.configuration, outputPath: self.outputPath) + + let videoTracks = asset.tracks(withMediaType: .video) + if (videoTracks.count > 0) { + let videoOutput: AVAssetReaderOutput + let inputTransform: CGAffineTransform? + if self.composer == nil { + videoOutput = AVAssetReaderTrackOutput(track: videoTracks.first!, outputSettings: [kCVPixelBufferPixelFormatTypeKey as String: [kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange]]) + inputTransform = videoTracks.first!.preferredTransform + } else { + videoOutput = AVAssetReaderTrackOutput(track: videoTracks.first!, outputSettings: [kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA]) + inputTransform = nil + } + + videoOutput.alwaysCopiesSampleData = false + if reader.canAdd(videoOutput) { + reader.add(videoOutput) + } else { + self.internalStatus = .finished + self.statusValue = .failed(.addVideoOutput) + } + self.videoOutput = videoOutput + + writer.setupVideoInput(configuration: self.configuration, inputTransform: inputTransform) + } else { + self.videoOutput = nil + } + + let audioTracks = asset.tracks(withMediaType: .audio) + if audioTracks.count > 0 { + let audioOutput = AVAssetReaderAudioMixOutput(audioTracks: audioTracks, audioSettings: nil) + audioOutput.alwaysCopiesSampleData = false + if reader.canAdd(audioOutput) { + reader.add(audioOutput) + } else { + self.internalStatus = .finished + self.statusValue = .failed(.addAudioOutput) + } + self.audioOutput = audioOutput + + writer.setupAudioInput(configuration: self.configuration) + } else { + self.audioOutput = nil + } + + if videoTracks.count == 0 && audioTracks.count == 0 { + self.internalStatus = .finished + self.statusValue = .failed(.noTracksFound) + } + } + + private func finish() { + assert(self.queue.isCurrent()) + + guard let reader = self.reader, let writer = self.writer else { + return + } + + let outputUrl = URL(fileURLWithPath: self.outputPath) + + if reader.status == .cancelled || writer.status == .cancelled { + if writer.status != .cancelled { + writer.cancelWriting() + } + if reader.status != .cancelled { + reader.cancelReading() + } + try? FileManager().removeItem(at: outputUrl) + self.internalStatus = .finished + self.statusValue = .failed(.cancelled) + return + } + + if writer.status == .failed { + try? FileManager().removeItem(at: outputUrl) + self.internalStatus = .finished + self.statusValue = .failed(.writing(nil)) + } else if reader.status == .failed { + try? FileManager().removeItem(at: outputUrl) + writer.cancelWriting() + self.internalStatus = .finished + self.statusValue = .failed(.reading(reader.error)) + } else { + writer.finishWriting { + self.queue.async { + if writer.status == .failed { + try? FileManager().removeItem(at: outputUrl) + self.internalStatus = .finished + self.statusValue = .failed(.writing(nil)) + } else { + self.internalStatus = .finished + self.statusValue = .completed + + let end = CACurrentMediaTime() + let _ = (self.duration.get() + |> take(1)).start(next: { duration in + let exportDuration = end - self.startTimestamp + print("video processing took \(exportDuration)s") + if duration.seconds > 0 { + print("\(exportDuration / duration.seconds) speed") + } + }) + } + } + } + } + } + + private func encodeVideo() -> Bool { + guard let reader = self.reader, let writer = self.writer, let output = self.videoOutput else { + return false + } + + while writer.isReadyForMoreVideoData { + let _ = self.composer?.semaphore.wait(timeout: .distantFuture) + + if reader.status != .reading || writer.status != .writing { + writer.markVideoAsFinished() + return false + } + self.pauseDispatchGroup.wait() + if let buffer = output.copyNextSampleBuffer() { + if let pixelBuffer = self.composer?.processSampleBuffer(buffer, pool: writer.pixelBufferPool) { + let timestamp = CMSampleBufferGetPresentationTimeStamp(buffer) + if !writer.appendPixelBuffer(pixelBuffer, at: timestamp) { + writer.markVideoAsFinished() + return false + } + } else { + if !writer.appendVideoBuffer(buffer) { + writer.markVideoAsFinished() + return false + } + } + +// let progress = (CMSampleBufferGetPresentationTimeStamp(buffer) - self.configuration.timeRange.start).seconds/self.duration.seconds +// if self.videoOutput === output { +// self.dispatchProgressCallback { $0.updateVideoEncodingProgress(fractionCompleted: progress) } +// } +// if self.audioOutput === output { +// self.dispatchProgressCallback { $0.updateAudioEncodingProgress(fractionCompleted: progress) } +// } + + } else { +// if self.videoOutput === output { +// self.dispatchProgressCallback { $0.updateVideoEncodingProgress(fractionCompleted: 1) } +// } +// if self.audioOutput === output { +// self.dispatchProgressCallback { $0.updateAudioEncodingProgress(fractionCompleted: 1) } +// } + writer.markVideoAsFinished() + return false + } + } + return true + } + + private func encodeAudio() -> Bool { + guard let reader = self.reader, let writer = self.writer, let output = self.audioOutput else { + return false + } + + while writer.isReadyForMoreAudioData { + if reader.status != .reading || writer.status != .writing { + writer.markAudioAsFinished() + return false + } + self.pauseDispatchGroup.wait() + if let buffer = output.copyNextSampleBuffer() { +// let progress = (CMSampleBufferGetPresentationTimeStamp(buffer) - self.configuration.timeRange.start).seconds/self.duration.seconds +// if self.videoOutput === output { +// self.dispatchProgressCallback { $0.updateVideoEncodingProgress(fractionCompleted: progress) } +// } +// if self.audioOutput === output { +// self.dispatchProgressCallback { $0.updateAudioEncodingProgress(fractionCompleted: progress) } +// } + if !writer.appendVideoBuffer(buffer) { + writer.markAudioAsFinished() + return false + } + } else { +// if self.videoOutput === output { +// self.dispatchProgressCallback { $0.updateVideoEncodingProgress(fractionCompleted: 1) } +// } +// if self.audioOutput === output { +// self.dispatchProgressCallback { $0.updateAudioEncodingProgress(fractionCompleted: 1) } +// } + writer.markAudioAsFinished() + return false + } + } + return true + } + + func pause() { + guard self.internalStatus == .exporting && self.cancelled == false else { + return + } + self.internalStatus = .paused + self.pauseDispatchGroup.enter() + } + + func resume() { + guard self.internalStatus == .paused && self.cancelled == false else { + return + } + self.internalStatus = .exporting + self.pauseDispatchGroup.leave() + } + + public func cancel() { + if case .paused = self.internalStatus { + self.resume() + } + self.cancelled = true + } + + private let statusPromise = Promise(.unknown) + private var statusValue: ExportStatus = .unknown { + didSet { + self.statusPromise.set(.single(self.statusValue)) + } + } + public var status: Signal { + return self.statusPromise.get() + } + + public func startExport() { + guard self.internalStatus == .idle, let writer = self.writer, let reader = self.reader else { + self.statusValue = .failed(.invalid) + return + } + + guard writer.startWriting() else { + self.statusValue = .failed(.writing(nil)) + return + } + guard reader.startReading() else { + self.statusValue = .failed(.reading(nil)) + return + } + + self.internalStatus = .exporting + + writer.startSession(atSourceTime: self.configuration.timeRange?.start ?? .zero) + + var videoCompleted = false + var audioCompleted = false + + if let _ = self.videoOutput { + var sessionForVideoEncoder: MediaEditorVideoExport? = self + writer.requestVideoDataWhenReady(on: self.queue.queue) { + guard let session = sessionForVideoEncoder else { return } + if !session.encodeVideo() { + videoCompleted = true + sessionForVideoEncoder = nil + if audioCompleted { + session.finish() + } + } + } + } else { + videoCompleted = true + } + + if let _ = self.audioOutput { + var sessionForAudioEncoder: MediaEditorVideoExport? = self + writer.requestAudioDataWhenReady(on: self.queue.queue) { + guard let session = sessionForAudioEncoder else { return } + if !session.encodeAudio() { + audioCompleted = true + sessionForAudioEncoder = nil + if videoCompleted { + session.finish() + } + } + } + } else { + audioCompleted = true + } + } +} diff --git a/submodules/TelegramUI/Components/MediaEditor/Sources/VideoTextureSource.swift b/submodules/TelegramUI/Components/MediaEditor/Sources/VideoTextureSource.swift index d735506a46..a881a83f9f 100644 --- a/submodules/TelegramUI/Components/MediaEditor/Sources/VideoTextureSource.swift +++ b/submodules/TelegramUI/Components/MediaEditor/Sources/VideoTextureSource.swift @@ -117,7 +117,7 @@ final class VideoTextureSource: NSObject, TextureSource, AVPlayerItemOutputPullD let displayLink = CADisplayLink(target: DisplayLinkTarget({ [weak self] in self?.handleUpdate() }), selector: #selector(DisplayLinkTarget.handleDisplayLinkUpdate(sender:))) - displayLink.preferredFramesPerSecond = 30 + displayLink.preferredFramesPerSecond = 60 displayLink.add(to: .main, forMode: .common) self.displayLink = displayLink } @@ -173,14 +173,6 @@ final class VideoTextureSource: NSObject, TextureSource, AVPlayerItemOutputPullD } } - func start() { - - } - - func pause() { - - } - func connect(to consumer: TextureConsumer) { self.output = consumer } diff --git a/submodules/TelegramUI/Components/MediaEditorScreen/Sources/AdjustmentsComponent.swift b/submodules/TelegramUI/Components/MediaEditorScreen/Sources/AdjustmentsComponent.swift index 934bda3a10..212a12aa3d 100644 --- a/submodules/TelegramUI/Components/MediaEditorScreen/Sources/AdjustmentsComponent.swift +++ b/submodules/TelegramUI/Components/MediaEditorScreen/Sources/AdjustmentsComponent.swift @@ -2,6 +2,7 @@ import Foundation import UIKit import Display import ComponentFlow +import SwiftSignalKit import LegacyComponents import MediaEditor @@ -15,7 +16,9 @@ final class AdjustmentSliderComponent: Component { let startValue: Float let isEnabled: Bool let trackColor: UIColor? - let updateValue: (Float) -> Void + let displayValue: Bool + let valueUpdated: (Float) -> Void + let isTrackingUpdated: ((Bool) -> Void)? init( title: String, @@ -25,7 +28,9 @@ final class AdjustmentSliderComponent: Component { startValue: Float, isEnabled: Bool, trackColor: UIColor?, - updateValue: @escaping (Float) -> Void + displayValue: Bool, + valueUpdated: @escaping (Float) -> Void, + isTrackingUpdated: ((Bool) -> Void)? = nil ) { self.title = title self.value = value @@ -34,7 +39,9 @@ final class AdjustmentSliderComponent: Component { self.startValue = startValue self.isEnabled = isEnabled self.trackColor = trackColor - self.updateValue = updateValue + self.displayValue = displayValue + self.valueUpdated = valueUpdated + self.isTrackingUpdated = isTrackingUpdated } static func ==(lhs: AdjustmentSliderComponent, rhs: AdjustmentSliderComponent) -> Bool { @@ -59,6 +66,9 @@ final class AdjustmentSliderComponent: Component { if lhs.trackColor != rhs.trackColor { return false } + if lhs.displayValue != rhs.displayValue { + return false + } return true } @@ -81,6 +91,34 @@ final class AdjustmentSliderComponent: Component { func update(component: AdjustmentSliderComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { self.component = component self.state = state + + var internalIsTrackingUpdated: ((Bool) -> Void)? + if let isTrackingUpdated = component.isTrackingUpdated { + internalIsTrackingUpdated = { [weak self] isTracking in + if let self { + if isTracking { + self.sliderView?.bordered = true + } else { + Queue.mainQueue().after(0.1) { + self.sliderView?.bordered = false + } + } + isTrackingUpdated(isTracking) + let transition: Transition + if isTracking { + transition = .immediate + } else { + transition = .easeInOut(duration: 0.25) + } + if let titleView = self.title.view { + transition.setAlpha(view: titleView, alpha: isTracking ? 0.0 : 1.0) + } + if let valueView = self.value.view { + transition.setAlpha(view: valueView, alpha: isTracking ? 0.0 : 1.0) + } + } + } + } let sliderView: TGPhotoEditorSliderView if let current = self.sliderView { @@ -89,6 +127,7 @@ final class AdjustmentSliderComponent: Component { } else { sliderView = TGPhotoEditorSliderView() sliderView.backgroundColor = .clear + sliderView.startColor = UIColor(rgb: 0xffffff) sliderView.enablePanHandling = true sliderView.trackCornerRadius = 1.0 sliderView.lineSize = 2.0 @@ -102,6 +141,12 @@ final class AdjustmentSliderComponent: Component { self.sliderView = sliderView self.addSubview(sliderView) } + sliderView.interactionBegan = { + internalIsTrackingUpdated?(true) + } + sliderView.interactionEnded = { + internalIsTrackingUpdated?(false) + } if component.isEnabled { sliderView.alpha = 1.3 @@ -131,6 +176,34 @@ final class AdjustmentSliderComponent: Component { transition.setFrame(view: titleView, frame: CGRect(origin: CGPoint(x: 21.0, y: 0.0), size: titleSize)) } + let valueText: String + if component.displayValue { + if component.value > 0.005 { + valueText = String(format: "+%.2f", component.value) + } else if component.value < -0.005 { + valueText = String(format: "%.2f", component.value) + } else { + valueText = "" + } + } else { + valueText = "" + } + + let valueSize = self.value.update( + transition: .immediate, + component: AnyComponent( + Text(text: valueText, font: Font.with(size: 14.0, traits: .monospacedNumbers), color: UIColor(rgb: 0xf8d74a)) + ), + environment: {}, + containerSize: CGSize(width: 100.0, height: 100.0) + ) + if let valueView = self.value.view { + if valueView.superview == nil { + self.addSubview(valueView) + } + transition.setFrame(view: valueView, frame: CGRect(origin: CGPoint(x: availableSize.width - 21.0 - valueSize.width, y: 0.0), size: valueSize)) + } + return CGSize(width: availableSize.width, height: 52.0) } @@ -138,7 +211,7 @@ final class AdjustmentSliderComponent: Component { guard let component = self.component, let sliderView = self.sliderView else { return } - component.updateValue(Float(sliderView.value)) + component.valueUpdated(Float(sliderView.value)) } } @@ -165,13 +238,16 @@ final class AdjustmentsComponent: Component { let tools: [AdjustmentTool] let valueUpdated: (EditorToolKey, Float) -> Void + let isTrackingUpdated: (Bool) -> Void init( tools: [AdjustmentTool], - valueUpdated: @escaping (EditorToolKey, Float) -> Void + valueUpdated: @escaping (EditorToolKey, Float) -> Void, + isTrackingUpdated: @escaping (Bool) -> Void ) { self.tools = tools self.valueUpdated = valueUpdated + self.isTrackingUpdated = isTrackingUpdated } static func ==(lhs: AdjustmentsComponent, rhs: AdjustmentsComponent) -> Bool { @@ -205,6 +281,26 @@ final class AdjustmentsComponent: Component { self.state = state let valueUpdated = component.valueUpdated + let isTrackingUpdated: (EditorToolKey, Bool) -> Void = { [weak self] trackingTool, isTracking in + component.isTrackingUpdated(isTracking) + + if let self { + for i in 0 ..< component.tools.count { + let tool = component.tools[i] + if tool.key != trackingTool && i < self.toolViews.count { + if let view = self.toolViews[i].view { + let transition: Transition + if isTracking { + transition = .immediate + } else { + transition = .easeInOut(duration: 0.25) + } + transition.setAlpha(view: view, alpha: isTracking ? 0.0 : 1.0) + } + } + } + } + } var sizes: [CGSize] = [] for i in 0 ..< component.tools.count { @@ -228,8 +324,12 @@ final class AdjustmentsComponent: Component { startValue: tool.startValue, isEnabled: true, trackColor: nil, - updateValue: { value in + displayValue: true, + valueUpdated: { value in valueUpdated(tool.key, value) + }, + isTrackingUpdated: { isTracking in + isTrackingUpdated(tool.key, isTracking) } ) ), diff --git a/submodules/TelegramUI/Components/MediaEditorScreen/Sources/BlurComponent.swift b/submodules/TelegramUI/Components/MediaEditorScreen/Sources/BlurComponent.swift index ad47244fad..920d35c2c3 100644 --- a/submodules/TelegramUI/Components/MediaEditorScreen/Sources/BlurComponent.swift +++ b/submodules/TelegramUI/Components/MediaEditorScreen/Sources/BlurComponent.swift @@ -299,7 +299,8 @@ final class BlurComponent: Component { startValue: 0.0, isEnabled: state.value.mode != .off, trackColor: nil, - updateValue: { [weak state] value in + displayValue: false, + valueUpdated: { [weak state] value in if let state { valueUpdated(state.value.withUpdatedIntensity(value)) } diff --git a/submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreen.swift b/submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreen.swift index 46a663c39c..bd31a862cc 100644 --- a/submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreen.swift +++ b/submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreen.swift @@ -23,19 +23,25 @@ enum DrawingScreenType { case sticker } +private let muteButtonTag = GenericComponentViewTag() +private let saveButtonTag = GenericComponentViewTag() + final class MediaEditorScreenComponent: Component { typealias EnvironmentType = ViewControllerComponentContainer.Environment let context: AccountContext + let mediaEditor: MediaEditor? let openDrawing: (DrawingScreenType) -> Void let openTools: () -> Void init( context: AccountContext, + mediaEditor: MediaEditor?, openDrawing: @escaping (DrawingScreenType) -> Void, openTools: @escaping () -> Void ) { self.context = context + self.mediaEditor = mediaEditor self.openDrawing = openDrawing self.openTools = openTools } @@ -555,15 +561,19 @@ final class MediaEditorScreenComponent: Component { attachmentAction: nil, reactionAction: nil, audioRecorder: nil, - videoRecordingStatus: nil + videoRecordingStatus: nil, + displayGradient: false,//component.inputHeight != 0.0, + bottomInset: 0.0 //component.inputHeight != 0.0 ? 0.0 : bottomContentInset )), environment: {}, containerSize: CGSize(width: availableSize.width, height: 200.0) ) + var inputPanelOffset: CGFloat = 0.0 var inputPanelBottomInset: CGFloat = scrubberBottomInset if environment.inputHeight > 0.0 { inputPanelBottomInset = environment.inputHeight - environment.safeInsets.bottom + inputPanelOffset = inputPanelBottomInset } let inputPanelFrame = CGRect(origin: CGPoint(x: 0.0, y: availableSize.height - environment.safeInsets.bottom - inputPanelBottomInset - inputPanelSize.height - 3.0), size: inputPanelSize) if let inputPanelView = self.inputPanel.view { @@ -585,30 +595,40 @@ final class MediaEditorScreenComponent: Component { ), colors: ["__allcolors__": .white], size: CGSize(width: 33.0, height: 33.0) - ) + ).tagged(saveButtonTag) ), - action: { - guard let controller = environment.controller() as? MediaEditorScreen else { - return + action: { [weak self] in + if let view = self?.saveButton.findTaggedView(tag: saveButtonTag) as? LottieAnimationComponent.View { + view.playOnce() + } + if let controller = environment.controller() as? MediaEditorScreen { + controller.requestSave() } - controller.requestDismiss(animated: true) } )), environment: {}, containerSize: CGSize(width: 44.0, height: 44.0) ) let saveButtonFrame = CGRect( - origin: CGPoint(x: availableSize.width - 20.0 - saveButtonSize.width, y: environment.safeInsets.top + 20.0), + origin: CGPoint(x: availableSize.width - 20.0 - saveButtonSize.width, y: environment.safeInsets.top + 20.0 - inputPanelOffset), size: saveButtonSize ) if let saveButtonView = self.saveButton.view { if saveButtonView.superview == nil { + saveButtonView.layer.shadowOffset = CGSize(width: 0.0, height: 0.0) + saveButtonView.layer.shadowRadius = 2.0 + saveButtonView.layer.shadowColor = UIColor.black.cgColor + saveButtonView.layer.shadowOpacity = 0.25 self.addSubview(saveButtonView) } transition.setPosition(view: saveButtonView, position: saveButtonFrame.center) transition.setBounds(view: saveButtonView, bounds: CGRect(origin: .zero, size: saveButtonFrame.size)) + transition.setScale(view: saveButtonView, scale: self.inputPanelExternalState.isEditing ? 0.01 : 1.0) + transition.setAlpha(view: saveButtonView, alpha: self.inputPanelExternalState.isEditing ? 0.0 : 1.0) } + + let isVideoMuted = component.mediaEditor?.values.videoIsMuted ?? false let muteButtonSize = self.muteButton.update( transition: transition, component: AnyComponent(Button( @@ -616,33 +636,39 @@ final class MediaEditorScreenComponent: Component { LottieAnimationComponent( animation: LottieAnimationComponent.AnimationItem( name: "anim_storymute", - mode: .still(position: .begin), - range: nil + mode: .animating(loop: false), + range: isVideoMuted ? (0.0, 0.5) : (0.5, 1.0) ), colors: ["__allcolors__": .white], size: CGSize(width: 33.0, height: 33.0) - ) + ).tagged(muteButtonTag) ), - action: { - guard let controller = environment.controller() as? MediaEditorScreen else { - return + action: { [weak self, weak state] in + if let self, let mediaEditor = self.component?.mediaEditor { + mediaEditor.setVideoIsMuted(!mediaEditor.values.videoIsMuted) + state?.updated() } - controller.requestDismiss(animated: true) } )), environment: {}, containerSize: CGSize(width: 44.0, height: 44.0) ) let muteButtonFrame = CGRect( - origin: CGPoint(x: availableSize.width - 20.0 - muteButtonSize.width - 50.0, y: environment.safeInsets.top + 20.0), + origin: CGPoint(x: availableSize.width - 20.0 - muteButtonSize.width - 50.0, y: environment.safeInsets.top + 20.0 - inputPanelOffset), size: muteButtonSize ) if let muteButtonView = self.muteButton.view { if muteButtonView.superview == nil { + muteButtonView.layer.shadowOffset = CGSize(width: 0.0, height: 0.0) + muteButtonView.layer.shadowRadius = 2.0 + muteButtonView.layer.shadowColor = UIColor.black.cgColor + muteButtonView.layer.shadowOpacity = 0.25 self.addSubview(muteButtonView) } transition.setPosition(view: muteButtonView, position: muteButtonFrame.center) transition.setBounds(view: muteButtonView, bounds: CGRect(origin: .zero, size: muteButtonFrame.size)) + transition.setScale(view: muteButtonView, scale: self.inputPanelExternalState.isEditing ? 0.01 : 1.0) + transition.setAlpha(view: muteButtonView, alpha: self.inputPanelExternalState.isEditing ? 0.0 : 1.0) } return availableSize @@ -666,7 +692,7 @@ public final class MediaEditorScreen: ViewController { private let context: AccountContext private let initializationTimestamp = CACurrentMediaTime() - private var subject: MediaEditorScreen.Subject? + fileprivate var subject: MediaEditorScreen.Subject? private var subjectDisposable: Disposable? private let backgroundDimView: UIView @@ -806,7 +832,7 @@ public final class MediaEditorScreen: ViewController { private func setup(with subject: MediaEditorScreen.Subject) { self.subject = subject - guard let controller = self.controller else { + guard let _ = self.controller else { return } @@ -822,11 +848,24 @@ public final class MediaEditorScreen: ViewController { mediaEntity.scale = storyDimensions.width / fittedSize.width } self.entitiesView.add(mediaEntity, announce: false) + + let initialPosition = mediaEntity.position + let initialScale = mediaEntity.scale + let initialRotation = mediaEntity.rotation + if let entityView = self.entitiesView.getView(for: mediaEntity.uuid) as? DrawingMediaEntityView { entityView.previewView = self.previewView + entityView.updated = { [weak self, weak mediaEntity] in + if let self, let mediaEntity { + let rotationDelta = mediaEntity.rotation - initialRotation + let positionDelta = CGPoint(x: mediaEntity.position.x - initialPosition.x, y: mediaEntity.position.y - initialPosition.y) + let scaleDelta = mediaEntity.scale / initialScale + self.mediaEditor?.setCrop(offset: positionDelta, scale: scaleDelta, rotation: rotationDelta, mirroring: false) + } + } } - let mediaEditor = MediaEditor(context: controller.context, subject: subject.editorSubject) + let mediaEditor = MediaEditor(subject: subject.editorSubject, hasHistogram: true) mediaEditor.attachPreviewView(self.previewView) self.gradientColorsDisposable = mediaEditor.gradientColors.start(next: { [weak self] colors in @@ -977,6 +1016,7 @@ public final class MediaEditorScreen: ViewController { component: AnyComponent( MediaEditorScreenComponent( context: self.context, + mediaEditor: self.mediaEditor, openDrawing: { [weak self] mode in if let self { let controller = DrawingScreen(context: self.context, sourceHint: .storyEditor, size: self.previewContainerView.frame.size, originalSize: storyDimensions, isVideo: false, isAvatar: false, drawingView: self.drawingView, entitiesView: self.entitiesView, existingStickerPickerInputData: self.stickerPickerInputData) @@ -1132,9 +1172,9 @@ public final class MediaEditorScreen: ViewController { public var sourceHint: SourceHint? public var cancelled: () -> Void = {} - public var completion: (MediaEditorScreen.Result) -> Void = { _ in } + public var completion: (MediaEditorScreen.Result, @escaping () -> Void) -> Void = { _, _ in } - public init(context: AccountContext, subject: Signal, completion: @escaping (MediaEditorScreen.Result) -> Void) { + public init(context: AccountContext, subject: Signal, completion: @escaping (MediaEditorScreen.Result, @escaping () -> Void) -> Void) { self.context = context self.subject = subject self.completion = completion @@ -1160,8 +1200,8 @@ public final class MediaEditorScreen: ViewController { func requestDismiss(animated: Bool) { self.cancelled() - self.node.animateOut(completion: { - self.dismiss() + self.node.animateOut(completion: { [weak self] in + self?.dismiss() }) } @@ -1174,13 +1214,70 @@ public final class MediaEditorScreen: ViewController { } else { if let image = mediaEditor.resultImage { - self.completion(.image(image)) + self.completion(.image(image), { [weak self] in + self?.node.animateOut(completion: { [weak self] in + self?.dismiss() + }) + }) } } + } + + private var export: MediaEditorVideoExport? + private var exportDisposable: Disposable? + + func requestSave() { + guard let mediaEditor = self.node.mediaEditor, let subject = self.node.subject else { + return + } - self.node.animateOut(completion: { - self.dismiss() - }) + let tempVideoPath = NSTemporaryDirectory() + "\(Int64.random(in: Int64.min ... Int64.max)).mp4" + let saveToPhotos: (String, Bool) -> Void = { path, isVideo in + PHPhotoLibrary.shared().performChanges({ + if isVideo { + if let _ = try? FileManager.default.copyItem(atPath: path, toPath: tempVideoPath) { + PHAssetChangeRequest.creationRequestForAssetFromVideo(atFileURL: URL(fileURLWithPath: path)) + } + } else { + if let fileData = try? Data(contentsOf: URL(fileURLWithPath: path)) { + PHAssetCreationRequest.forAsset().addResource(with: .photo, data: fileData, options: nil) + } + } + }, completionHandler: { _, error in + if let error = error { + print("\(error)") + } + let _ = try? FileManager.default.removeItem(atPath: tempVideoPath) + }) + } + + if mediaEditor.resultIsVideo { + let exportSubject: MediaEditorVideoExport.Subject + if case let .video(path, _) = subject { + let asset = AVURLAsset(url: NSURL(fileURLWithPath: path) as URL) + exportSubject = .video(asset) + } else { + fatalError() + } + + let configuration = recommendedExportConfiguration(mediaEditor: mediaEditor) + let outputPath = NSTemporaryDirectory() + "\(Int64.random(in: 0 ..< .max)).mp4" + let export = MediaEditorVideoExport(subject: exportSubject, configuration: configuration, outputPath: outputPath) + self.export = export + + export.startExport() + + self.exportDisposable = (export.status + |> deliverOnMainQueue).start(next: { [weak self] status in + if let _ = self { + if case .completed = status { + saveToPhotos(outputPath, true) + } + } + }) + } else { + + } } override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { @@ -1189,3 +1286,30 @@ public final class MediaEditorScreen: ViewController { (self.displayNode as! Node).containerLayoutUpdated(layout: layout, transition: Transition(transition)) } } + + +private func recommendedExportConfiguration(mediaEditor: MediaEditor) -> MediaEditorVideoExport.Configuration { + let compressionProperties: [String: Any] = [ + AVVideoAverageBitRateKey: 2000000 + ] + + let videoSettings: [String: Any] = [ + AVVideoCodecKey: AVVideoCodecType.h264, + AVVideoCompressionPropertiesKey: compressionProperties, + AVVideoWidthKey: 1080, + AVVideoHeightKey: 1920 + ] + + let audioSettings: [String: Any] = [ + AVFormatIDKey: kAudioFormatMPEG4AAC, + AVSampleRateKey: 44100, + AVEncoderBitRateKey: 64000, + AVNumberOfChannelsKey: 2 + ] + + return MediaEditorVideoExport.Configuration( + videoSettings: videoSettings, + audioSettings: audioSettings, + values: mediaEditor.values + ) +} diff --git a/submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaToolsScreen.swift b/submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaToolsScreen.swift index df5a8638f7..a16f6630cc 100644 --- a/submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaToolsScreen.swift +++ b/submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaToolsScreen.swift @@ -18,7 +18,7 @@ import MessageInputPanelComponent private enum MediaToolsSection: Equatable { case adjustments - case highlights + case tint case blur case curves } @@ -154,7 +154,7 @@ private final class MediaToolsScreenComponent: Component { final class State: ComponentState { enum ImageKey: Hashable { case adjustments - case highlights + case tint case blur case curves case done @@ -168,7 +168,7 @@ private final class MediaToolsScreenComponent: Component { switch key { case .adjustments: image = UIImage(bundleImageName: "Media Editor/Tools")! - case .highlights: + case .tint: image = UIImage(bundleImageName: "Media Editor/Tint")! case .blur: image = UIImage(bundleImageName: "Media Editor/Blur")! @@ -216,13 +216,14 @@ private final class MediaToolsScreenComponent: Component { private let buttonsContainerView = UIView() private let cancelButton = ComponentView() private let adjustmentsButton = ComponentView() - private let highlightsButton = ComponentView() + private let tintButton = ComponentView() private let blurButton = ComponentView() private let curvesButton = ComponentView() private let doneButton = ComponentView() private let previewContainerView = UIView() private var optionsContainerView = UIView() + private var optionsBackgroundView = UIView() private var toolOptions = ComponentView() private var toolScreen: ComponentView? @@ -237,7 +238,7 @@ private final class MediaToolsScreenComponent: Component { self.previewContainerView.clipsToBounds = true self.optionsContainerView.clipsToBounds = true - self.optionsContainerView.backgroundColor = UIColor(rgb: 0x000000, alpha: 0.9) + self.optionsBackgroundView.backgroundColor = UIColor(rgb: 0x000000, alpha: 0.9) super.init(frame: frame) @@ -246,6 +247,7 @@ private final class MediaToolsScreenComponent: Component { self.addSubview(self.buttonsContainerView) self.addSubview(self.previewContainerView) self.previewContainerView.addSubview(self.optionsContainerView) + self.optionsContainerView.addSubview(self.optionsBackgroundView) } required init?(coder: NSCoder) { @@ -255,7 +257,7 @@ private final class MediaToolsScreenComponent: Component { func animateInFromEditor() { let buttons = [ self.adjustmentsButton, - self.highlightsButton, + self.tintButton, self.blurButton, self.curvesButton ] @@ -275,7 +277,7 @@ private final class MediaToolsScreenComponent: Component { view.layer.animateScale(from: 0.1, to: 1.0, duration: 0.2) } - self.optionsContainerView.layer.animatePosition(from: CGPoint(x: 0.0, y: self.optionsContainerView.frame.height), to: .zero, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, additive: true) + self.optionsContainerView.layer.animatePosition(from: CGPoint(x: 0.0, y: self.optionsContainerView.frame.height), to: .zero, duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, additive: true) } private var animatingOut = false @@ -284,7 +286,7 @@ private final class MediaToolsScreenComponent: Component { let buttons = [ self.adjustmentsButton, - self.highlightsButton, + self.tintButton, self.blurButton, self.curvesButton ] @@ -399,7 +401,7 @@ private final class MediaToolsScreenComponent: Component { component: AnyComponent(Button( content: AnyComponent(ToolIconComponent( icon: state.image(.adjustments), - isActive: false, + isActive: mediaEditor?.values.hasAdjustments ?? false, isSelected: component.section == .adjustments )), action: { @@ -420,30 +422,30 @@ private final class MediaToolsScreenComponent: Component { transition.setFrame(view: adjustmentsButtonView, frame: adjustmentsButtonFrame) } - let highlightsButtonSize = self.highlightsButton.update( + let tintButtonSize = self.tintButton.update( transition: transition, component: AnyComponent(Button( content: AnyComponent(ToolIconComponent( - icon: state.image(.highlights), - isActive: false, - isSelected: component.section == .highlights + icon: state.image(.tint), + isActive: mediaEditor?.values.hasTint ?? false, + isSelected: component.section == .tint )), action: { - sectionUpdated(.highlights) + sectionUpdated(.tint) } )), environment: {}, containerSize: CGSize(width: 40.0, height: 40.0) ) - let highlightsButtonFrame = CGRect( - origin: CGPoint(x: floorToScreenPixels(availableSize.width / 2.5 + 5.0 - highlightsButtonSize.width / 2.0), y: buttonBottomInset), - size: highlightsButtonSize + let tintButtonFrame = CGRect( + origin: CGPoint(x: floorToScreenPixels(availableSize.width / 2.5 + 5.0 - tintButtonSize.width / 2.0), y: buttonBottomInset), + size: tintButtonSize ) - if let highlightsButtonView = self.highlightsButton.view { - if highlightsButtonView.superview == nil { - self.buttonsContainerView.addSubview(highlightsButtonView) + if let tintButtonView = self.tintButton.view { + if tintButtonView.superview == nil { + self.buttonsContainerView.addSubview(tintButtonView) } - transition.setFrame(view: highlightsButtonView, frame: highlightsButtonFrame) + transition.setFrame(view: tintButtonView, frame: tintButtonFrame) } let blurButtonSize = self.blurButton.update( @@ -451,7 +453,7 @@ private final class MediaToolsScreenComponent: Component { component: AnyComponent(Button( content: AnyComponent(ToolIconComponent( icon: state.image(.blur), - isActive: false, + isActive: mediaEditor?.values.hasBlur ?? false, isSelected: component.section == .blur )), action: { @@ -477,7 +479,7 @@ private final class MediaToolsScreenComponent: Component { component: AnyComponent(Button( content: AnyComponent(ToolIconComponent( icon: state.image(.curves), - isActive: false, + isActive: mediaEditor?.values.hasCurves ?? false, isSelected: component.section == .curves )), action: { @@ -623,6 +625,17 @@ private final class MediaToolsScreenComponent: Component { controller.mediaEditor.setToolValue(key, value: value) state?.updated() } + }, + isTrackingUpdated: { [weak self] isTracking in + if let self { + let transition: Transition + if isTracking { + transition = .immediate + } else { + transition = .easeInOut(duration: 0.25) + } + transition.setAlpha(view: self.optionsBackgroundView, alpha: isTracking ? 0.0 : 1.0) + } } )), environment: {}, @@ -630,7 +643,7 @@ private final class MediaToolsScreenComponent: Component { ) screenSize = previewContainerFrame.size self.toolScreen = nil - case .highlights: + case .tint: self.curvesState = nil optionsSize = self.toolOptions.update( transition: optionsTransition, @@ -763,6 +776,7 @@ private final class MediaToolsScreenComponent: Component { size: optionsSize ) transition.setFrame(view: self.optionsContainerView, frame: optionsBackgroundFrame) + transition.setFrame(view: self.optionsBackgroundView, frame: CGRect(origin: .zero, size: optionsBackgroundFrame.size)) if let toolScreen = toolScreen { let screenFrame = CGRect(origin: .zero, size: screenSize) diff --git a/submodules/TelegramUI/Components/MediaEditorScreen/Sources/TintComponent.swift b/submodules/TelegramUI/Components/MediaEditorScreen/Sources/TintComponent.swift index 8276e9f3d1..cf8a3dcdcd 100644 --- a/submodules/TelegramUI/Components/MediaEditorScreen/Sources/TintComponent.swift +++ b/submodules/TelegramUI/Components/MediaEditorScreen/Sources/TintComponent.swift @@ -311,7 +311,8 @@ final class TintComponent: Component { startValue: 0.0, isEnabled: currentColor != .clear, trackColor: currentColor != .clear ? currentColor : .white, - updateValue: { [weak state] value in + displayValue: false, + valueUpdated: { [weak state] value in if let state { switch state.section { case .shadows: 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 06f2ba1cb4..0573e08853 100644 --- a/submodules/TelegramUI/Components/MessageInputPanelComponent/Sources/MessageInputPanelComponent.swift +++ b/submodules/TelegramUI/Components/MessageInputPanelComponent/Sources/MessageInputPanelComponent.swift @@ -35,6 +35,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, @@ -49,7 +51,9 @@ public final class MessageInputPanelComponent: Component { attachmentAction: (() -> Void)?, reactionAction: ((UIView) -> Void)?, audioRecorder: ManagedAudioRecorder?, - videoRecordingStatus: InstantVideoControllerRecordingStatus? + videoRecordingStatus: InstantVideoControllerRecordingStatus?, + displayGradient: Bool, + bottomInset: CGFloat ) { self.externalState = externalState self.context = context @@ -64,6 +68,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 { @@ -91,6 +97,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 } @@ -99,8 +111,12 @@ public final class MessageInputPanelComponent: Component { } public final class View: UIView { - private let fieldBackgroundView: UIImageView - private let fieldBackgroundEffectView: UIVisualEffectView + 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() @@ -120,10 +136,23 @@ public final class MessageInputPanelComponent: Component { private weak var state: EmptyComponentState? override init(frame: CGRect) { - self.fieldBackgroundView = UIImageView() - self.fieldBackgroundEffectView = UIVisualEffectView() + 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) } required init?(coder: NSCoder) { @@ -152,35 +181,47 @@ public final class MessageInputPanelComponent: Component { } func update(component: MessageInputPanelComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { - let baseHeight: CGFloat = 44.0 - var insets = UIEdgeInsets(top: 5.0, left: 7.0, bottom: 5.0, right: 7.0) + var insets = UIEdgeInsets(top: 14.0, left: 7.0, bottom: 6.0, right: 7.0) if let _ = component.attachmentAction { insets.left = 41.0 } if let _ = component.setMediaRecordingActive { insets.right = 41.0 } - let fieldCornerRadius: CGFloat = 16.0 + let baseFieldHeight: CGFloat = 40.0 self.component = component self.state = state + + let hasMediaRecording = component.audioRecorder != nil || component.videoRecordingStatus != nil - var placeholderAlignment: NSTextAlignment - switch component.style { - case .story: - if self.fieldBackgroundView.superview == nil { - self.fieldBackgroundView.image = generateStretchableFilledCircleImage(diameter: fieldCornerRadius * 2.0, color: nil, strokeColor: UIColor(white: 1.0, alpha: 0.16), strokeWidth: 1.0, backgroundColor: nil) - self.insertSubview(self.fieldBackgroundView, at: 0) - } - placeholderAlignment = .natural - case .editor: - if self.fieldBackgroundEffectView.superview == nil { - self.fieldBackgroundEffectView.clipsToBounds = true - self.fieldBackgroundEffectView.layer.cornerRadius = fieldCornerRadius - self.fieldBackgroundEffectView.effect = UIBlurEffect(style: .dark) - self.insertSubview(self.fieldBackgroundEffectView, at: 0) - } - placeholderAlignment = .center + 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) @@ -190,8 +231,18 @@ public final class MessageInputPanelComponent: Component { transition: .immediate, component: AnyComponent(TextFieldComponent( externalState: self.textFieldExternalState, - placeholder: component.placeholder, - placeholderAlignment: placeholderAlignment + placeholder: "" + )), + environment: {}, + containerSize: availableTextFieldSize + ) + + let placeholderSize = self.placeholder.update( + transition: .immediate, + component: AnyComponent(Text( + text: component.placeholder, + font: Font.regular(17.0), + color: .white )), environment: {}, containerSize: availableTextFieldSize @@ -201,13 +252,33 @@ public final class MessageInputPanelComponent: Component { } 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.vibrancyEffectView, frame: CGRect(origin: CGPoint(), size: fieldFrame.size)) + transition.setAlpha(view: self.vibrancyEffectView, alpha: (component.audioRecorder != nil || component.videoRecordingStatus != nil) ? 0.0 : 1.0) + transition.setFrame(view: self.fieldBackgroundView, frame: fieldFrame) - transition.setAlpha(view: self.fieldBackgroundView, alpha: (component.audioRecorder != nil || component.videoRecordingStatus != nil) ? 0.0 : 1.0) + self.fieldBackgroundView.update(size: fieldFrame.size, cornerRadius: baseFieldHeight * 0.5, transition: transition.containedViewLayoutTransition) - transition.setFrame(view: self.fieldBackgroundEffectView, frame: fieldFrame) - transition.setAlpha(view: self.fieldBackgroundEffectView, alpha: (component.audioRecorder != nil || component.videoRecordingStatus != nil) ? 0.0 : 1.0) - - //let rightFieldInset: CGFloat = 34.0 + 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 placeholderOriginX: CGFloat + if self.textFieldExternalState.isEditing || component.style == .story { + placeholderOriginX = 16.0 + } else { + placeholderOriginX = floorToScreenPixels((availableSize.width - placeholderSize.width) / 2.0) + } + let placeholderFrame = CGRect(origin: CGPoint(x: placeholderOriginX, 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) @@ -230,15 +301,15 @@ public final class MessageInputPanelComponent: Component { action: { 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)) } } @@ -312,7 +383,7 @@ public final class MessageInputPanelComponent: Component { } else { inputActionButtonOriginX = size.width } - transition.setFrame(view: inputActionButtonView, frame: CGRect(origin: CGPoint(x: inputActionButtonOriginX, y: size.height - baseHeight + floorToScreenPixels((baseHeight - inputActionButtonSize.height) * 0.5)), size: inputActionButtonSize)) + transition.setFrame(view: inputActionButtonView, frame: CGRect(origin: CGPoint(x: inputActionButtonOriginX, y: size.height - insets.bottom - baseFieldHeight + floorToScreenPixels((baseFieldHeight - inputActionButtonSize.height) * 0.5)), size: inputActionButtonSize)) } var fieldIconNextX = fieldFrame.maxX - 2.0 @@ -342,7 +413,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 @@ -375,23 +446,18 @@ 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 || component.style == .editor ? 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 @@ -419,7 +485,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 64bf41fe96..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 { @@ -56,15 +35,21 @@ private final class StoryContainerScreenComponent: Component { let context: AccountContext let initialFocusedId: AnyHashable? let initialContent: [StoryContentItemSlice] + let transitionIn: StoryContainerScreen.TransitionIn? + let transitionOut: (EnginePeer.Id) -> StoryContainerScreen.TransitionOut? init( context: AccountContext, initialFocusedId: AnyHashable?, - initialContent: [StoryContentItemSlice] + initialContent: [StoryContentItemSlice], + 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 { @@ -78,32 +63,54 @@ 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 } } - 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? @@ -113,122 +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(UIPanGestureRecognizer(target: self, action: #selector(self.panGesture(_:)))) - - self.audioRecorderDisposable = (self.audioRecorder.get() - |> deliverOnMainQueue).start(next: { [weak self] audioRecorder 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 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) { @@ -236,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) { @@ -247,14 +179,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,1684 +258,97 @@ 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 + @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() } - self.layer.allowsGroupOpacity = false - }) + 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 { + continue + } + 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) { - 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 }) { @@ -2037,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 @@ -2082,12 +461,11 @@ 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 { return @@ -2120,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() } @@ -2141,55 +542,126 @@ 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) + 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)) - /*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 { + 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 { + 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 + + 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 +707,56 @@ 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 + } + } + + 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 public init( context: AccountContext, initialFocusedId: AnyHashable?, - initialContent: [StoryContentItemSlice] + initialContent: [StoryContentItemSlice], + transitionIn: TransitionIn?, + transitionOut: @escaping (EnginePeer.Id) -> TransitionOut? ) { self.context = context super.init(context: context, component: StoryContainerScreenComponent( context: context, initialFocusedId: initialFocusedId, - initialContent: initialContent + initialContent: initialContent, + 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 771bda5bd5..ebc5c4c493 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContent.swift +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContent.swift @@ -41,9 +41,11 @@ 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)? + public let markAsSeen: (() -> Void)? public let hasLike: Bool public let isMy: Bool @@ -53,9 +55,11 @@ public final class StoryContentItem { component: AnyComponent, centerInfoComponent: AnyComponent?, rightInfoComponent: AnyComponent?, - targetMessageId: EngineMessage.Id?, + peerId: EnginePeer.Id?, + storyItem: StoryListContext.Item?, preload: Signal?, delete: (() -> Void)?, + markAsSeen: (() -> Void)?, hasLike: Bool, isMy: Bool ) { @@ -64,9 +68,11 @@ 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 + 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 f126ef5672..4355b92fda 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,7 @@ 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 public let navigateToItemSet: (NavigationDirection) -> Void @@ -54,8 +55,7 @@ public final class StoryItemSetContainerComponent: Component { safeInsets: UIEdgeInsets, inputHeight: CGFloat, isProgressPaused: Bool, - audioRecorder: ManagedAudioRecorder?, - videoRecorder: InstantVideoController?, + hideUI: Bool, presentController: @escaping (ViewController) -> Void, close: @escaping () -> Void, navigateToItemSet: @escaping (NavigationDirection) -> Void, @@ -70,8 +70,7 @@ 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 self.navigateToItemSet = navigateToItemSet @@ -103,16 +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 { + 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) } @@ -122,7 +118,7 @@ public final class StoryItemSetContainerComponent: Component { } } - private struct ItemLayout { + struct ItemLayout { var size: CGSize init(size: CGSize) { @@ -130,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 @@ -140,7 +136,7 @@ public final class StoryItemSetContainerComponent: Component { } } - private final class InfoItem { + final class InfoItem { let component: AnyComponent let view = ComponentView() @@ -149,48 +145,58 @@ public final class StoryItemSetContainerComponent: Component { } } - public final class View: UIView, UIScrollViewDelegate { - private let scrollView: ScrollView + public final class View: UIView, UIScrollViewDelegate, UIGestureRecognizerDelegate { + 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() @@ -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,116 @@ 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) + 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) { @@ -244,15 +347,29 @@ 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 { + 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) { - 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) @@ -267,6 +384,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 +478,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,12 +508,12 @@ 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)) 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) } } } @@ -409,19 +532,152 @@ 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) } } } } + 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 { + 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) + } + + 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 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 @@ -431,6 +687,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 @@ -443,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 { @@ -490,6 +769,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 @@ -503,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 } @@ -528,22 +809,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 { @@ -563,20 +841,29 @@ 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) ) + 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 @@ -606,6 +893,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 { @@ -666,8 +955,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) @@ -716,7 +1010,7 @@ public final class StoryItemSetContainerComponent: Component { } self.contextController = contextController self.updateIsProgressPaused() - controller.presentInGlobalOverlay(contextController) + controller.present(contextController, in: .window(.root)) } )), environment: {}, @@ -727,7 +1021,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 @@ -739,16 +1033,17 @@ 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) 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? @@ -810,15 +1105,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 { + 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) } + + transition.setAlpha(view: view, alpha: component.hideUI ? 0.0 : 1.0) } } @@ -835,19 +1132,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 @@ -861,7 +1161,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 { @@ -918,23 +1220,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 } + )) + } + }) } } @@ -944,19 +1295,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) } } @@ -971,9 +1344,10 @@ 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: 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 @@ -1007,9 +1381,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 }) { @@ -1034,8 +1409,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: {}, @@ -1043,17 +1417,20 @@ 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 { + if self.sendMessageContext.audioRecorderValue != nil || self.sendMessageContext.videoRecorderValue != nil { inlineActionsAlpha = 0.0 } if self.reactionItems != nil { inlineActionsAlpha = 0.0 } + if component.hideUI { + inlineActionsAlpha = 0.0 + } transition.setAlpha(view: inlineActionsView, alpha: inlineActionsAlpha) } @@ -1064,6 +1441,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 { @@ -1088,6 +1549,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 fd75f34511..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) @@ -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..7f32ede4be 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), @@ -118,11 +37,18 @@ public enum StoryChatContent { peer: author )) }, - targetMessageId: nil, + peerId: itemSet.peerId, + 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/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/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) + } +} diff --git a/submodules/TelegramUI/Components/TextFieldComponent/Sources/TextFieldComponent.swift b/submodules/TelegramUI/Components/TextFieldComponent/Sources/TextFieldComponent.swift index e1799bbba1..230bbdf369 100644 --- a/submodules/TelegramUI/Components/TextFieldComponent/Sources/TextFieldComponent.swift +++ b/submodules/TelegramUI/Components/TextFieldComponent/Sources/TextFieldComponent.swift @@ -27,16 +27,13 @@ public final class TextFieldComponent: Component { public let externalState: ExternalState public let placeholder: String - public let placeholderAlignment: NSTextAlignment public init( externalState: ExternalState, - placeholder: String, - placeholderAlignment: NSTextAlignment + placeholder: String ) { self.externalState = externalState self.placeholder = placeholder - self.placeholderAlignment = placeholderAlignment } public static func ==(lhs: TextFieldComponent, rhs: TextFieldComponent) -> Bool { @@ -46,9 +43,6 @@ public final class TextFieldComponent: Component { if lhs.placeholder != rhs.placeholder { return false } - if lhs.placeholderAlignment != rhs.placeholderAlignment { - return false - } return true } @@ -79,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 @@ -151,7 +145,7 @@ public final class TextFieldComponent: Component { let placeholderSize = self.placeholder.update( transition: .immediate, - component: AnyComponent(Text(text: component.placeholder, font: Font.regular(17.0), color: UIColor(white: 1.0, alpha: 0.4))), + component: AnyComponent(Text(text: component.placeholder, font: Font.regular(17.0), color: UIColor(white: 1.0, alpha: 0.25))), environment: {}, containerSize: availableSize ) @@ -162,22 +156,7 @@ public final class TextFieldComponent: Component { self.insertSubview(placeholderView, belowSubview: self.textView) } - var placeholderAlignment = component.placeholderAlignment - if self.textView.isFirstResponder { - placeholderAlignment = .natural - } - let placeholderOriginX: CGFloat - switch placeholderAlignment { - case .left, .natural: - placeholderOriginX = self.textView.textContainerInset.left + 5.0 - case .center, .justified: - placeholderOriginX = floor((size.width - placeholderSize.width) / 2.0) - case .right: - placeholderOriginX = availableSize.width - self.textView.textContainerInset.left - 5.0 - placeholderSize.width - @unknown default: - placeholderOriginX = self.textView.textContainerInset.left + 5.0 - } - let placeholderFrame = CGRect(origin: CGPoint(x: placeholderOriginX, y: self.textView.textContainerInset.top), size: placeholderSize) + let placeholderFrame = CGRect(origin: CGPoint(x: self.textView.textContainerInset.left + 5.0, y: self.textView.textContainerInset.top), size: placeholderSize) placeholderView.bounds = CGRect(origin: CGPoint(), size: placeholderFrame.size) transition.setPosition(view: placeholderView, position: placeholderFrame.origin) 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 e8e3acba4b..70d9ca48ba 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 d3142a7524..26c0d0e6f1 100644 --- a/submodules/TelegramUI/Sources/TelegramRootController.swift +++ b/submodules/TelegramUI/Sources/TelegramRootController.swift @@ -22,6 +22,7 @@ import MediaEditorScreen import LegacyComponents import LegacyMediaPickerUI import LegacyCamera +import AvatarNode private class DetailsChatPlaceholderNode: ASDisplayNode, NavigationDetailsPlaceholderNode { private var presentationData: PresentationData @@ -278,16 +279,89 @@ public final class TelegramRootController: NavigationController { return .asset(asset) } } - let controller = MediaEditorScreen(context: context, subject: subject, completion: { result in - switch result { - case let .image(image): - if let data = image.jpegData(compressionQuality: 0.8) { - let _ = context.engine.messages.uploadStory(media: .image(dimensions: PixelDimensions(image.size), data: data)).start() - } - case .video: - break + let controller = MediaEditorScreen(context: context, subject: subject, completion: { mediaResult, commit in + enum AdditionalCategoryId: Int { + case everyone + case contacts + case closeFriends } - dismissCameraImpl?() + + 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 selectionController] result in + 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): + if let data = image.jpegData(compressionQuality: 0.8) { + let _ = context.engine.messages.uploadStory(media: .image(dimensions: PixelDimensions(image.size), data: data), privacy: privacy).start() + } + case .video: + break + } + dismissCameraImpl?() + commit() + }) }) controller.sourceHint = .camera controller.cancelled = { 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 {