diff --git a/submodules/ChatListUI/Sources/ChatListController.swift b/submodules/ChatListUI/Sources/ChatListController.swift index 2c489ddd03..2085255d79 100644 --- a/submodules/ChatListUI/Sources/ChatListController.swift +++ b/submodules/ChatListUI/Sources/ChatListController.swift @@ -273,9 +273,19 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController private let isReorderingTabsValue = ValuePromise(false) - private let navigationSecondaryContentNode: ASDisplayNode + let tabsNode: SparseNode private let tabContainerNode: ChatListFilterTabContainerNode private var tabContainerData: ([ChatListFilterTabEntry], Bool, Int32?)? + var hasTabs: Bool { + if let tabContainerData = self.tabContainerData { + let isEmpty = tabContainerData.0.count <= 1 || tabContainerData.1 + return !isEmpty + } else { + return false + } + } + + var searchTabsNode: SparseNode? private var hasDownloads: Bool = false private var activeDownloadsDisposable: Disposable? @@ -336,9 +346,9 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController groupCallPanelSource = .peer(peerId) } - self.navigationSecondaryContentNode = SparseNode() + self.tabsNode = SparseNode() self.tabContainerNode = ChatListFilterTabContainerNode() - self.navigationSecondaryContentNode.addSubnode(self.tabContainerNode) + self.tabsNode.addSubnode(self.tabContainerNode) self.storyListHeight = 0.0 @@ -435,10 +445,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController self.scrollToTop = { [weak self] in if let strongSelf = self { - /*if let searchContentNode = strongSelf.searchContentNode { - searchContentNode.updateExpansionProgress(1.0, animated: true) - }*/ - //TODO:scroll to top + strongSelf.chatListDisplayNode.willScrollToTop() strongSelf.chatListDisplayNode.scrollToTop() } } @@ -451,10 +458,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } else { switch strongSelf.chatListDisplayNode.effectiveContainerNode.currentItemNode.visibleContentOffset() { case .none, .unknown: - //TODO:scroll to top - /*if let searchContentNode = strongSelf.searchContentNode { - searchContentNode.updateExpansionProgress(1.0, animated: true) - }*/ + strongSelf.chatListDisplayNode.willScrollToTop() strongSelf.chatListDisplayNode.effectiveContainerNode.currentItemNode.scrollToPosition(.top) case let .known(offset): let isFirstFilter = strongSelf.chatListDisplayNode.effectiveContainerNode.currentItemNode.chatListFilter == strongSelf.chatListDisplayNode.mainContainerNode.availableFilters.first?.filter @@ -472,10 +476,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } strongSelf.selectTab(id: targetTab) } else { - //TODO:scroll to top - /*if let searchContentNode = strongSelf.searchContentNode { - searchContentNode.updateExpansionProgress(1.0, animated: true) - }*/ + strongSelf.chatListDisplayNode.willScrollToTop() if let inlineStackContainerNode = strongSelf.chatListDisplayNode.inlineStackContainerNode { inlineStackContainerNode.currentItemNode.scrollToPosition(.top) } else { @@ -781,19 +782,21 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController ))) ])) - let _ = contentComponent - //TODO:download indicator - /*strongSelf.searchContentNode?.placeholderNode.setAccessoryComponent(component: AnyComponent(Button( - content: contentComponent, - action: { - guard let strongSelf = self else { - return + if let navigationBarView = strongSelf.chatListDisplayNode.navigationBarView.view as? ChatListNavigationBar.View { + navigationBarView.searchContentNode?.placeholderNode.setAccessoryComponent(component: AnyComponent(Button( + content: contentComponent, + action: { + guard let strongSelf = self else { + return + } + strongSelf.activateSearch(filter: .downloads, query: nil) } - strongSelf.activateSearch(filter: .downloads, query: nil) - } - )))*/ + ))) + } } else { - //strongSelf.searchContentNode?.placeholderNode.setAccessoryComponent(component: nil) + if let navigationBarView = strongSelf.chatListDisplayNode.navigationBarView.view as? ChatListNavigationBar.View { + navigationBarView.searchContentNode?.placeholderNode.setAccessoryComponent(component: nil) + } } }) } @@ -1254,40 +1257,6 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController navigationController.filterController(strongSelf, animated: true) } - /*self.chatListDisplayNode.contentOffsetChanged = { [weak self] offset in - if let strongSelf = self, let validLayout = strongSelf.validLayout { - var offset = offset - if validLayout.inVoiceOver { - offset = .known(0.0) - } - - let offsetValue: CGFloat - switch offset { - case .none: - offsetValue = 0.0 - case let .known(value): - offsetValue = value - case .unknown: - offsetValue = 10000.0 - } - - if let navigationBarView = strongSelf.chatListDisplayNode.navigationBarView.view as? ChatListNavigationBar.View { - navigationBarView.applyScroll(offset: offsetValue, transition: .immediate) - } - } - }*/ - - self.chatListDisplayNode.contentScrollingEnded = { [weak self] listView in - let _ = self - /*if let strongSelf = self, let searchContentNode = strongSelf.searchContentNode { - return fixListNodeScrolling(listView, searchNode: searchContentNode) - } else { - return false - }*/ - //TODO:fix scrolling - return false - } - self.chatListDisplayNode.emptyListAction = { [weak self] _ in guard let strongSelf = self, let navigationController = strongSelf.navigationController as? NavigationController else { return @@ -2426,133 +2395,6 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } return (primaryContent, secondaryContent) - - /*let storiesFraction: CGFloat = 0.0 - //TODO:move to navigation bar - /*if let searchContentNode = self.searchContentNode, case .chatList(.root) = self.location { - if self.storyListHeight > 0.0 { - let fraction = navigationBarSearchContentHeight / searchContentNode.nominalHeight - - let fromLow: CGFloat = fraction - let toLow: CGFloat = 0.0 - let fromHigh: CGFloat = 1.0 - let toHigh: CGFloat = 1.0 - let visibleProgress: CGFloat = toLow + (searchContentNode.expansionProgress - fromLow) * (toHigh - toLow) / (fromHigh - fromLow) - storiesFraction = max(0.0, min(1.0, visibleProgress)) - } - }*/ - - let _ = self.headerContentView.update( - transition: Transition(transition), - component: AnyComponent(ChatListHeaderComponent( - sideInset: layout.safeInsets.left + 16.0, - primaryContent: primaryContent, - secondaryContent: secondaryContent, - secondaryTransition: self.chatListDisplayNode.inlineStackContainerTransitionFraction, - networkStatus: nil, - storySubscriptions: self.storySubscriptions, - storiesFraction: storiesFraction, - context: self.context, - theme: self.presentationData.theme, - strings: self.presentationData.strings, - openStatusSetup: { [weak self] sourceView in - guard let self else { - return - } - self.openStatusSetup(sourceView: sourceView) - }, - toggleIsLocked: { [weak self] in - guard let self else { - return - } - self.context.sharedContext.appLockContext.lock() - } - )), - environment: {}, - containerSize: CGSize(width: layout.size.width, height: 44.0) - ) - if let componentView = self.headerContentView.view as? NavigationBarHeaderView { - if self.navigationBar?.customHeaderContentView !== componentView { - self.navigationBar?.customHeaderContentView = componentView - } - } - if case .chatList(.root) = self.location { - if let componentView = self.headerContentView.view as? ChatListHeaderComponent.View { - componentView.storyPeerAction = { [weak self] peer in - guard let self else { - return - } - - let storyContent = StoryContentContextImpl(context: self.context, focusedPeerId: peer?.id) - let _ = (storyContent.state - |> take(1) - |> deliverOnMainQueue).start(next: { [weak self] storyContentState in - guard let self else { - return - } - if let peer, peer.id == self.context.account.peerId, storyContentState.slice == nil { - if let rootController = self.context.sharedContext.mainWindow?.viewController as? TelegramRootControllerInterface { - let coordinator = rootController.openStoryCamera(transitionIn: nil, transitionedIn: {}, transitionOut: { [weak self] finished in - guard let self else { - return nil - } - if finished, let componentView = self.headerContentView.view as? ChatListHeaderComponent.View { - if let transitionView = componentView.storyPeerListView()?.transitionViewForItem(peerId: self.context.account.peerId) { - return StoryCameraTransitionOut( - destinationView: transitionView, - destinationRect: transitionView.bounds, - destinationCornerRadius: transitionView.bounds.height * 0.5 - ) - } - } - return nil - }) - coordinator?.animateIn() - } - - return - } - - var transitionIn: StoryContainerScreen.TransitionIn? - if let peer, let componentView = self.headerContentView.view as? ChatListHeaderComponent.View { - if let transitionView = componentView.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, - content: storyContent, - transitionIn: transitionIn, - transitionOut: { [weak self] peerId, _ in - guard let self else { - return nil - } - - if let componentView = self.headerContentView.view as? ChatListHeaderComponent.View { - if let transitionView = componentView.storyPeerListView()?.transitionViewForItem(peerId: peerId) { - return StoryContainerScreen.TransitionOut( - destinationView: transitionView, - destinationRect: transitionView.bounds, - destinationCornerRadius: transitionView.bounds.height * 0.5, - destinationIsAvatar: true, - completed: {} - ) - } - } - - return nil - } - ) - self.push(storyContainerScreen) - }) - } - } - }*/ } override public func updateNavigationBarLayout(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { @@ -2569,17 +2411,16 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { - //TODO:move to chat list node - /*if case .chatList(.root) = self.location, !self.isSearchActive { - self.searchContentNode?.additionalHeight = (1.0 - self.chatListDisplayNode.inlineStackContainerTransitionFraction) * self.storyListHeight - }*/ - super.containerLayoutUpdated(layout, transition: transition) let wasInVoiceOver = self.validLayout?.inVoiceOver ?? false self.validLayout = layout + if let searchTabsNode = self.searchTabsNode { + searchTabsNode.bounds.origin = CGPoint(x: 0.0, y: (layout.statusBarHeight ?? 0.0) + navigationBarSearchContentHeight + 44.0) + } + self.updateLayout(layout: layout, transition: transition) if layout.inVoiceOver != wasInVoiceOver { @@ -2613,7 +2454,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } if let rootController = self.context.sharedContext.mainWindow?.viewController as? TelegramRootControllerInterface { - rootController.openStoryCamera(transitionIn: cameraTransitionIn, transitionOut: { [weak self] _ in + rootController.openStoryCamera(transitionIn: cameraTransitionIn, transitionedIn: {}, transitionOut: { [weak self] _ in guard let self else { return nil } @@ -2721,12 +2562,10 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController tabContainerOffset += 44.0 + 20.0 } - let navigationBarHeight: CGFloat = 100.0//self.navigationBar?.frame.maxY ?? 0.0 - let secondaryContentHeight = self.navigationBar?.secondaryContentHeight ?? 0.0 + let navigationBarHeight: CGFloat = 0.0//self.navigationBar?.frame.maxY ?? 0.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))) + transition.updateFrame(node: self.tabContainerNode, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: layout.size.width, height: 46.0))) 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)) @@ -3175,11 +3014,6 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController let animated = strongSelf.didSetupTabs strongSelf.didSetupTabs = true - - if strongSelf.displayNavigationBar { - strongSelf.navigationBar?.secondaryContentHeight = (!isEmpty ? NavigationBar.defaultSecondaryContentHeight : 0.0) - strongSelf.navigationBar?.setSecondaryContentNode(strongSelf.navigationSecondaryContentNode, animated: false) - } if let layout = strongSelf.validLayout { if wasEmpty != isEmpty { @@ -3557,9 +3391,11 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } else { tabsIsEmpty = true } + let _ = tabsIsEmpty + //TODO:swap tabs let displaySearchFilters = true - if !tabsIsEmpty, let snapshotView = strongSelf.tabContainerNode.view.snapshotView(afterScreenUpdates: false) { + /*if !tabsIsEmpty, let snapshotView = strongSelf.tabContainerNode.view.snapshotView(afterScreenUpdates: false) { snapshotView.frame = strongSelf.navigationSecondaryContentNode.frame strongSelf.navigationSecondaryContentNode.view.superview?.addSubview(snapshotView) @@ -3567,14 +3403,14 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController snapshotView?.removeFromSuperview() }) snapshotView.layer.animatePosition(from: CGPoint(), to: CGPoint(x: 0.0, y: -strongSelf.storyListHeight), duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false) - } + }*/ 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 { - //TODO:search filters - strongSelf.navigationBar?.secondaryContentHeight = NavigationBar.defaultSecondaryContentHeight - strongSelf.navigationBar?.setSecondaryContentNode(filterContainerNode, animated: false) + let searchTabsNode = SparseNode() + strongSelf.searchTabsNode = searchTabsNode + searchTabsNode.addSubnode(filterContainerNode) } activate(filter != .downloads) @@ -3582,13 +3418,6 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController if let searchContentNode = strongSelf.chatListDisplayNode.searchDisplayController?.contentNode as? ChatListSearchContainerNode { searchContentNode.search(filter: filter, query: query) } - - let tabsOffset = 30.0 + strongSelf.storyListHeight - - Queue.mainQueue().justDispatch { - filterContainerNode.layer.animatePosition(from: CGPoint(x: 0.0, y: tabsOffset), to: CGPoint(), duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, additive: true) - filterContainerNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3) - } } let transition: ContainedViewLayoutTransition = .animated(duration: 0.4, curve: .spring) @@ -3617,7 +3446,9 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController if !self.displayNavigationBar { var completion: (() -> Void)? - let tabsIsEmpty: Bool + self.searchTabsNode = nil + + /*let tabsIsEmpty: Bool if let (resolvedItems, displayTabsAtBottom, _) = self.tabContainerData { tabsIsEmpty = resolvedItems.count <= 1 || displayTabsAtBottom } else { @@ -3627,11 +3458,6 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController var filterContainerNode: ASDisplayNode? - var searchContentNode: NavigationBarSearchContentNode? - if let navigationBarView = self.chatListDisplayNode.navigationBarView.view as? ChatListNavigationBar.View { - searchContentNode = navigationBarView.searchContentNode - } - if animated, let searchContentNode = self.chatListDisplayNode.searchDisplayController?.contentNode as? ChatListSearchContainerNode { filterContainerNode = searchContentNode.filterContainerNode @@ -3651,6 +3477,11 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } } } + }*/ + + var searchContentNode: NavigationBarSearchContentNode? + if let navigationBarView = self.chatListDisplayNode.navigationBarView.view as? ChatListNavigationBar.View { + searchContentNode = navigationBarView.searchContentNode } if let searchContentNode { @@ -3664,15 +3495,10 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController self.requestLayout(transition: .animated(duration: 0.5, curve: .spring)) - self.navigationBar?.secondaryContentHeight = (!tabsIsEmpty ? NavigationBar.defaultSecondaryContentHeight : 0.0) - //TODO:move layout to navigation bar - /*if case .chatList(.root) = self.location { - self.searchContentNode?.additionalHeight = self.storyListHeight - }*/ - self.navigationBar?.setSecondaryContentNode(self.navigationSecondaryContentNode, animated: false) + //TODO:swap tabs let transition: ContainedViewLayoutTransition = animated ? .animated(duration: 0.4, curve: .spring) : .immediate - transition.updateAlpha(node: self.tabContainerNode, alpha: tabsIsEmpty ? 0.0 : 1.0) + //transition.updateAlpha(node: self.tabContainerNode, alpha: tabsIsEmpty ? 0.0 : 1.0) self.setDisplayNavigationBar(true, transition: transition) completion?() @@ -5049,7 +4875,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController guard let self else { return nil } - if finished, let componentView = self.headerContentView.view as? ChatListHeaderComponent.View { + if finished, let componentView = self.chatListHeaderView() { if let transitionView = componentView.storyPeerListView()?.transitionViewForItem(peerId: self.context.account.peerId) { return StoryCameraTransitionOut( destinationView: transitionView, @@ -5659,7 +5485,7 @@ private final class ChatListLocationContext { self.ready.set(.single(true)) } - self.parentController?.requestLayout(transition: .immediate) + self.parentController?.requestLayout(transition: .animated(duration: 0.45, curve: .spring)) } private func updateForum( @@ -5751,7 +5577,7 @@ private final class ChatListLocationContext { navigationController.replaceController(parentController, with: chatController, animated: true) } } else { - self.parentController?.requestLayout(transition: .immediate) + self.parentController?.requestLayout(transition: .animated(duration: 0.45, curve: .spring)) } } diff --git a/submodules/ChatListUI/Sources/ChatListControllerNode.swift b/submodules/ChatListUI/Sources/ChatListControllerNode.swift index e97b549461..0998f55f87 100644 --- a/submodules/ChatListUI/Sources/ChatListControllerNode.swift +++ b/submodules/ChatListUI/Sources/ChatListControllerNode.swift @@ -748,6 +748,8 @@ public final class ChatListContainerNode: ASDisplayNode, UIGestureRecognizerDele private let filterEmptyAction: (ChatListFilter?) -> Void private let secondaryEmptyAction: () -> Void + fileprivate var onStoriesLockedUpdated: ((Bool) -> Void)? + fileprivate var onFilterSwitch: (() -> Void)? private var presentationData: PresentationData @@ -770,6 +772,10 @@ public final class ChatListContainerNode: ASDisplayNode, UIGestureRecognizerDele private var filtersLimit: Int32? = nil private var selectedId: ChatListFilterTabEntryId + var storiesUnlocked: Bool = false + + var initialScrollingOffset: CGFloat? + public private(set) var transitionFraction: CGFloat = 0.0 private var transitionFractionOffset: CGFloat = 0.0 private var disableItemNodeOperationsWhileAnimating: Bool = false @@ -806,6 +812,9 @@ public final class ChatListContainerNode: ASDisplayNode, UIGestureRecognizerDele return self.currentItemNode.chatListFilter.flatMap { .filter($0.id) } ?? .all } + private var didSetupContentOffset = false + private var isSettingUpContentOffset = false + private func applyItemNodeAsCurrent(id: ChatListFilterTabEntryId, itemNode: ChatListContainerItemNode) { if let previousItemNode = self.currentItemNodeValue { previousItemNode.listNode.activateSearch = nil @@ -824,6 +833,7 @@ public final class ChatListContainerNode: ASDisplayNode, UIGestureRecognizerDele previousItemNode.listNode.updatePeerGrouping = nil previousItemNode.listNode.contentOffsetChanged = nil previousItemNode.listNode.contentScrollingEnded = nil + previousItemNode.listNode.endedInteractiveDragging = { _ in } previousItemNode.listNode.activateChatPreview = nil previousItemNode.listNode.openStories = nil previousItemNode.listNode.addedVisibleChatsWithPeerIds = nil @@ -876,15 +886,98 @@ public final class ChatListContainerNode: ASDisplayNode, UIGestureRecognizerDele itemNode.listNode.updatePeerGrouping = { [weak self] peerId, group in self?.updatePeerGrouping?(peerId, group) } - itemNode.listNode.contentOffsetChanged = { [weak self] offset in - guard let self else { + itemNode.listNode.contentOffsetChanged = { [weak self, weak itemNode] offset in + guard let self, let itemNode else { + return + } + if self.isSettingUpContentOffset { + return + } + + if !self.didSetupContentOffset, let initialScrollingOffset = self.initialScrollingOffset { + self.initialScrollingOffset = nil + self.didSetupContentOffset = true + self.isSettingUpContentOffset = true + + let _ = itemNode.listNode.scrollToOffsetFromTop(initialScrollingOffset, animated: false) + + let offset = itemNode.listNode.visibleContentOffset() + self.contentOffset = offset + self.contentOffsetChanged?(offset) + + self.isSettingUpContentOffset = false return } self.contentOffset = offset self.contentOffsetChanged?(offset) + + if itemNode.listNode.isTracking { + if case let .known(value) = offset { + if !self.storiesUnlocked { + if value < -1.0 { + self.storiesUnlocked = true + DispatchQueue.main.async { [weak self] in + guard let self else { + return + } + self.currentItemNode.ignoreStoryInsetAdjustment = true + self.onStoriesLockedUpdated?(true) + self.currentItemNode.ignoreStoryInsetAdjustment = false + } + } + } + } + } else { + switch offset { + case let .known(value): + if value >= 94.0 { + if self.storiesUnlocked { + self.storiesUnlocked = false + self.currentItemNode.stopScrolling() + + DispatchQueue.main.async { [weak self] in + guard let self else { + return + } + self.onStoriesLockedUpdated?(false) + } + } + } + default: + break + } + } + } + itemNode.listNode.endedInteractiveDragging = { [weak self] _ in + guard let self else { + return + } + switch self.currentItemNode.visibleContentOffset() { + case let .known(value): + if value > 94.0 { + if self.storiesUnlocked { + self.storiesUnlocked = false + self.currentItemNode.stopScrolling() + + DispatchQueue.main.async { [weak self] in + guard let self else { + return + } + self.onStoriesLockedUpdated?(false) + let _ = self.contentScrollingEnded?(self.currentItemNode) + } + } + } + default: + break + } } itemNode.listNode.contentScrollingEnded = { [weak self] listView in - return self?.contentScrollingEnded?(listView) ?? false + guard let self else { + return false + } + + return self.contentScrollingEnded?(listView) ?? false } itemNode.listNode.activateChatPreview = { [weak self] item, threadId, sourceNode, gesture, location in self?.activateChatPreview?(item, threadId, sourceNode, gesture, location) @@ -1059,8 +1152,15 @@ public final class ChatListContainerNode: ASDisplayNode, UIGestureRecognizerDele for (id, itemNode) in self.itemNodes { if id != selectedId { itemNode.emptyNode?.restartAnimation() + + if let controller = self.controller, let chatListDisplayNode = controller.displayNode as? ChatListControllerNode, let navigationBarComponentView = chatListDisplayNode.navigationBarView.view as? ChatListNavigationBar.View, let clippedScrollOffset = navigationBarComponentView.clippedScrollOffset { + let scrollOffset = max(0.0, clippedScrollOffset - navigationBarComponentView.effectiveStoriesInsetHeight) + + let _ = itemNode.listNode.scrollToOffsetFromTop(scrollOffset, animated: false) + } } } + if let presentationLayer = itemNode.layer.presentation() { self.transitionFraction = presentationLayer.frame.minX / layout.size.width self.transitionFractionOffset = self.transitionFraction @@ -1145,6 +1245,9 @@ public final class ChatListContainerNode: ASDisplayNode, UIGestureRecognizerDele directionIsToRight = translation.x > layout.size.width / 2.0 } } + + var applyNodeAsCurrent: ChatListFilterTabEntryId? + if let directionIsToRight = directionIsToRight { var updatedIndex = selectedIndex if directionIsToRight { @@ -1154,15 +1257,15 @@ public final class ChatListContainerNode: ASDisplayNode, UIGestureRecognizerDele } let switchToId = self.availableFilters[updatedIndex].id if switchToId != self.selectedId, let itemNode = self.itemNodes[switchToId] { + let _ = itemNode self.selectedId = switchToId - self.applyItemNodeAsCurrent(id: switchToId, itemNode: itemNode) + applyNodeAsCurrent = switchToId } } self.transitionFraction = 0.0 let transition: ContainedViewLayoutTransition = .animated(duration: 0.45, curve: .spring) self.disableItemNodeOperationsWhileAnimating = true self.update(layout: layout, navigationBarHeight: navigationBarHeight, visualNavigationHeight: visualNavigationHeight, originalNavigationHeight: originalNavigationHeight, cleanNavigationBarHeight: cleanNavigationBarHeight, insets: insets, isReorderingFilters: isReorderingFilters, isEditing: isEditing, inlineNavigationLocation: inlineNavigationLocation, inlineNavigationTransitionFraction: inlineNavigationTransitionFraction, storiesInset: storiesInset, transition: transition) - self.currentItemFilterUpdated?(self.currentItemFilter, self.transitionFraction, transition, false) DispatchQueue.main.async { self.disableItemNodeOperationsWhileAnimating = false if let (layout, navigationBarHeight, visualNavigationHeight, originalNavigationHeight, cleanNavigationBarHeight, insets, isReorderingFilters, isEditing, inlineNavigationLocation, inlineNavigationTransitionFraction, storiesInset) = self.validLayout { @@ -1171,6 +1274,11 @@ public final class ChatListContainerNode: ASDisplayNode, UIGestureRecognizerDele } self.controller?.storyCameraPanGestureEnded(transitionFraction: translation.x / layout.size.width, velocity: velocity.x) + + if let switchToId = applyNodeAsCurrent, let itemNode = self.itemNodes[switchToId] { + self.applyItemNodeAsCurrent(id: switchToId, itemNode: itemNode) + } + self.currentItemFilterUpdated?(self.currentItemFilter, self.transitionFraction, transition, false) } default: break @@ -1602,6 +1710,13 @@ final class ChatListControllerNode: ASDisplayNode, UIGestureRecognizerDelegate { } } + self.mainContainerNode.onStoriesLockedUpdated = { [weak self] isLocked in + guard let self else { + return + } + self.controller?.requestLayout(transition: .immediate) + } + let inlineContentPanRecognizer = InteractiveTransitionGestureRecognizer(target: self, action: #selector(self.inlineContentPanGesture(_:)), allowedDirections: { [weak self] _ in guard let strongSelf = self, strongSelf.inlineStackContainerNode != nil else { return [] @@ -1711,6 +1826,14 @@ final class ChatListControllerNode: ASDisplayNode, UIGestureRecognizerDelegate { private func updateNavigationBar(layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) -> (navigationHeight: CGFloat, storiesInset: CGFloat) { let headerContent = self.controller?.updateHeaderContent(layout: layout, transition: transition) + var tabsNode: ASDisplayNode? + + if let value = self.controller?.searchTabsNode { + tabsNode = value + } else if let value = self.controller?.tabsNode, self.controller?.hasTabs == true { + tabsNode = value + } + let navigationBarSize = self.navigationBarView.update( transition: Transition(transition), component: AnyComponent(ChatListNavigationBar( @@ -1720,10 +1843,12 @@ final class ChatListControllerNode: ASDisplayNode, UIGestureRecognizerDelegate { statusBarHeight: layout.statusBarHeight ?? 0.0, sideInset: layout.safeInsets.left, isSearchActive: self.isSearchDisplayControllerActive, + storiesUnlocked: self.mainContainerNode.storiesUnlocked, primaryContent: headerContent?.primaryContent, secondaryContent: headerContent?.secondaryContent, secondaryTransition: self.inlineStackContainerTransitionFraction, storySubscriptions: self.controller?.storySubscriptions, + tabsNode: tabsNode, activateSearch: { [weak self] searchContentNode in guard let self, let controller = self.controller else { return @@ -1767,7 +1892,7 @@ final class ChatListControllerNode: ASDisplayNode, UIGestureRecognizerDelegate { } } - private func updateNavigationScrolling(transition: ContainedViewLayoutTransition) { + private func getEffectiveNavigationScrollingOffset() -> CGFloat { let mainOffset: CGFloat if let contentOffset = self.mainContainerNode.contentOffset, case let .known(value) = contentOffset { mainOffset = value @@ -1789,8 +1914,17 @@ final class ChatListControllerNode: ASDisplayNode, UIGestureRecognizerDelegate { resultingOffset = mainOffset } + return resultingOffset + } + + private func updateNavigationScrolling(transition: ContainedViewLayoutTransition) { + var offset = self.getEffectiveNavigationScrollingOffset() + if self.isSearchDisplayControllerActive { + offset = 0.0 + } + if let navigationBarComponentView = self.navigationBarView.view as? ChatListNavigationBar.View { - navigationBarComponentView.applyScroll(offset: resultingOffset, transition: Transition(transition)) + navigationBarComponentView.applyScroll(offset: offset, transition: Transition(transition)) } } @@ -1801,6 +1935,7 @@ final class ChatListControllerNode: ASDisplayNode, UIGestureRecognizerDelegate { var storiesInset = storiesInset let navigationBarLayout = self.updateNavigationBar(layout: layout, transition: transition) + self.mainContainerNode.initialScrollingOffset = navigationBarSearchContentHeight + navigationBarLayout.storiesInset navigationBarHeight = navigationBarLayout.navigationHeight visualNavigationHeight = navigationBarLayout.navigationHeight @@ -2018,6 +2153,12 @@ final class ChatListControllerNode: ASDisplayNode, UIGestureRecognizerDelegate { private var contentOffsetSyncLockedIn: Bool = false + func willScrollToTop() { + if let navigationBarComponentView = self.navigationBarView.view as? ChatListNavigationBar.View { + navigationBarComponentView.applyScroll(offset: 0.0, transition: Transition(animation: .curve(duration: 0.3, curve: .slide))) + } + } + private func contentOffsetChanged(offset: ListViewVisibleContentOffset, isPrimary: Bool) { guard let containerLayout = self.containerLayout else { return @@ -2074,7 +2215,45 @@ final class ChatListControllerNode: ASDisplayNode, UIGestureRecognizerDelegate { } private func contentScrollingEnded(listView: ListView, isPrimary: Bool) -> Bool { - guard let inlineStackContainerNode = self.inlineStackContainerNode else { + if !isPrimary || self.inlineStackContainerNode == nil { + if let navigationBarComponentView = self.navigationBarView.view as? ChatListNavigationBar.View { + if let clippedScrollOffset = navigationBarComponentView.clippedScrollOffset { + if navigationBarComponentView.effectiveStoriesInsetHeight > 0.0 { + if clippedScrollOffset > 0.0 && clippedScrollOffset < navigationBarComponentView.effectiveStoriesInsetHeight { + if clippedScrollOffset < navigationBarComponentView.effectiveStoriesInsetHeight * 0.5 { + let _ = listView.scrollToOffsetFromTop(0.0, animated: true) + } else { + let _ = listView.scrollToOffsetFromTop(navigationBarComponentView.effectiveStoriesInsetHeight, animated: true) + } + return true + } else { + let searchScrollOffset = clippedScrollOffset - navigationBarComponentView.effectiveStoriesInsetHeight + if searchScrollOffset > 0.0 && searchScrollOffset < navigationBarSearchContentHeight { + if searchScrollOffset < navigationBarSearchContentHeight * 0.5 { + let _ = listView.scrollToOffsetFromTop(navigationBarComponentView.effectiveStoriesInsetHeight, animated: true) + } else { + let _ = listView.scrollToOffsetFromTop(navigationBarComponentView.effectiveStoriesInsetHeight + navigationBarSearchContentHeight, animated: true) + } + return true + } + } + } else { + if clippedScrollOffset > 0.0 && clippedScrollOffset < navigationBarSearchContentHeight { + if clippedScrollOffset < navigationBarSearchContentHeight * 0.5 { + let _ = listView.scrollToOffsetFromTop(0.0, animated: true) + } else { + let _ = listView.scrollToOffsetFromTop(navigationBarSearchContentHeight, animated: true) + } + return true + } + } + } + } + } + + return false + + /*guard let inlineStackContainerNode = self.inlineStackContainerNode else { return self.contentScrollingEnded?(listView) ?? false } @@ -2086,7 +2265,7 @@ final class ChatListControllerNode: ASDisplayNode, UIGestureRecognizerDelegate { let _ = inlineStackContainerNode - return self.contentScrollingEnded?(listView) ?? false + return self.contentScrollingEnded?(listView) ?? false*/ } func makeInlineChatList(location: ChatListControllerLocation) -> ChatListContainerNode { @@ -2131,6 +2310,11 @@ final class ChatListControllerNode: ASDisplayNode, UIGestureRecognizerDelegate { let previousInlineStackContainerNode = self.inlineStackContainerNode + if let navigationBarComponentView = self.navigationBarView.view as? ChatListNavigationBar.View, let clippedScrollOffset = navigationBarComponentView.clippedScrollOffset { + let scrollOffset = max(0.0, clippedScrollOffset - navigationBarComponentView.effectiveStoriesInsetHeight) + inlineStackContainerNode.initialScrollingOffset = scrollOffset + } + self.inlineStackContainerNode = inlineStackContainerNode self.inlineStackContainerTransitionFraction = 1.0 diff --git a/submodules/ChatListUI/Sources/Node/ChatListNode.swift b/submodules/ChatListUI/Sources/Node/ChatListNode.swift index 2d1e050460..937c31d07a 100644 --- a/submodules/ChatListUI/Sources/Node/ChatListNode.swift +++ b/submodules/ChatListUI/Sources/Node/ChatListNode.swift @@ -3173,7 +3173,8 @@ public final class ChatListNode: ListView { var isNavigationInAFinalState: Bool { switch self.visibleContentOffset() { case let .known(value): - if value < self.scrollHeightTopInset - 1.0 { + let _ = value + /*if value < self.scrollHeightTopInset - 1.0 { if abs(value - 0.0) < 1.0 { return true } @@ -3181,9 +3182,9 @@ public final class ChatListNode: ListView { return true } return false - } else { + } else {*/ return true - } + //} default: return true } @@ -3216,6 +3217,7 @@ public final class ChatListNode: ListView { self.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous], scrollToItem: scrollToItem, updateSizeAndInsets: nil, stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in })*/ } + public var ignoreStoryInsetAdjustment: Bool = false private var previousStoriesInset: CGFloat? public func updateLayout(transition: ContainedViewLayoutTransition, updateSizeAndInsets: ListViewUpdateSizeAndInsets, visibleTopInset: CGFloat, originalTopInset: CGFloat, storiesInset: CGFloat, inlineNavigationLocation: ChatListControllerLocation?, inlineNavigationTransitionFraction: CGFloat) { @@ -3257,7 +3259,11 @@ public final class ChatListNode: ListView { var additionalScrollDistance: CGFloat = 0.0 if let previousStoriesInset = self.previousStoriesInset { - additionalScrollDistance += previousStoriesInset - storiesInset + if self.ignoreStoryInsetAdjustment { + //additionalScrollDistance += -20.0 + } else { + additionalScrollDistance += previousStoriesInset - storiesInset + } } self.previousStoriesInset = storiesInset //print("storiesInset: \(storiesInset), additionalScrollDistance: \(additionalScrollDistance)") diff --git a/submodules/ComponentFlow/Source/Base/Transition.swift b/submodules/ComponentFlow/Source/Base/Transition.swift index 860a4d3e86..98f8ff7d88 100644 --- a/submodules/ComponentFlow/Source/Base/Transition.swift +++ b/submodules/ComponentFlow/Source/Base/Transition.swift @@ -84,6 +84,10 @@ public struct Transition { return bezierPoint(CGFloat(c1x), CGFloat(c1y), CGFloat(c2x), CGFloat(c2y), offset) } } + + public static var slide: Curve { + return .custom(0.33, 0.52, 0.25, 0.99) + } } case none diff --git a/submodules/Display/Source/ListView.swift b/submodules/Display/Source/ListView.swift index acdaf44b24..d6c318d6e0 100644 --- a/submodules/Display/Source/ListView.swift +++ b/submodules/Display/Source/ListView.swift @@ -1360,7 +1360,7 @@ open class ListView: ASDisplayNode, UIScrollViewAccessibilityDelegate, UIGesture self.visibleBottomContentOffsetChanged(self.visibleBottomContentOffset()) } - private func stopScrolling() { + public func stopScrolling() { let wasIgnoringScrollingEvents = self.ignoreScrollingEvents self.ignoreScrollingEvents = true self.scroller.setContentOffset(self.scroller.contentOffset, animated: false) diff --git a/submodules/SearchBarNode/Sources/SearchBarPlaceholderNode.swift b/submodules/SearchBarNode/Sources/SearchBarPlaceholderNode.swift index 154cbadcca..1a22608d1f 100644 --- a/submodules/SearchBarNode/Sources/SearchBarPlaceholderNode.swift +++ b/submodules/SearchBarNode/Sources/SearchBarPlaceholderNode.swift @@ -210,26 +210,40 @@ public class SearchBarPlaceholderNode: ASDisplayNode { } else if innerAlpha < 0.0001 { innerAlpha = 0.0 } - if !transition.isAnimated { - strongSelf.labelNode.layer.removeAnimation(forKey: "opacity") - strongSelf.iconNode.layer.removeAnimation(forKey: "opacity") - } if strongSelf.labelNode.alpha != innerAlpha { + if !transition.isAnimated { + strongSelf.labelNode.layer.removeAnimation(forKey: "opacity") + strongSelf.iconNode.layer.removeAnimation(forKey: "opacity") + } + transition.updateAlpha(node: strongSelf.labelNode, alpha: innerAlpha) transition.updateAlpha(node: strongSelf.iconNode, alpha: innerAlpha) } let outerAlpha = min(0.3, expansionProgress) / 0.3 let cornerRadius = min(strongSelf.fieldStyle.cornerDiameter / 2.0, height / 2.0) - if !transition.isAnimated { - strongSelf.backgroundNode.layer.removeAnimation(forKey: "cornerRadius") - strongSelf.backgroundNode.layer.removeAnimation(forKey: "position") - strongSelf.backgroundNode.layer.removeAnimation(forKey: "bounds") - strongSelf.backgroundNode.layer.removeAnimation(forKey: "opacity") + + if strongSelf.backgroundNode.cornerRadius != cornerRadius { + if !transition.isAnimated { + strongSelf.backgroundNode.layer.removeAnimation(forKey: "cornerRadius") + } + transition.updateCornerRadius(node: strongSelf.backgroundNode, cornerRadius: cornerRadius) + } + + if strongSelf.backgroundNode.alpha != outerAlpha { + if !transition.isAnimated { + strongSelf.backgroundNode.layer.removeAnimation(forKey: "opacity") + } + transition.updateAlpha(node: strongSelf.backgroundNode, alpha: outerAlpha) + } + + if strongSelf.backgroundNode.frame != CGRect(origin: CGPoint(), size: CGSize(width: constrainedSize.width, height: height)) { + if !transition.isAnimated { + strongSelf.backgroundNode.layer.removeAnimation(forKey: "position") + strongSelf.backgroundNode.layer.removeAnimation(forKey: "bounds") + } + transition.updateFrame(node: strongSelf.backgroundNode, frame: CGRect(origin: CGPoint(), size: CGSize(width: constrainedSize.width, height: height))) } - transition.updateCornerRadius(node: strongSelf.backgroundNode, cornerRadius: cornerRadius) - transition.updateAlpha(node: strongSelf.backgroundNode, alpha: outerAlpha) - transition.updateFrame(node: strongSelf.backgroundNode, frame: CGRect(origin: CGPoint(), size: CGSize(width: constrainedSize.width, height: height))) if let accessoryComponentContainer = strongSelf.accessoryComponentContainer { accessoryComponentContainer.frame = CGRect(origin: CGPoint(x: constrainedSize.width - accessoryComponentContainer.bounds.width - 4.0, y: floor((constrainedSize.height - accessoryComponentContainer.bounds.height) / 2.0)), size: accessoryComponentContainer.bounds.size) diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Messages/Stories.swift b/submodules/TelegramCore/Sources/TelegramEngine/Messages/Stories.swift index 3e61c35f5b..388a88dc1c 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Messages/Stories.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Messages/Stories.swift @@ -9,22 +9,26 @@ public enum EngineStoryInputMedia { } public struct EngineStoryPrivacy: Equatable { - public enum Base { - case everyone - case contacts - case closeFriends - case nobody - } + public typealias Base = Stories.Item.Privacy.Base public var base: Base public var additionallyIncludePeers: [EnginePeer.Id] - public init(base: Base, additionallyIncludePeers: [EnginePeer.Id]) { + public init(base: Stories.Item.Privacy.Base, additionallyIncludePeers: [EnginePeer.Id]) { self.base = base self.additionallyIncludePeers = additionallyIncludePeers } } +public extension EngineStoryPrivacy { + init(_ privacy: Stories.Item.Privacy) { + self.init( + base: privacy.base, + additionallyIncludePeers: privacy.additionallyIncludePeers + ) + } +} + public enum Stories { public final class Item: Codable, Equatable { public struct Views: Codable, Equatable { @@ -100,6 +104,9 @@ public enum Stories { case entities case views case privacy + case isPinned + case isExpired + case isPublic } public let id: Int32 @@ -109,6 +116,9 @@ public enum Stories { public let entities: [MessageTextEntity] public let views: Views? public let privacy: Privacy? + public let isPinned: Bool + public let isExpired: Bool + public let isPublic: Bool public init( id: Int32, @@ -117,7 +127,10 @@ public enum Stories { text: String, entities: [MessageTextEntity], views: Views?, - privacy: Privacy? + privacy: Privacy?, + isPinned: Bool, + isExpired: Bool, + isPublic: Bool ) { self.id = id self.timestamp = timestamp @@ -126,6 +139,9 @@ public enum Stories { self.entities = entities self.views = views self.privacy = privacy + self.isPinned = isPinned + self.isExpired = isExpired + self.isPublic = isPublic } public init(from decoder: Decoder) throws { @@ -144,6 +160,9 @@ public enum Stories { self.entities = try container.decode([MessageTextEntity].self, forKey: .entities) self.views = try container.decodeIfPresent(Views.self, forKey: .views) self.privacy = try container.decodeIfPresent(Privacy.self, forKey: .privacy) + self.isPinned = try container.decodeIfPresent(Bool.self, forKey: .isPinned) ?? false + self.isExpired = try container.decodeIfPresent(Bool.self, forKey: .isExpired) ?? false + self.isPublic = try container.decodeIfPresent(Bool.self, forKey: .isPublic) ?? false } public func encode(to encoder: Encoder) throws { @@ -163,6 +182,9 @@ public enum Stories { try container.encode(self.entities, forKey: .entities) try container.encodeIfPresent(self.views, forKey: .views) try container.encodeIfPresent(self.privacy, forKey: .privacy) + try container.encode(self.isPinned, forKey: .isPinned) + try container.encode(self.isExpired, forKey: .isExpired) + try container.encode(self.isPublic, forKey: .isPublic) } public static func ==(lhs: Item, rhs: Item) -> Bool { @@ -195,6 +217,15 @@ public enum Stories { if lhs.privacy != rhs.privacy { return false } + if lhs.isPinned != rhs.isPinned { + return false + } + if lhs.isExpired != rhs.isExpired { + return false + } + if lhs.isPublic != rhs.isPublic { + return false + } return true } @@ -698,6 +729,41 @@ func _internal_markStoryAsSeen(account: Account, peerId: PeerId, id: Int32) -> S } } +func _internal_updateStoryIsPinned(account: Account, id: Int32, isPinned: Bool) -> Signal { + return account.postbox.transaction { transaction -> Void in + var items = transaction.getStoryItems(peerId: account.peerId) + if let index = items.firstIndex(where: { $0.id == id }), case let .item(item) = items[index].value.get(Stories.StoredItem.self) { + let updatedItem = Stories.Item( + id: item.id, + timestamp: item.timestamp, + media: item.media, + text: item.text, + entities: item.entities, + views: item.views, + privacy: item.privacy, + isPinned: isPinned, + isExpired: item.isExpired, + isPublic: item.isPublic + ) + if let entry = CodableEntry(Stories.StoredItem.item(updatedItem)) { + items[index] = StoryItemsTableEntry(value: entry, id: item.id) + transaction.setStoryItems(peerId: account.peerId, items: items) + } + + DispatchQueue.main.async { + account.stateManager.injectStoryUpdates(updates: [.added(peerId: account.peerId, item: Stories.StoredItem.item(updatedItem))]) + } + } + } + |> mapToSignal { _ -> Signal in + return account.network.request(Api.functions.stories.togglePinned(id: [id], pinned: isPinned ? .boolTrue : .boolFalse)) + |> `catch` { _ -> Signal<[Int32], NoError> in + return .single([]) + } + |> ignoreValues + } +} + extension Api.StoryItem { var id: Int32 { switch self { @@ -762,6 +828,10 @@ extension Stories.StoredItem { parsedPrivacy = Stories.Item.Privacy(base: base, additionallyIncludePeers: additionalPeerIds) } + let isPinned = (flags & (1 << 5)) != 0 + let isExpired = (flags & (1 << 6)) != 0 + let isPublic = (flags & (1 << 7)) != 0 + let item = Stories.Item( id: id, timestamp: date, @@ -769,7 +839,10 @@ extension Stories.StoredItem { text: caption ?? "", entities: entities.flatMap { entities in return messageTextEntitiesFromApiEntities(entities) } ?? [], views: views.flatMap(Stories.Item.Views.init(apiViews:)), - privacy: parsedPrivacy + privacy: parsedPrivacy, + isPinned: isPinned, + isExpired: isExpired, + isPublic: isPublic ) self = .item(item) } else { diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Messages/StoryListContext.swift b/submodules/TelegramCore/Sources/TelegramEngine/Messages/StoryListContext.swift index 3237ff096b..a87c6f9823 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Messages/StoryListContext.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Messages/StoryListContext.swift @@ -2,6 +2,7 @@ import Foundation import Postbox import TelegramApi import SwiftSignalKit +import MtProtoKit enum InternalStoryUpdate { case deleted(peerId: PeerId, id: Int32) @@ -37,8 +38,11 @@ public final class EngineStoryItem: Equatable { public let entities: [MessageTextEntity] public let views: Views? public let privacy: EngineStoryPrivacy? + public let isPinned: Bool + public let isExpired: Bool + public let isPublic: Bool - public init(id: Int32, timestamp: Int32, media: EngineMedia, text: String, entities: [MessageTextEntity], views: Views?, privacy: EngineStoryPrivacy?) { + public init(id: Int32, timestamp: Int32, media: EngineMedia, text: String, entities: [MessageTextEntity], views: Views?, privacy: EngineStoryPrivacy?, isPinned: Bool, isExpired: Bool, isPublic: Bool) { self.id = id self.timestamp = timestamp self.media = media @@ -46,6 +50,9 @@ public final class EngineStoryItem: Equatable { self.entities = entities self.views = views self.privacy = privacy + self.isPinned = isPinned + self.isExpired = isExpired + self.isPublic = isPublic } public static func ==(lhs: EngineStoryItem, rhs: EngineStoryItem) -> Bool { @@ -70,6 +77,15 @@ public final class EngineStoryItem: Equatable { if lhs.privacy != rhs.privacy { return false } + if lhs.isPinned != rhs.isPinned { + return false + } + if lhs.isExpired != rhs.isExpired { + return false + } + if lhs.isPublic != rhs.isPublic { + return false + } return true } } @@ -255,6 +271,15 @@ public final class StorySubscriptionsContext { var updatedPeerEntries: [StoryItemsTableEntry] = [] for story in stories { if let storedItem = Stories.StoredItem(apiStoryItem: story, peerId: peerId, transaction: transaction) { + /*#if DEBUG + if "".isEmpty { + if let codedEntry = CodableEntry(Stories.StoredItem.placeholder(Stories.Placeholder(id: storedItem.id, timestamp: storedItem.timestamp))) { + updatedPeerEntries.append(StoryItemsTableEntry(value: codedEntry, id: storedItem.id)) + } + continue + } + #endif*/ + if case .placeholder = storedItem, let previousEntry = previousPeerEntries.first(where: { $0.id == storedItem.id }) { updatedPeerEntries.append(previousEntry) } else { @@ -331,3 +356,275 @@ public final class StorySubscriptionsContext { } } } + +public final class PeerStoryListContext { + public struct State: Equatable { + public var peerReference: PeerReference? + public var items: [EngineStoryItem] + public var totalCount: Int + public var loadMoreToken: Int? + + init( + peerReference: PeerReference?, + items: [EngineStoryItem], + totalCount: Int, + loadMoreToken: Int? + ) { + self.peerReference = peerReference + self.items = items + self.totalCount = totalCount + self.loadMoreToken = loadMoreToken + } + } + + private let account: Account + private let peerId: EnginePeer.Id + private let isArchived: Bool + + private let statePromise = Promise() + private var stateValue: State { + didSet { + self.statePromise.set(.single(self.stateValue)) + } + } + public var state: Signal { + return self.statePromise.get() + } + + private var isLoadingMore: Bool = false + private var requestDisposable: Disposable? + + private var updatesDisposable: Disposable? + + public init(account: Account, peerId: EnginePeer.Id, isArchived: Bool) { + self.account = account + self.peerId = peerId + self.isArchived = isArchived + + self.stateValue = State(peerReference: nil, items: [], totalCount: 0, loadMoreToken: 0) + self.statePromise.set(.single(self.stateValue)) + + self.loadMore() + } + + deinit { + self.requestDisposable?.dispose() + } + + public func loadMore() { + if self.isLoadingMore { + return + } + guard let loadMoreToken = self.stateValue.loadMoreToken else { + return + } + + self.isLoadingMore = true + + let peerId = self.peerId + let account = self.account + let isArchived = self.isArchived + self.requestDisposable = (self.account.postbox.transaction { transaction -> Api.InputUser? in + return transaction.getPeer(peerId).flatMap(apiInputUser) + } + |> mapToSignal { inputUser -> Signal<([EngineStoryItem], Int, PeerReference?), NoError> in + guard let inputUser = inputUser else { + return .single(([], 0, nil)) + } + + let signal: Signal + if isArchived { + signal = account.network.request(Api.functions.stories.getExpiredStories(offsetId: Int32(loadMoreToken), limit: 100)) + } else { + signal = account.network.request(Api.functions.stories.getPinnedStories(userId: inputUser, offsetId: Int32(loadMoreToken), limit: 100)) + } + return signal + |> map(Optional.init) + |> `catch` { _ -> Signal in + return .single(nil) + } + |> mapToSignal { result -> Signal<([EngineStoryItem], Int, PeerReference?), NoError> in + guard let result = result else { + return .single(([], 0, nil)) + } + + return account.postbox.transaction { transaction -> ([EngineStoryItem], Int, PeerReference?) in + var storyItems: [EngineStoryItem] = [] + var totalCount: Int = 0 + + switch result { + case let .stories(count, stories, users): + totalCount = Int(count) + + var peers: [Peer] = [] + var peerPresences: [PeerId: Api.User] = [:] + + for user in users { + let telegramUser = TelegramUser(user: user) + peers.append(telegramUser) + peerPresences[telegramUser.id] = user + } + + updatePeers(transaction: transaction, peers: peers, update: { _, updated -> Peer in + return updated + }) + updatePeerPresences(transaction: transaction, accountPeerId: account.peerId, peerPresences: peerPresences) + + for story in stories { + if let storedItem = Stories.StoredItem(apiStoryItem: story, peerId: peerId, transaction: transaction) { + if case let .item(item) = storedItem, let media = item.media { + let mappedItem = EngineStoryItem( + id: item.id, + timestamp: item.timestamp, + media: EngineMedia(media), + text: item.text, + entities: item.entities, + views: item.views.flatMap { views in + return EngineStoryItem.Views( + seenCount: views.seenCount, + seenPeers: views.seenPeerIds.compactMap { id -> EnginePeer? in + return transaction.getPeer(id).flatMap(EnginePeer.init) + } + ) + }, + privacy: item.privacy.flatMap(EngineStoryPrivacy.init), + isPinned: item.isPinned, + isExpired: item.isExpired, + isPublic: item.isPublic + ) + storyItems.append(mappedItem) + } + } + } + } + + return (storyItems, totalCount, transaction.getPeer(peerId).flatMap(PeerReference.init)) + } + } + }).start(next: { [weak self] storyItems, totalCount, peerReference in + guard let `self` = self else { + return + } + + self.isLoadingMore = false + + var updatedState = self.stateValue + + var existingIds = Set(updatedState.items.map { $0.id }) + for item in storyItems { + if existingIds.contains(item.id) { + continue + } + existingIds.insert(item.id) + + updatedState.items.append(item) + } + + if updatedState.peerReference == nil { + updatedState.peerReference = peerReference + } + + updatedState.loadMoreToken = (storyItems.last?.id).flatMap(Int.init) + if updatedState.loadMoreToken != nil { + updatedState.totalCount = max(totalCount, updatedState.items.count) + } else { + updatedState.totalCount = updatedState.items.count + } + self.stateValue = updatedState + + if self.updatesDisposable == nil { + self.updatesDisposable = (self.account.stateManager.storyUpdates + |> deliverOnMainQueue).start(next: { [weak self] updates in + guard let `self` = self else { + return + } + let selfPeerId = self.peerId + let _ = (self.account.postbox.transaction { transaction -> [PeerId: Peer] in + var peers: [PeerId: Peer] = [:] + + for update in updates { + switch update { + case let .added(peerId, item): + if selfPeerId == peerId { + if case let .item(item) = item { + if let views = item.views { + for id in views.seenPeerIds { + if let peer = transaction.getPeer(id) { + peers[peer.id] = peer + } + } + } + } + } + default: + break + } + } + + return peers + } + |> deliverOnMainQueue).start(next: { [weak self] peers in + guard let `self` = self else { + return + } + + for update in updates { + switch update { + case let .deleted(peerId, id): + if self.peerId == peerId { + if let index = self.stateValue.items.firstIndex(where: { $0.id == id }) { + var updatedState = self.stateValue + updatedState.items.remove(at: index) + updatedState.totalCount = max(0, updatedState.totalCount - 1) + self.stateValue = updatedState + } + } + case let .added(peerId, item): + if self.peerId == peerId { + if let index = self.stateValue.items.firstIndex(where: { $0.id == item.id }) { + if !self.isArchived { + if case let .item(item) = item { + if item.isPinned { + if let media = item.media { + var updatedState = self.stateValue + updatedState.items[index] = EngineStoryItem( + id: item.id, + timestamp: item.timestamp, + media: EngineMedia(media), + text: item.text, + entities: item.entities, + views: item.views.flatMap { views in + return EngineStoryItem.Views( + seenCount: views.seenCount, + seenPeers: views.seenPeerIds.compactMap { id -> EnginePeer? in + return peers[id].flatMap(EnginePeer.init) + } + ) + }, + privacy: item.privacy.flatMap(EngineStoryPrivacy.init), + isPinned: item.isPinned, + isExpired: item.isExpired, + isPublic: item.isPublic + ) + self.stateValue = updatedState + } + } else { + var updatedState = self.stateValue + updatedState.items.remove(at: index) + updatedState.totalCount = max(0, updatedState.totalCount - 1) + self.stateValue = updatedState + } + } + } + } + } + case .read: + break + } + } + }) + }) + } + }) + } +} diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift b/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift index 21ac8850a6..66cd78c529 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift @@ -871,6 +871,10 @@ public extension TelegramEngine { return _internal_markStoryAsSeen(account: self.account, peerId: peerId, id: id) } + public func updateStoryIsPinned(id: Int32, isPinned: Bool) -> Signal { + return _internal_updateStoryIsPinned(account: self.account, id: id, isPinned: isPinned) + } + public func getStoryViewList(account: Account, id: Int32, offsetTimestamp: Int32?, offsetPeerId: PeerId?, limit: Int) -> Signal { return _internal_getStoryViewList(account: account, id: id, offsetTimestamp: offsetTimestamp, offsetPeerId: offsetPeerId, limit: limit) } diff --git a/submodules/TelegramUI/Components/ChatListHeaderComponent/Sources/ChatListNavigationBar.swift b/submodules/TelegramUI/Components/ChatListHeaderComponent/Sources/ChatListNavigationBar.swift index ff7e502a14..2765bc117b 100644 --- a/submodules/TelegramUI/Components/ChatListHeaderComponent/Sources/ChatListNavigationBar.swift +++ b/submodules/TelegramUI/Components/ChatListHeaderComponent/Sources/ChatListNavigationBar.swift @@ -16,10 +16,12 @@ public final class ChatListNavigationBar: Component { public let statusBarHeight: CGFloat public let sideInset: CGFloat public let isSearchActive: Bool + public let storiesUnlocked: Bool public let primaryContent: ChatListHeaderComponent.Content? public let secondaryContent: ChatListHeaderComponent.Content? public let secondaryTransition: CGFloat public let storySubscriptions: EngineStorySubscriptions? + public let tabsNode: ASDisplayNode? public let activateSearch: (NavigationBarSearchContentNode) -> Void public let openStatusSetup: (UIView) -> Void @@ -30,10 +32,12 @@ public final class ChatListNavigationBar: Component { statusBarHeight: CGFloat, sideInset: CGFloat, isSearchActive: Bool, + storiesUnlocked: Bool, primaryContent: ChatListHeaderComponent.Content?, secondaryContent: ChatListHeaderComponent.Content?, secondaryTransition: CGFloat, storySubscriptions: EngineStorySubscriptions?, + tabsNode: ASDisplayNode?, activateSearch: @escaping (NavigationBarSearchContentNode) -> Void, openStatusSetup: @escaping (UIView) -> Void ) { @@ -43,10 +47,12 @@ public final class ChatListNavigationBar: Component { self.statusBarHeight = statusBarHeight self.sideInset = sideInset self.isSearchActive = isSearchActive + self.storiesUnlocked = storiesUnlocked self.primaryContent = primaryContent self.secondaryContent = secondaryContent self.secondaryTransition = secondaryTransition self.storySubscriptions = storySubscriptions + self.tabsNode = tabsNode self.activateSearch = activateSearch self.openStatusSetup = openStatusSetup } @@ -70,6 +76,9 @@ public final class ChatListNavigationBar: Component { if lhs.isSearchActive != rhs.isSearchActive { return false } + if lhs.storiesUnlocked != rhs.storiesUnlocked { + return false + } if lhs.primaryContent != rhs.primaryContent { return false } @@ -82,6 +91,9 @@ public final class ChatListNavigationBar: Component { if lhs.storySubscriptions != rhs.storySubscriptions { return false } + if lhs.tabsNode != rhs.tabsNode { + return false + } return true } @@ -109,13 +121,20 @@ public final class ChatListNavigationBar: Component { private var currentLayout: CurrentLayout? private var rawScrollOffset: CGFloat? - private var clippedScrollOffset: CGFloat? + public private(set) var clippedScrollOffset: CGFloat? public var deferScrollApplication: Bool = false private var hasDeferredScrollOffset: Bool = false public private(set) var effectiveStoriesInsetHeight: CGFloat = 0.0 + private var applyScrollFractionAnimator: DisplayLinkAnimator? + private var applyScrollFraction: CGFloat = 1.0 + private var applyScrollStartFraction: CGFloat = 0.0 + + private var tabsNode: ASDisplayNode? + private weak var disappearingTabsView: UIView? + override public init(frame: CGRect) { self.backgroundView = BlurredBackgroundView(color: .clear, enableBlur: true) self.separatorLayer = SimpleLayer() @@ -148,6 +167,11 @@ public final class ChatListNavigationBar: Component { } public func applyScroll(offset: CGFloat, transition: Transition) { + var transition = transition + if self.applyScrollFractionAnimator != nil { + transition = .immediate + } + self.rawScrollOffset = offset if self.deferScrollApplication { @@ -169,7 +193,7 @@ public final class ChatListNavigationBar: Component { let effectiveStoriesOffsetDistance: CGFloat var minContentOffset: CGFloat = navigationBarSearchContentHeight - if !component.isSearchActive, let storySubscriptions = component.storySubscriptions, !storySubscriptions.items.isEmpty { + if !component.isSearchActive, let storySubscriptions = component.storySubscriptions, !storySubscriptions.items.isEmpty, component.storiesUnlocked { effectiveStoriesOffsetDistance = defaultStoriesOffsetDistance * (1.0 - component.secondaryTransition) minContentOffset += effectiveStoriesOffsetDistance } else { @@ -185,6 +209,8 @@ public final class ChatListNavigationBar: Component { let visibleSize = CGSize(width: currentLayout.size.width, height: max(0.0, currentLayout.size.height - clippedScrollOffset)) + let previousHeight = self.backgroundView.bounds.height + self.backgroundView.update(size: visibleSize, transition: transition.containedViewLayoutTransition) transition.setFrame(view: self.backgroundView, frame: CGRect(origin: CGPoint(), size: visibleSize)) transition.setFrame(layer: self.separatorLayer, frame: CGRect(origin: CGPoint(x: 0.0, y: visibleSize.height), size: CGSize(width: visibleSize.width, height: UIScreenPixel))) @@ -226,15 +252,22 @@ public final class ChatListNavigationBar: Component { } let clippedStoriesOffset = max(0.0, min(clippedScrollOffset, defaultStoriesOffsetDistance)) - let storiesOffsetFraction: CGFloat - if let storySubscriptions = component.storySubscriptions, !storySubscriptions.items.isEmpty { + var storiesOffsetFraction: CGFloat + if !component.isSearchActive, component.secondaryTransition == 0.0, let storySubscriptions = component.storySubscriptions, !storySubscriptions.items.isEmpty, component.storiesUnlocked { storiesOffsetFraction = clippedStoriesOffset / defaultStoriesOffsetDistance } else { storiesOffsetFraction = 1.0 } + if self.applyScrollFractionAnimator != nil { + storiesOffsetFraction = self.applyScrollFraction * storiesOffsetFraction + (1.0 - self.applyScrollFraction) * 1.0 + } + let searchSize = CGSize(width: currentLayout.size.width, height: navigationBarSearchContentHeight) - let searchFrame = CGRect(origin: CGPoint(x: 0.0, y: visibleSize.height - 0.0 - searchSize.height), size: searchSize) + var searchFrame = CGRect(origin: CGPoint(x: 0.0, y: visibleSize.height - searchSize.height), size: searchSize) + if component.tabsNode != nil { + searchFrame.origin.y -= 46.0 + } let clippedSearchOffset = max(0.0, min(clippedScrollOffset - effectiveStoriesOffsetDistance, searchOffsetDistance)) let searchOffsetFraction = clippedSearchOffset / searchOffsetDistance @@ -289,11 +322,55 @@ public final class ChatListNavigationBar: Component { } transition.setFrame(view: headerContentView, frame: headerContentFrame) } + + if component.tabsNode !== self.tabsNode { + if let tabsNode = self.tabsNode { + self.tabsNode = nil + let disappearingTabsView = tabsNode.view + self.disappearingTabsView = disappearingTabsView + transition.setAlpha(view: tabsNode.view, alpha: 0.0, completion: { [weak self, weak disappearingTabsView] _ in + guard let self, let disappearingTabsView else { + return + } + if self.tabsNode?.view !== disappearingTabsView { + disappearingTabsView.removeFromSuperview() + } + }) + } + } + + let tabsFrame = CGRect(origin: CGPoint(x: 0.0, y: visibleSize.height - 46.0), size: CGSize(width: visibleSize.width, height: 46.0)) + + if let disappearingTabsView = self.disappearingTabsView { + transition.setFrame(view: disappearingTabsView, frame: tabsFrame) + } + + if let tabsNode = component.tabsNode { + self.tabsNode = tabsNode + + var tabsNodeTransition = transition + if tabsNode.view.superview !== self { + tabsNodeTransition = .immediate + self.addSubview(tabsNode.view) + if !transition.animation.isImmediate { + tabsNode.view.alpha = 1.0 + tabsNode.view.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) + transition.animatePosition(view: tabsNode.view, from: CGPoint(x: 0.0, y: previousHeight - visibleSize.height), to: CGPoint(), additive: true) + } + } + + tabsNodeTransition.setFrame(view: tabsNode.view, frame: tabsFrame) + } } func update(component: ChatListNavigationBar, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { let themeUpdated = self.component?.theme !== component.theme + var storiesUnlockedUpdated = false + if let previousComponent = self.component, previousComponent.storiesUnlocked != component.storiesUnlocked { + storiesUnlockedUpdated = true + } + self.component = component self.state = state @@ -315,7 +392,7 @@ public final class ChatListNavigationBar: Component { } self.effectiveStoriesInsetHeight = 0.0 } else { - if let storySubscriptions = component.storySubscriptions, !storySubscriptions.items.isEmpty { + if let storySubscriptions = component.storySubscriptions, !storySubscriptions.items.isEmpty, component.storiesUnlocked { let storiesHeight: CGFloat = 94.0 * (1.0 - component.secondaryTransition) contentHeight += storiesHeight self.effectiveStoriesInsetHeight = storiesHeight @@ -326,11 +403,37 @@ public final class ChatListNavigationBar: Component { contentHeight += navigationBarSearchContentHeight } + if component.tabsNode != nil { + contentHeight += 46.0 + } + let size = CGSize(width: availableSize.width, height: contentHeight) self.currentLayout = CurrentLayout(size: size) self.hasDeferredScrollOffset = true + if storiesUnlockedUpdated { + self.applyScrollFraction = 0.0 + self.applyScrollStartFraction = 0.0 + self.applyScrollFractionAnimator = DisplayLinkAnimator(duration: 0.3, from: 0.0, to: 1.0, update: { [weak self] value in + guard let self else { + return + } + self.applyScrollFraction = listViewAnimationCurveSystem(value) + + if let rawScrollOffset = self.rawScrollOffset { + self.hasDeferredScrollOffset = true + self.applyScroll(offset: rawScrollOffset, transition: transition) + } + }, completion: { [weak self] in + guard let self else { + return + } + self.applyScrollFractionAnimator?.invalidate() + self.applyScrollFractionAnimator = nil + }) + } + return size } } diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoStoryPaneNode.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoStoryPaneNode.swift index 216011c30d..ae881fe19a 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoStoryPaneNode.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoStoryPaneNode.swift @@ -738,7 +738,7 @@ private final class SparseItemGridBindingImpl: SparseItemGridBinding { } } -/*public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScrollViewDelegate, UIGestureRecognizerDelegate { +public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScrollViewDelegate, UIGestureRecognizerDelegate { public enum ContentType { case photoOrVideo case photo @@ -815,7 +815,7 @@ private final class SparseItemGridBindingImpl: SparseItemGridBinding { private var animationTimer: SwiftSignalKit.Timer? public private(set) var calendarSource: SparseMessageCalendar? - private var listSource: StorySubscriptionsContext + private var listSource: PeerStoryListContext public var openCurrentDate: (() -> Void)? public var paneDidScroll: (() -> Void)? @@ -848,7 +848,7 @@ private final class SparseItemGridBindingImpl: SparseItemGridBinding { captureProtected: captureProtected ) - //self.listSource = context.engine.messages.allStories() + self.listSource = PeerStoryListContext(account: context.account, peerId: peerId, isArchived: false) self.calendarSource = nil super.init() @@ -888,6 +888,75 @@ private final class SparseItemGridBindingImpl: SparseItemGridBinding { } strongSelf.chatControllerInteraction.toggleMessagesSelection([item.message.id], toggledValue)*/ } else { + let listContext = PeerStoryListContentContextImpl( + context: self.context, + peerId: self.peerId, + listContext: self.listSource, + initialId: item.story.id + ) + let _ = (listContext.state + |> take(1) + |> deliverOnMainQueue).start(next: { [weak self] _ in + guard let self, let navigationController = self.chatControllerInteraction.navigationController() else { + return + } + + var transitionIn: StoryContainerScreen.TransitionIn? + + let story = item.story + var foundItemLayer: SparseItemGridLayer? + self.itemGrid.forEachVisibleItem { item in + guard let itemLayer = item.layer as? ItemLayer else { + return + } + if let listItem = itemLayer.item, listItem.story.id == story.id { + foundItemLayer = itemLayer + } + } + if let foundItemLayer { + let itemRect = self.itemGrid.frameForItem(layer: foundItemLayer) + transitionIn = StoryContainerScreen.TransitionIn( + sourceView: self.view, + sourceRect: self.itemGrid.view.convert(itemRect, to: self.view), + sourceCornerRadius: 0.0 + ) + } + + let storyContainerScreen = StoryContainerScreen( + context: self.context, + content: listContext, + transitionIn: transitionIn, + transitionOut: { [weak self] _, itemId in + guard let self else { + return nil + } + + var foundItemLayer: SparseItemGridLayer? + self.itemGrid.forEachVisibleItem { item in + guard let itemLayer = item.layer as? ItemLayer else { + return + } + if let listItem = itemLayer.item, AnyHashable(listItem.story.id) == itemId { + foundItemLayer = itemLayer + } + } + if let foundItemLayer { + let itemRect = self.itemGrid.frameForItem(layer: foundItemLayer) + return StoryContainerScreen.TransitionOut( + destinationView: self.view, + destinationRect: self.itemGrid.view.convert(itemRect, to: self.view), + destinationCornerRadius: 0.0, + destinationIsAvatar: false, + completed: {} + ) + } + + return nil + } + ) + navigationController.pushViewController(storyContainerScreen) + }) + /*let _ = (StoryChatContent.stories( context: self.context, storyList: self.listSource, @@ -1389,7 +1458,7 @@ private final class SparseItemGridBindingImpl: SparseItemGridBinding { let queue = Queue() self.listDisposable.set((self.listSource.state - |> deliverOn(queue)).start(next: { [weak self] list in + |> deliverOn(queue)).start(next: { [weak self] state in guard let self else { return } @@ -1399,8 +1468,8 @@ private final class SparseItemGridBindingImpl: SparseItemGridBinding { var mappedItems: [SparseItemGrid.Item] = [] let mappedHoles: [SparseItemGrid.HoleAnchor] = [] var totalCount: Int = 0 - if let itemSet = list.itemSets.first(where: { $0.peerId == self.peerId }), let peer = itemSet.peer, let peerReference = PeerReference(peer._asPeer()) { - for item in itemSet.items { + if let peerReference = state.peerReference { + for item in state.items { mappedItems.append(VisualMediaItem( index: mappedItems.count, peer: peerReference, @@ -1408,9 +1477,9 @@ private final class SparseItemGridBindingImpl: SparseItemGridBinding { localMonthTimestamp: Month(localTimestamp: item.timestamp + timezoneOffset).packedValue )) } - totalCount = itemSet.totalCount ?? mappedItems.count - totalCount = max(mappedItems.count, totalCount) } + totalCount = state.totalCount + totalCount = max(mappedItems.count, totalCount) Queue.mainQueue().async { [weak self] in guard let strongSelf = self else { @@ -1933,4 +2002,3 @@ private class MediaListSelectionRecognizer: UIPanGestureRecognizer { } } } -*/ diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContainerScreen.swift b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContainerScreen.swift index ac84987c48..cf754ad09c 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContainerScreen.swift +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContainerScreen.swift @@ -432,11 +432,15 @@ private final class StoryContainerScreenComponent: Component { var update = false self.contentUpdatedDisposable = (component.content.updated |> deliverOnMainQueue).start(next: { [weak self] _ in - guard let self else { + guard let self, let component = self.component else { return } if update { - self.state?.updated(transition: .immediate) + if component.content.stateValue?.slice == nil { + self.environment?.controller()?.dismiss() + } else { + self.state?.updated(transition: .immediate) + } } }) update = true diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContent.swift b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContent.swift index 3118de5307..db49ffde22 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContent.swift +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContent.swift @@ -135,7 +135,7 @@ public final class StoryContentContextState { if lhs.peer != rhs.peer { return false } - if lhs.item.id != rhs.item.id { + if lhs.item.storyItem != rhs.item.storyItem { return false } if lhs.totalCount != rhs.totalCount { diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift index c7a9a234d6..6758fc7e47 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift @@ -932,11 +932,39 @@ public final class StoryItemSetContainerComponent: Component { return } - let _ = controller + var items: [ContextMenuItem] = [] - /*var items: [ContextMenuItem] = [] + let additionalCount = component.slice.item.storyItem.privacy?.additionallyIncludePeers.count ?? 0 - items.append(.action(ContextMenuActionItem(text: "Who can see", textLayout: .secondLineWithValue("Everyone"), icon: { theme in + let privacyText: String + switch component.slice.item.storyItem.privacy?.base { + case .closeFriends: + if additionalCount != 0 { + privacyText = "Close Friends (+\(additionalCount)" + } else { + privacyText = "Close Friends" + } + case .contacts: + if additionalCount != 0 { + privacyText = "Contacts (+\(additionalCount)" + } else { + privacyText = "Contacts" + } + case .nobody: + if additionalCount != 0 { + if additionalCount == 1 { + privacyText = "\(additionalCount) Person" + } else { + privacyText = "\(additionalCount) People" + } + } else { + privacyText = "Only Me" + } + default: + privacyText = "Everyone" + } + + items.append(.action(ContextMenuActionItem(text: "Who can see", textLayout: .secondLineWithValue(privacyText), icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Channels"), color: theme.contextMenu.primaryColor) }, action: { [weak self] _, a in a(.default) @@ -949,38 +977,62 @@ public final class StoryItemSetContainerComponent: Component { items.append(.separator) - items.append(.action(ContextMenuActionItem(text: "Save to profile", icon: { theme in - return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Add"), color: theme.contextMenu.primaryColor) + component.controller()?.forEachController { c in + if let c = c as? UndoOverlayController { + c.dismiss() + } + return true + } + + items.append(.action(ContextMenuActionItem(text: component.slice.item.storyItem.isPinned ? "Remove from profile" : "Save to profile", icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: component.slice.item.storyItem.isPinned ? "Chat/Context Menu/Check" : "Chat/Context Menu/Add"), color: theme.contextMenu.primaryColor) }, action: { [weak self] _, a in a(.default) guard let self, let component = self.component else { return } - let presentationData = component.context.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: component.theme) - self.component?.presentController(UndoOverlayController( - presentationData: presentationData, - content: .info(title: "Story saved to your profile", text: "Saved stories can be viewed by others on your profile until you remove them.", timeout: nil), - elevatedLayout: false, - animateInAsReplacement: false, - action: { _ in return false } - )) + + let _ = component.context.engine.messages.updateStoryIsPinned(id: component.slice.item.storyItem.id, isPinned: !component.slice.item.storyItem.isPinned).start() + + if component.slice.item.storyItem.isPinned { + let presentationData = component.context.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: component.theme) + self.component?.presentController(UndoOverlayController( + presentationData: presentationData, + content: .info(title: nil, text: "Story removed from your profile", timeout: nil), + elevatedLayout: false, + animateInAsReplacement: false, + action: { _ in return false } + )) + } else { + let presentationData = component.context.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: component.theme) + self.component?.presentController(UndoOverlayController( + presentationData: presentationData, + content: .info(title: "Story saved to your profile", text: "Saved stories can be viewed by others on your profile until you remove them.", timeout: nil), + elevatedLayout: false, + animateInAsReplacement: false, + action: { _ in return false } + )) + } }))) items.append(.action(ContextMenuActionItem(text: "Save image", icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Save"), color: theme.contextMenu.primaryColor) }, action: { _, a in a(.default) }))) - items.append(.action(ContextMenuActionItem(text: "Copy link", icon: { theme in - return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Link"), color: theme.contextMenu.primaryColor) - }, action: { _, a in - a(.default) - }))) - items.append(.action(ContextMenuActionItem(text: "Share", icon: { theme in - return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Forward"), color: theme.contextMenu.primaryColor) - }, action: { _, a in - a(.default) - }))) + + if component.slice.item.storyItem.isPublic { + items.append(.action(ContextMenuActionItem(text: "Copy link", icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Link"), color: theme.contextMenu.primaryColor) + }, action: { _, a in + a(.default) + }))) + items.append(.action(ContextMenuActionItem(text: "Share", icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Forward"), color: theme.contextMenu.primaryColor) + }, action: { _, a in + a(.default) + }))) + } let presentationData = component.context.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: component.theme) let contextController = ContextController(account: component.context.account, presentationData: presentationData, source: .reference(HeaderContextReferenceContentSource(controller: controller, sourceView: sourceView)), items: .single(ContextController.Items(content: .list(items))), gesture: gesture) @@ -993,7 +1045,7 @@ public final class StoryItemSetContainerComponent: Component { } self.contextController = contextController self.updateIsProgressPaused() - controller.present(contextController, in: .window(.root))*/ + controller.present(contextController, in: .window(.root)) } )), environment: {}, @@ -1479,7 +1531,7 @@ public final class StoryItemSetContainerComponent: Component { ) if let inlineActionsView = self.inlineActions.view { if inlineActionsView.superview == nil { - self.contentContainerView.addSubview(inlineActionsView) + //self.contentContainerView.addSubview(inlineActionsView) } transition.setFrame(view: inlineActionsView, frame: CGRect(origin: CGPoint(x: contentFrame.width - 10.0 - inlineActionsSize.width, y: contentFrame.height - 20.0 - inlineActionsSize.height), size: inlineActionsSize)) diff --git a/submodules/TelegramUI/Components/Stories/StoryContentComponent/Sources/StoryChatContent.swift b/submodules/TelegramUI/Components/Stories/StoryContentComponent/Sources/StoryChatContent.swift index d34240f1a5..cb3e4a5b4d 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContentComponent/Sources/StoryChatContent.swift +++ b/submodules/TelegramUI/Components/Stories/StoryContentComponent/Sources/StoryChatContent.swift @@ -124,11 +124,14 @@ public final class StoryContentContextImpl: StoryContentContext { var loadKeys: [StoryKey] = [] for index in (focusedIndex - 2) ... (focusedIndex + 2) { if index >= 0 && index < itemsView.items.count { - if let item = itemsView.items[focusedIndex].value.get(Stories.StoredItem.self), case .placeholder = item { + if let item = itemsView.items[index].value.get(Stories.StoredItem.self), case .placeholder = item { loadKeys.append(StoryKey(peerId: peerId, id: item.id)) } } } + if !loadKeys.isEmpty { + loadIds(loadKeys) + } if let item = itemsView.items[focusedIndex].value.get(Stories.StoredItem.self), case let .item(item) = item, let media = item.media { let mappedItem = EngineStoryItem( @@ -145,7 +148,10 @@ public final class StoryContentContextImpl: StoryContentContext { } ) }, - privacy: nil + privacy: item.privacy.flatMap(EngineStoryPrivacy.init), + isPinned: item.isPinned, + isExpired: item.isExpired, + isPublic: item.isPublic ) self.sliceValue = StoryContentContextState.FocusedSlice( @@ -584,7 +590,10 @@ public final class SingleStoryContentContextImpl: StoryContentContext { } ) }, - privacy: nil + privacy: itemValue.privacy.flatMap(EngineStoryPrivacy.init), + isPinned: itemValue.isPinned, + isExpired: itemValue.isExpired, + isPublic: itemValue.isPublic ) let stateValue = StoryContentContextState( @@ -666,3 +675,168 @@ public final class SingleStoryContentContextImpl: StoryContentContext { } } +public final class PeerStoryListContentContextImpl: StoryContentContext { + private let context: AccountContext + + public private(set) var stateValue: StoryContentContextState? + public var state: Signal { + return self.statePromise.get() + } + private let statePromise = Promise() + + private let updatedPromise = Promise() + public var updated: Signal { + return self.updatedPromise.get() + } + + private var storyDisposable: Disposable? + + private var requestedStoryKeys = Set() + private var requestStoryDisposables = DisposableSet() + + private var listState: PeerStoryListContext.State? + + private var focusedId: Int32? + private var focusedIdUpdated = Promise(Void()) + + public init(context: AccountContext, peerId: EnginePeer.Id, listContext: PeerStoryListContext, initialId: Int32?) { + self.context = context + + self.storyDisposable = (combineLatest(queue: .mainQueue(), + context.engine.data.subscribe(TelegramEngine.EngineData.Item.Peer.Peer(id: peerId)), + listContext.state, + self.focusedIdUpdated.get() + ) + |> deliverOnMainQueue).start(next: { [weak self] peer, state, _ in + guard let self else { + return + } + + self.listState = state + + let focusedIndex: Int? + if let current = self.focusedId { + if let index = state.items.firstIndex(where: { $0.id == current }) { + focusedIndex = index + } else if let index = state.items.firstIndex(where: { $0.id >= current }) { + focusedIndex = index + } else if !state.items.isEmpty { + focusedIndex = 0 + } else { + focusedIndex = nil + } + } else if let initialId = initialId { + if let index = state.items.firstIndex(where: { $0.id == initialId }) { + focusedIndex = index + } else if let index = state.items.firstIndex(where: { $0.id >= initialId }) { + focusedIndex = index + } else { + focusedIndex = nil + } + } else { + if !state.items.isEmpty { + focusedIndex = 0 + } else { + focusedIndex = nil + } + } + + let stateValue: StoryContentContextState + if let focusedIndex = focusedIndex, let peer = peer { + let item = state.items[focusedIndex] + self.focusedId = item.id + + stateValue = StoryContentContextState( + slice: StoryContentContextState.FocusedSlice( + peer: peer, + item: StoryContentItem( + id: AnyHashable(item.id), + position: focusedIndex, + component: AnyComponent(StoryItemContentComponent( + context: context, + peer: peer, + item: item + )), + centerInfoComponent: AnyComponent(StoryAuthorInfoComponent( + context: context, + peer: peer, + timestamp: item.timestamp + )), + rightInfoComponent: AnyComponent(StoryAvatarInfoComponent( + context: context, + peer: peer + )), + peerId: peer.id, + storyItem: item, + preload: nil, + delete: { + }, + markAsSeen: { + }, + hasLike: false, + isMy: peerId == self.context.account.peerId + ), + totalCount: state.totalCount, + previousItemId: focusedIndex == 0 ? nil : state.items[focusedIndex - 1].id, + nextItemId: (focusedIndex == state.items.count - 1) ? nil : state.items[focusedIndex + 1].id + ), + previousSlice: nil, + nextSlice: nil + ) + } else { + self.focusedId = nil + + stateValue = StoryContentContextState( + slice: nil, + previousSlice: nil, + nextSlice: nil + ) + } + + if self.stateValue == nil || self.stateValue?.slice != stateValue.slice { + self.stateValue = stateValue + self.statePromise.set(.single(stateValue)) + self.updatedPromise.set(.single(Void())) + } + }) + } + + deinit { + self.storyDisposable?.dispose() + self.requestStoryDisposables.dispose() + } + + public func resetSideStates() { + } + + public func navigate(navigation: StoryContentContextNavigation) { + switch navigation { + case .peer: + break + case let .item(direction): + let indexDifference: Int + switch direction { + case .next: + indexDifference = 1 + case .previous: + indexDifference = -1 + } + + if let listState = self.listState, let focusedId = self.focusedId { + if let index = listState.items.firstIndex(where: { $0.id == focusedId }) { + var nextIndex = index + indexDifference + if nextIndex < 0 { + nextIndex = 0 + } + if nextIndex > listState.items.count - 1 { + nextIndex = listState.items.count - 1 + } + if nextIndex != index { + self.focusedId = listState.items[nextIndex].id + self.focusedIdUpdated.set(.single(Void())) + } + } + } + } + } +} diff --git a/submodules/TelegramUI/Components/Stories/StoryContentComponent/Sources/StoryItemContentComponent.swift b/submodules/TelegramUI/Components/Stories/StoryContentComponent/Sources/StoryItemContentComponent.swift index 78ea5c2d5b..9221c82b58 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContentComponent/Sources/StoryItemContentComponent.swift +++ b/submodules/TelegramUI/Components/Stories/StoryContentComponent/Sources/StoryItemContentComponent.swift @@ -363,11 +363,18 @@ final class StoryItemContentComponent: Component { case let .file(file): self.contentLoaded = true - signal = chatMessageVideo( + signal = mediaGridMessageVideo( postbox: component.context.account.postbox, userLocation: .other, videoReference: .story(peer: peerReference, id: component.item.id, media: file), - synchronousLoad: true + onlyFullSize: false, + useLargeThumbnail: true, + synchronousLoad: true, + autoFetchFullSizeThumbnail: true, + overlayColor: nil, + nilForEmptyResult: false, + useMiniThumbnailIfAvailable: false, + blurred: false ) fetchSignal = fetchedMediaResource( mediaBox: component.context.account.postbox.mediaBox, @@ -397,6 +404,10 @@ final class StoryItemContentComponent: Component { wasSynchronous = false } + #if DEBUG + self.performActionAfterImageContentLoaded(update: false) + #endif + self.fetchDisposable?.dispose() self.fetchDisposable = nil if let fetchSignal { diff --git a/submodules/TelegramUI/Sources/PeerInfo/PeerInfoPaneContainerNode.swift b/submodules/TelegramUI/Sources/PeerInfo/PeerInfoPaneContainerNode.swift index da7240e65a..71884a94e7 100644 --- a/submodules/TelegramUI/Sources/PeerInfo/PeerInfoPaneContainerNode.swift +++ b/submodules/TelegramUI/Sources/PeerInfo/PeerInfoPaneContainerNode.swift @@ -368,8 +368,7 @@ private final class PeerInfoPendingPane { let paneNode: PeerInfoPaneNode switch key { case .stories: - //let visualPaneNode = PeerInfoStoryPaneNode(context: context, chatControllerInteraction: chatControllerInteraction, peerId: peerId, chatLocation: chatLocation, chatLocationContextHolder: chatLocationContextHolder, contentType: .photoOrVideo, captureProtected: captureProtected) - let visualPaneNode = PeerInfoVisualMediaPaneNode(context: context, chatControllerInteraction: chatControllerInteraction, peerId: peerId, chatLocation: chatLocation, chatLocationContextHolder: chatLocationContextHolder, contentType: .photoOrVideo, captureProtected: captureProtected) + let visualPaneNode = PeerInfoStoryPaneNode(context: context, chatControllerInteraction: chatControllerInteraction, peerId: peerId, chatLocation: chatLocation, chatLocationContextHolder: chatLocationContextHolder, contentType: .photoOrVideo, captureProtected: captureProtected) paneNode = visualPaneNode visualPaneNode.openCurrentDate = { openMediaCalendar()