diff --git a/submodules/ChatListUI/Sources/ChatListController.swift b/submodules/ChatListUI/Sources/ChatListController.swift index eb74f33398..ed97b98756 100644 --- a/submodules/ChatListUI/Sources/ChatListController.swift +++ b/submodules/ChatListUI/Sources/ChatListController.swift @@ -1848,6 +1848,9 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController if let size = info.size { fetchRange = (0 ..< Int64(size), .default) } + #if DEBUG + fetchRange = nil + #endif self.preloadStoryResourceDisposables[resource.resource.id] = fetchedMediaResource(mediaBox: self.context.account.postbox.mediaBox, userLocation: .other, userContentType: .other, reference: resource, range: fetchRange).start() } } @@ -2516,6 +2519,51 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController self.push(storyContainerScreen) }) } + + componentView.storyContextPeerAction = { [weak self] sourceNode, gesture, peer in + guard let self else { + return + } + + var items: [ContextMenuItem] = [] + + //TODO:localize + items.append(.action(ContextMenuActionItem(text: "View Profile", icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/User"), color: theme.contextMenu.primaryColor) + }, action: { [weak self] c, _ in + c.dismiss(completion: { + guard let self else { + return + } + + let _ = (self.context.engine.data.get( + TelegramEngine.EngineData.Item.Peer.Peer(id: peer.id) + ) + |> deliverOnMainQueue).start(next: { [weak self] peer in + guard let self else { + return + } + guard let peer = peer, let controller = context.sharedContext.makePeerInfoController(context: self.context, updatedPresentationData: nil, peer: peer._asPeer(), mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) else { + return + } + (self.navigationController as? NavigationController)?.pushViewController(controller) + }) + }) + }))) + items.append(.action(ContextMenuActionItem(text: "Mute", icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Unmute"), color: theme.contextMenu.primaryColor) + }, action: { _, f in + f(.default) + }))) + items.append(.action(ContextMenuActionItem(text: "Archive", icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Archive"), color: theme.contextMenu.primaryColor) + }, action: { _, f in + f(.default) + }))) + + let controller = ContextController(account: self.context.account, presentationData: self.presentationData, source: .extracted(ChatListHeaderBarContextExtractedContentSource(controller: self, sourceNode: sourceNode, keepInPlace: false)), items: .single(ContextController.Items(content: .list(items))), recognizer: nil, gesture: gesture) + self.context.sharedContext.mainWindow?.presentInGlobalOverlay(controller) + } } } diff --git a/submodules/ChatListUI/Sources/ChatListControllerNode.swift b/submodules/ChatListUI/Sources/ChatListControllerNode.swift index a3076a8d6b..c222bb25d8 100644 --- a/submodules/ChatListUI/Sources/ChatListControllerNode.swift +++ b/submodules/ChatListUI/Sources/ChatListControllerNode.swift @@ -914,34 +914,30 @@ public final class ChatListContainerNode: ASDisplayNode, UIGestureRecognizerDele if itemNode.listNode.isTracking { if case let .known(value) = offset { if !self.storiesUnlocked { - if value < -1.0 { + if value < -50.0 { self.storiesUnlocked = true DispatchQueue.main.async { [weak self] in guard let self else { return } + + HapticFeedback().impact() + self.currentItemNode.ignoreStoryInsetAdjustment = true + self.currentItemNode.allowInsetFixWhileTracking = true self.onStoriesLockedUpdated?(true) self.currentItemNode.ignoreStoryInsetAdjustment = false + self.currentItemNode.allowInsetFixWhileTracking = false } } } } - } else { + } else if self.storiesUnlocked { 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) - } - } + self.storiesUnlocked = false + self.onStoriesLockedUpdated?(false) } default: break @@ -957,7 +953,6 @@ public final class ChatListContainerNode: ASDisplayNode, UIGestureRecognizerDele if value > 94.0 { if self.storiesUnlocked { self.storiesUnlocked = false - self.currentItemNode.stopScrolling() DispatchQueue.main.async { [weak self] in guard let self else { @@ -1720,7 +1715,8 @@ final class ChatListControllerNode: ASDisplayNode, UIGestureRecognizerDelegate { guard let self else { return } - self.controller?.requestLayout(transition: .immediate) + //self.controller?.requestLayout(transition: .immediate) + self.controller?.requestLayout(transition: .animated(duration: 0.4, curve: .spring)) } let inlineContentPanRecognizer = InteractiveTransitionGestureRecognizer(target: self, action: #selector(self.inlineContentPanGesture(_:)), allowedDirections: { [weak self] _ in diff --git a/submodules/ChatListUI/Sources/Node/ChatListNode.swift b/submodules/ChatListUI/Sources/Node/ChatListNode.swift index 937c31d07a..ff1e73fcbb 100644 --- a/submodules/ChatListUI/Sources/Node/ChatListNode.swift +++ b/submodules/ChatListUI/Sources/Node/ChatListNode.swift @@ -1214,6 +1214,8 @@ public final class ChatListNode: ListView { super.init() + self.useMainQueueTransactions = true + self.verticalScrollIndicatorColor = theme.list.scrollIndicatorColor self.verticalScrollIndicatorFollowsOverscroll = true @@ -3128,6 +3130,7 @@ public final class ChatListNode: ListView { } var options = transition.options + options.insert(.Synchronous) if self.view.window != nil { if !options.contains(.AnimateInsertion) { options.insert(.PreferSynchronousDrawing) diff --git a/submodules/ComponentFlow/Source/Base/Transition.swift b/submodules/ComponentFlow/Source/Base/Transition.swift index 98f8ff7d88..6441f10783 100644 --- a/submodules/ComponentFlow/Source/Base/Transition.swift +++ b/submodules/ComponentFlow/Source/Base/Transition.swift @@ -212,6 +212,62 @@ public struct Transition { } } + public func setFrameWithAdditivePosition(view: UIView, frame: CGRect, completion: ((Bool) -> Void)? = nil) { + assert(view.layer.anchorPoint == CGPoint()) + + if view.frame == frame { + completion?(true) + return + } + + var completedBounds: Bool? + var completedPosition: Bool? + let processCompletion: () -> Void = { + guard let completedBounds, let completedPosition else { + return + } + completion?(completedBounds && completedPosition) + } + + self.setBounds(view: view, bounds: CGRect(origin: view.bounds.origin, size: frame.size), completion: { value in + completedBounds = value + processCompletion() + }) + self.animatePosition(view: view, from: CGPoint(x: -frame.minX + view.layer.position.x, y: -frame.minY + view.layer.position.y), to: CGPoint(), additive: true, completion: { value in + completedPosition = value + processCompletion() + }) + view.layer.position = frame.origin + } + + public func setFrameWithAdditivePosition(layer: CALayer, frame: CGRect, completion: ((Bool) -> Void)? = nil) { + assert(layer.anchorPoint == CGPoint()) + + if layer.frame == frame { + completion?(true) + return + } + + var completedBounds: Bool? + var completedPosition: Bool? + let processCompletion: () -> Void = { + guard let completedBounds, let completedPosition else { + return + } + completion?(completedBounds && completedPosition) + } + + self.setBounds(layer: layer, bounds: CGRect(origin: layer.bounds.origin, size: frame.size), completion: { value in + completedBounds = value + processCompletion() + }) + self.animatePosition(layer: layer, from: CGPoint(x: -frame.minX + layer.position.x, y: -frame.minY + layer.position.y), to: CGPoint(), additive: true, completion: { value in + completedPosition = value + processCompletion() + }) + layer.position = frame.origin + } + public func setBounds(view: UIView, bounds: CGRect, completion: ((Bool) -> Void)? = nil) { if view.bounds == bounds { completion?(true) diff --git a/submodules/Display/Source/ListView.swift b/submodules/Display/Source/ListView.swift index d6c318d6e0..3ee4e6965c 100644 --- a/submodules/Display/Source/ListView.swift +++ b/submodules/Display/Source/ListView.swift @@ -206,6 +206,7 @@ open class ListView: ASDisplayNode, UIScrollViewAccessibilityDelegate, UIGesture public final var dynamicBounceEnabled = true public final var rotated = false public final var experimentalSnapScrollToItem = false + public final var useMainQueueTransactions = false public final var scrollEnabled: Bool = true { didSet { @@ -250,6 +251,7 @@ open class ListView: ASDisplayNode, UIScrollViewAccessibilityDelegate, UIGesture } } public final var snapToBottomInsetUntilFirstInteraction: Bool = false + public final var allowInsetFixWhileTracking: Bool = false public final var updateFloatingHeaderOffset: ((CGFloat, ContainedViewLayoutTransition) -> Void)? public final var didScrollWithOffset: ((CGFloat, ContainedViewLayoutTransition, ListViewItemNode?, Bool) -> Void)? @@ -595,7 +597,11 @@ open class ListView: ASDisplayNode, UIScrollViewAccessibilityDelegate, UIGesture action() } }*/ - DispatchQueue.main.async(execute: action) + if self.useMainQueueTransactions && Thread.isMainThread { + action() + } else { + DispatchQueue.main.async(execute: action) + } } private func beginReordering(itemNode: ListViewItemNode) { @@ -980,7 +986,13 @@ open class ListView: ASDisplayNode, UIScrollViewAccessibilityDelegate, UIGesture self.trackingOffset += -deltaY } - self.enqueueUpdateVisibleItems(synchronous: false) + if self.useMainQueueTransactions { + DispatchQueue.main.async { [weak self] in + self?.enqueueUpdateVisibleItems(synchronous: false) + } + } else { + self.enqueueUpdateVisibleItems(synchronous: false) + } var useScrollDynamics = false @@ -1630,19 +1642,29 @@ open class ListView: ASDisplayNode, UIScrollViewAccessibilityDelegate, UIGesture let wasIgnoringScrollingEvents = self.ignoreScrollingEvents self.ignoreScrollingEvents = true if topItemFound && bottomItemFound { - self.scroller.contentSize = CGSize(width: self.visibleSize.width, height: completeHeight) + if self.scroller.contentSize != CGSize(width: self.visibleSize.width, height: infiniteScrollSize * 2.0) { + self.scroller.contentSize = CGSize(width: self.visibleSize.width, height: completeHeight) + } self.lastContentOffset = CGPoint(x: 0.0, y: -topItemEdge) - self.scroller.contentOffset = self.lastContentOffset + if self.scroller.contentOffset != self.lastContentOffset { + self.scroller.contentOffset = self.lastContentOffset + } } else if topItemFound { - self.scroller.contentSize = CGSize(width: self.visibleSize.width, height: infiniteScrollSize * 2.0) + if self.scroller.contentSize != CGSize(width: self.visibleSize.width, height: infiniteScrollSize * 2.0) { + self.scroller.contentSize = CGSize(width: self.visibleSize.width, height: infiniteScrollSize * 2.0) + } self.lastContentOffset = CGPoint(x: 0.0, y: -topItemEdge) if self.scroller.contentOffset != self.lastContentOffset { self.scroller.contentOffset = self.lastContentOffset } } else if bottomItemFound { - self.scroller.contentSize = CGSize(width: self.visibleSize.width, height: infiniteScrollSize * 2.0) + if self.scroller.contentSize != CGSize(width: self.visibleSize.width, height: infiniteScrollSize * 2.0) { + self.scroller.contentSize = CGSize(width: self.visibleSize.width, height: infiniteScrollSize * 2.0) + } self.lastContentOffset = CGPoint(x: 0.0, y: infiniteScrollSize * 2.0 - bottomItemEdge) - self.scroller.contentOffset = self.lastContentOffset + if self.scroller.contentOffset != self.lastContentOffset { + self.scroller.contentOffset = self.lastContentOffset + } } else if self.itemNodes.isEmpty { self.scroller.contentSize = self.visibleSize if self.lastContentOffset.y == infiniteScrollSize && self.scroller.contentOffset.y.isZero { @@ -1650,10 +1672,14 @@ open class ListView: ASDisplayNode, UIScrollViewAccessibilityDelegate, UIGesture self.lastContentOffset = .zero } } else { - self.scroller.contentSize = CGSize(width: self.visibleSize.width, height: infiniteScrollSize * 2.0) + if self.scroller.contentSize != CGSize(width: self.visibleSize.width, height: infiniteScrollSize * 2.0) { + self.scroller.contentSize = CGSize(width: self.visibleSize.width, height: infiniteScrollSize * 2.0) + } if abs(self.scroller.contentOffset.y - infiniteScrollSize) > infiniteScrollSize / 2.0 { self.lastContentOffset = CGPoint(x: 0.0, y: infiniteScrollSize) - self.scroller.contentOffset = self.lastContentOffset + if self.scroller.contentOffset != self.lastContentOffset { + self.scroller.contentOffset = self.lastContentOffset + } } else { self.lastContentOffset = self.scroller.contentOffset } @@ -1662,8 +1688,15 @@ open class ListView: ASDisplayNode, UIScrollViewAccessibilityDelegate, UIGesture } private func async(_ f: @escaping () -> Void) { - DispatchQueue.global(qos: .userInteractive).async(execute: f) - //DispatchQueue.main.async(execute: f) + if self.useMainQueueTransactions { + if Thread.isMainThread { + f() + } else { + DispatchQueue.main.async(execute: f) + } + } else { + DispatchQueue.global(qos: .userInteractive).async(execute: f) + } } private func nodeForItem(synchronous: Bool, synchronousLoads: Bool, item: ListViewItem, previousNode: QueueLocalObject?, index: Int, previousItem: ListViewItem?, nextItem: ListViewItem?, params: ListViewItemLayoutParams, updateAnimationIsAnimated: Bool, updateAnimationIsCrossfade: Bool, completion: @escaping (QueueLocalObject, ListViewItemNodeLayout, @escaping () -> (Signal?, (ListViewItemApply) -> Void)) -> Void) { @@ -2951,7 +2984,7 @@ open class ListView: ASDisplayNode, UIScrollViewAccessibilityDelegate, UIGesture var offsetFix: CGFloat let insetDeltaOffsetFix: CGFloat = 0.0 - if self.isTracking || isExperimentalSnapToScrollToItem { + if (self.isTracking && !self.allowInsetFixWhileTracking) || isExperimentalSnapToScrollToItem { offsetFix = 0.0 } else if self.snapToBottomInsetUntilFirstInteraction { offsetFix = -updateSizeAndInsets.insets.bottom + self.insets.bottom diff --git a/submodules/Display/Source/NavigationBar.swift b/submodules/Display/Source/NavigationBar.swift index add9fe57a9..399ce1b9f0 100644 --- a/submodules/Display/Source/NavigationBar.swift +++ b/submodules/Display/Source/NavigationBar.swift @@ -1450,7 +1450,7 @@ open class NavigationBar: ASDisplayNode { if let titleView = titleView as? NavigationBarTitleView { let titleWidth = size.width - (leftTitleInset > 0.0 ? leftTitleInset : rightTitleInset) - (rightTitleInset > 0.0 ? rightTitleInset : leftTitleInset) - let _ = titleView.updateLayout(size: titleFrame.size, clearBounds: CGRect(origin: CGPoint(x: leftTitleInset - titleFrame.minX, y: 0.0), size: CGSize(width: titleWidth, height: titleFrame.height)), sideContentWidth: 0.0, transition: titleViewTransition) + let _ = titleView.updateLayout(size: titleFrame.size, clearBounds: CGRect(origin: CGPoint(x: leftTitleInset - titleFrame.minX, y: 0.0), size: CGSize(width: titleWidth, height: titleFrame.height)), transition: titleViewTransition) } if let transitionState = self.transitionState, let otherNavigationBar = transitionState.navigationBar { diff --git a/submodules/Display/Source/NavigationBarTitleView.swift b/submodules/Display/Source/NavigationBarTitleView.swift index 38c838325b..408cfb2f61 100644 --- a/submodules/Display/Source/NavigationBarTitleView.swift +++ b/submodules/Display/Source/NavigationBarTitleView.swift @@ -4,5 +4,5 @@ import UIKit public protocol NavigationBarTitleView { func animateLayoutTransition() - func updateLayout(size: CGSize, clearBounds: CGRect, sideContentWidth: CGFloat, transition: ContainedViewLayoutTransition) -> CGFloat + func updateLayout(size: CGSize, clearBounds: CGRect, transition: ContainedViewLayoutTransition) -> CGRect } diff --git a/submodules/GalleryUI/Sources/GalleryTitleView.swift b/submodules/GalleryUI/Sources/GalleryTitleView.swift index ec83cb3c4f..8fd42e7c49 100644 --- a/submodules/GalleryUI/Sources/GalleryTitleView.swift +++ b/submodules/GalleryUI/Sources/GalleryTitleView.swift @@ -41,7 +41,7 @@ final class GalleryTitleView: UIView, NavigationBarTitleView { self.dateNode.attributedText = NSAttributedString(string: dateText, font: dateFont, textColor: .white) } - func updateLayout(size: CGSize, clearBounds: CGRect, sideContentWidth: CGFloat, transition: ContainedViewLayoutTransition) -> CGFloat { + func updateLayout(size: CGSize, clearBounds: CGRect, transition: ContainedViewLayoutTransition) -> CGRect { let leftInset: CGFloat = 0.0 let rightInset: CGFloat = 0.0 @@ -56,7 +56,7 @@ final class GalleryTitleView: UIView, NavigationBarTitleView { self.dateNode.frame = CGRect(origin: CGPoint(x: floor((size.width - dateSize.width) / 2.0), y: floor((size.height - dateSize.height - authorNameSize.height - labelsSpacing) / 2.0) + authorNameSize.height + labelsSpacing), size: dateSize) } - return 0.0 + return CGRect() } func animateLayoutTransition() { diff --git a/submodules/ItemListUI/Sources/ItemListController.swift b/submodules/ItemListUI/Sources/ItemListController.swift index 7fb0a41ff9..23ab855131 100644 --- a/submodules/ItemListUI/Sources/ItemListController.swift +++ b/submodules/ItemListUI/Sources/ItemListController.swift @@ -636,7 +636,7 @@ private final class ItemListTextWithSubtitleTitleView: UIView, NavigationBarTitl self.titleNode.attributedText = NSAttributedString(string: self.titleNode.attributedText?.string ?? "", font: Font.medium(17.0), textColor: theme.rootController.navigationBar.primaryTextColor) self.subtitleNode.attributedText = NSAttributedString(string: self.subtitleNode.attributedText?.string ?? "", font: Font.regular(13.0), textColor: theme.rootController.navigationBar.secondaryTextColor) if let (size, clearBounds) = self.validLayout { - let _ = self.updateLayout(size: size, clearBounds: clearBounds, sideContentWidth: 0.0, transition: .immediate) + let _ = self.updateLayout(size: size, clearBounds: clearBounds, transition: .immediate) } } @@ -644,11 +644,11 @@ private final class ItemListTextWithSubtitleTitleView: UIView, NavigationBarTitl super.layoutSubviews() if let (size, clearBounds) = self.validLayout { - let _ = self.updateLayout(size: size, clearBounds: clearBounds, sideContentWidth: 0.0, transition: .immediate) + let _ = self.updateLayout(size: size, clearBounds: clearBounds, transition: .immediate) } } - func updateLayout(size: CGSize, clearBounds: CGRect, sideContentWidth: CGFloat, transition: ContainedViewLayoutTransition) -> CGFloat { + func updateLayout(size: CGSize, clearBounds: CGRect, transition: ContainedViewLayoutTransition) -> CGRect { self.validLayout = (size, clearBounds) let titleSize = self.titleNode.updateLayout(size) @@ -661,7 +661,7 @@ private final class ItemListTextWithSubtitleTitleView: UIView, NavigationBarTitl self.titleNode.frame = titleFrame self.subtitleNode.frame = subtitleFrame - return titleSize.width + return titleFrame } func animateLayoutTransition() { diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Messages/Stories.swift b/submodules/TelegramCore/Sources/TelegramEngine/Messages/Stories.swift index d6c9a0356f..5498de0bde 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Messages/Stories.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Messages/Stories.swift @@ -1036,3 +1036,224 @@ func _internal_getStoryViews(account: Account, ids: [Int32]) -> Signal<[Int32: S } } } + +public final class EngineStoryViewListContext { + public struct LoadMoreToken: Equatable { + var id: Int64 + var timestamp: Int32 + } + + public final class Item: Equatable { + public let peer: EnginePeer + public let timestamp: Int32 + + public init( + peer: EnginePeer, + timestamp: Int32 + ) { + self.peer = peer + self.timestamp = timestamp + } + + public static func ==(lhs: Item, rhs: Item) -> Bool { + if lhs.peer != rhs.peer { + return false + } + if lhs.timestamp != rhs.timestamp { + return false + } + return true + } + } + + public struct State: Equatable { + public var totalCount: Int + public var items: [Item] + public var loadMoreToken: LoadMoreToken? + + public init( + totalCount: Int, + items: [Item], + loadMoreToken: LoadMoreToken? + ) { + self.totalCount = totalCount + self.items = items + self.loadMoreToken = loadMoreToken + } + } + + private final class Impl { + struct NextOffset: Equatable { + var id: Int64 + var timestamp: Int32 + } + + struct InternalState: Equatable { + var totalCount: Int + var items: [Item] + var canLoadMore: Bool + var nextOffset: NextOffset? + } + + let queue: Queue + + let account: Account + let storyId: Int32 + + let disposable = MetaDisposable() + + var state: InternalState + let statePromise = Promise() + + var isLoadingMore: Bool = false + + init(queue: Queue, account: Account, storyId: Int32, views: EngineStoryItem.Views) { + self.queue = queue + self.account = account + self.storyId = storyId + + let initialState = State(totalCount: views.seenCount, items: [], loadMoreToken: LoadMoreToken(id: 0, timestamp: 0)) + self.state = InternalState(totalCount: initialState.totalCount, items: initialState.items, canLoadMore: initialState.loadMoreToken != nil, nextOffset: nil) + self.statePromise.set(.single(self.state)) + + if initialState.loadMoreToken != nil { + self.loadMore() + } + } + + deinit { + assert(self.queue.isCurrent()) + + self.disposable.dispose() + } + + func loadMore() { + if self.isLoadingMore { + return + } + self.isLoadingMore = true + + let account = self.account + let storyId = self.storyId + let currentOffset = self.state.nextOffset + let limit = self.state.items.isEmpty ? 50 : 100 + let signal: Signal = self.account.postbox.transaction { transaction -> Void in + } + |> mapToSignal { _ -> Signal in + return account.network.request(Api.functions.stories.getStoryViewsList(id: storyId, offsetDate: currentOffset?.timestamp ?? 0, offsetId: currentOffset?.id ?? 0, limit: Int32(limit))) + |> map(Optional.init) + |> `catch` { _ -> Signal in + return .single(nil) + } + |> mapToSignal { result -> Signal in + return account.postbox.transaction { transaction -> InternalState in + switch result { + case let .storyViewsList(count, views, users): + 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) + + var items: [Item] = [] + var nextOffset: NextOffset? + for view in views { + switch view { + case let .storyView(userId, date): + if let peer = transaction.getPeer(PeerId(namespace: Namespaces.Peer.CloudUser, id: PeerId.Id._internalFromInt64Value(userId))) { + items.append(Item(peer: EnginePeer(peer), timestamp: date)) + + nextOffset = NextOffset(id: userId, timestamp: date) + } + } + } + + return InternalState(totalCount: Int(count), items: items, canLoadMore: nextOffset != nil, nextOffset: nextOffset) + case .none: + return InternalState(totalCount: 0, items: [], canLoadMore: false, nextOffset: nil) + } + } + } + } + self.disposable.set((signal + |> deliverOn(self.queue)).start(next: { [weak self] state in + guard let strongSelf = self else { + return + } + + struct ItemHash: Hashable { + var peerId: EnginePeer.Id + } + + var existingItems = Set() + for item in strongSelf.state.items { + existingItems.insert(ItemHash(peerId: item.peer.id)) + } + + for item in state.items { + let itemHash = ItemHash(peerId: item.peer.id) + if existingItems.contains(itemHash) { + continue + } + existingItems.insert(itemHash) + strongSelf.state.items.append(item) + } + if state.canLoadMore { + strongSelf.state.totalCount = max(state.totalCount, strongSelf.state.items.count) + } else { + strongSelf.state.totalCount = strongSelf.state.items.count + } + strongSelf.state.canLoadMore = state.canLoadMore + strongSelf.state.nextOffset = state.nextOffset + + strongSelf.isLoadingMore = false + strongSelf.statePromise.set(.single(strongSelf.state)) + })) + } + } + + private let queue: Queue + private let impl: QueueLocalObject + + public var state: Signal { + return Signal { subscriber in + let disposable = MetaDisposable() + self.impl.with { impl in + disposable.set(impl.statePromise.get().start(next: { state in + var loadMoreToken: LoadMoreToken? + if let nextOffset = state.nextOffset { + loadMoreToken = LoadMoreToken(id: nextOffset.id, timestamp: nextOffset.timestamp) + } + subscriber.putNext(State( + totalCount: state.totalCount, + items: state.items, + loadMoreToken: loadMoreToken + )) + })) + } + return disposable + } + } + + init(account: Account, storyId: Int32, views: EngineStoryItem.Views) { + let queue = Queue() + self.queue = queue + self.impl = QueueLocalObject(queue: queue, generate: { + return Impl(queue: queue, account: account, storyId: storyId, views: views) + }) + } + + public func loadMore() { + self.impl.with { impl in + impl.loadMore() + } + } +} diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift b/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift index 4667de9938..f7364df71a 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift @@ -878,5 +878,9 @@ public extension TelegramEngine { 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) } + + public func storyViewList(id: Int32, views: EngineStoryItem.Views) -> EngineStoryViewListContext { + return EngineStoryViewListContext(account: self.account, storyId: id, views: views) + } } } diff --git a/submodules/TelegramUI/Components/ChatListHeaderComponent/Sources/ChatListHeaderComponent.swift b/submodules/TelegramUI/Components/ChatListHeaderComponent/Sources/ChatListHeaderComponent.swift index 568edf6e6d..bb8c50dba1 100644 --- a/submodules/TelegramUI/Components/ChatListHeaderComponent/Sources/ChatListHeaderComponent.swift +++ b/submodules/TelegramUI/Components/ChatListHeaderComponent/Sources/ChatListHeaderComponent.swift @@ -293,6 +293,7 @@ public final class ChatListHeaderComponent: Component { var contentOffsetFraction: CGFloat = 0.0 private(set) var centerContentWidth: CGFloat = 0.0 + private(set) var centerContentOffsetX: CGFloat = 0.0 init( backPressed: @escaping () -> Void, @@ -440,7 +441,7 @@ public final class ChatListHeaderComponent: Component { } } - func update(context: AccountContext, theme: PresentationTheme, strings: PresentationStrings, content: Content, backTitle: String?, sideInset: CGFloat, sideContentWidth: CGFloat, size: CGSize, transition: Transition) { + func update(context: AccountContext, theme: PresentationTheme, strings: PresentationStrings, content: Content, backTitle: String?, sideInset: CGFloat, sideContentWidth: CGFloat, sideContentFraction: CGFloat, size: CGSize, transition: Transition) { transition.setPosition(view: self.titleOffsetContainer, position: CGPoint(x: size.width * 0.5, y: size.height * 0.5)) transition.setBounds(view: self.titleOffsetContainer, bounds: CGRect(origin: self.titleOffsetContainer.bounds.origin, size: size)) @@ -616,6 +617,8 @@ public final class ChatListHeaderComponent: Component { } } + var centerContentWidth: CGFloat = 0.0 + var centerContentOffsetX: CGFloat = 0.0 if let chatListTitle = content.chatListTitle { var chatListTitleTransition = transition let chatListTitleView: ChatListTitleView @@ -633,8 +636,13 @@ public final class ChatListHeaderComponent: Component { chatListTitleView.theme = theme chatListTitleView.strings = strings chatListTitleView.setTitle(chatListTitle, animated: false) - let centerContentWidth = chatListTitleView.updateLayout(size: chatListTitleContentSize, clearBounds: CGRect(origin: CGPoint(), size: chatListTitleContentSize), sideContentWidth: sideContentWidth, transition: transition.containedViewLayoutTransition) - self.centerContentWidth = centerContentWidth + let titleContentRect = chatListTitleView.updateLayout(size: chatListTitleContentSize, clearBounds: CGRect(origin: CGPoint(), size: chatListTitleContentSize), transition: transition.containedViewLayoutTransition) + centerContentWidth = floor((chatListTitleContentSize.width * 0.5 - titleContentRect.minX) * 2.0) + + //sideWidth + centerWidth + centerOffset = size.width + //let centerOffset = -(size.width - (sideContentWidth + centerContentWidth)) * 0.5 + size.width * 0.5 + let centerOffset = sideContentWidth + centerContentOffsetX = -max(0.0, centerOffset + titleContentRect.maxX - 2.0 - rightOffset) chatListTitleView.openStatusSetup = { [weak self] sourceView in guard let self else { @@ -649,7 +657,14 @@ public final class ChatListHeaderComponent: Component { self.toggleIsLocked() } - chatListTitleTransition.setFrame(view: chatListTitleView, frame: CGRect(origin: CGPoint(x: floor((size.width - chatListTitleContentSize.width) / 2.0), y: floor((size.height - chatListTitleContentSize.height) / 2.0)), size: chatListTitleContentSize)) + let chatListTitleOffset: CGFloat + if chatListTitle.activity { + chatListTitleOffset = 0.0 + } else { + chatListTitleOffset = (centerOffset + centerContentOffsetX) * sideContentFraction + } + + chatListTitleTransition.setFrame(view: chatListTitleView, frame: CGRect(origin: CGPoint(x: chatListTitleOffset + floor((size.width - chatListTitleContentSize.width) / 2.0), y: floor((size.height - chatListTitleContentSize.height) / 2.0)), size: chatListTitleContentSize)) } else { if let chatListTitleView = self.chatListTitleView { self.chatListTitleView = nil @@ -658,6 +673,8 @@ public final class ChatListHeaderComponent: Component { } self.titleTextView.isHidden = self.chatListTitleView != nil || self.titleContentView != nil + self.centerContentWidth = centerContentWidth + self.centerContentOffsetX = centerContentOffsetX } } @@ -672,6 +689,7 @@ public final class ChatListHeaderComponent: Component { private let storyPeerListExternalState = StoryPeerListComponent.ExternalState() private var storyPeerList: ComponentView? public var storyPeerAction: ((EnginePeer?) -> Void)? + public var storyContextPeerAction: ((ContextExtractedContentContainingNode, ContextGesture, EnginePeer) -> Void)? private var effectiveContentView: ContentView? { return self.secondaryContentView ?? self.primaryContentView @@ -803,10 +821,16 @@ public final class ChatListHeaderComponent: Component { return } self.storyPeerAction?(peer) + }, + contextPeerAction: { [weak self] sourceNode, gesture, peer in + guard let self else { + return + } + self.storyContextPeerAction?(sourceNode, gesture, peer) } )), environment: {}, - containerSize: CGSize(width: self.bounds.width, height: 94.0) + containerSize: CGSize(width: availableSize.width, height: 94.0) ) } @@ -851,7 +875,7 @@ public final class ChatListHeaderComponent: Component { } } - primaryContentView.update(context: component.context, theme: component.theme, strings: component.strings, content: primaryContent, backTitle: primaryContent.backTitle, sideInset: component.sideInset, sideContentWidth: sideContentWidth * (1.0 - component.storiesFraction), size: availableSize, transition: primaryContentTransition) + primaryContentView.update(context: component.context, theme: component.theme, strings: component.strings, content: primaryContent, backTitle: primaryContent.backTitle, sideInset: component.sideInset, sideContentWidth: sideContentWidth, sideContentFraction: (1.0 - component.storiesFraction), size: availableSize, transition: primaryContentTransition) primaryContentTransition.setFrame(view: primaryContentView, frame: CGRect(origin: CGPoint(), size: availableSize)) primaryContentView.updateContentOffsetFraction(contentOffsetFraction: 1.0 - self.storyOffsetFraction, transition: primaryContentTransition) @@ -890,7 +914,7 @@ public final class ChatListHeaderComponent: Component { self.secondaryContentView = secondaryContentView self.addSubview(secondaryContentView) } - secondaryContentView.update(context: component.context, theme: component.theme, strings: component.strings, content: secondaryContent, backTitle: component.primaryContent?.navigationBackTitle ?? component.primaryContent?.title, sideInset: component.sideInset, sideContentWidth: 0.0, size: availableSize, transition: secondaryContentTransition) + secondaryContentView.update(context: component.context, theme: component.theme, strings: component.strings, content: secondaryContent, backTitle: component.primaryContent?.navigationBackTitle ?? component.primaryContent?.title, sideInset: component.sideInset, sideContentWidth: 0.0, sideContentFraction: 0.0, size: availableSize, transition: secondaryContentTransition) secondaryContentTransition.setFrame(view: secondaryContentView, frame: CGRect(origin: CGPoint(), size: availableSize)) secondaryContentView.updateContentOffsetFraction(contentOffsetFraction: 1.0 - self.storyOffsetFraction, transition: secondaryContentTransition) @@ -946,7 +970,7 @@ public final class ChatListHeaderComponent: Component { var defaultStoryListX: CGFloat = 0.0 if let primaryContentView = self.primaryContentView { - defaultStoryListX = floor((self.storyPeerListExternalState.collapsedWidth - primaryContentView.centerContentWidth) * 0.5) + defaultStoryListX = floor((self.storyPeerListExternalState.collapsedWidth - primaryContentView.centerContentWidth) * 0.5) + primaryContentView.centerContentOffsetX } storyListTransition.setFrame(view: storyPeerListComponentView, frame: CGRect(origin: CGPoint(x: -1.0 * availableSize.width * component.secondaryTransition + (1.0 - component.storiesFraction) * defaultStoryListX, y: storyPeerListPosition), size: CGSize(width: availableSize.width, height: 94.0))) diff --git a/submodules/TelegramUI/Components/ChatListHeaderComponent/Sources/ChatListNavigationBar.swift b/submodules/TelegramUI/Components/ChatListHeaderComponent/Sources/ChatListNavigationBar.swift index 2765bc117b..6aa8c7e51f 100644 --- a/submodules/TelegramUI/Components/ChatListHeaderComponent/Sources/ChatListNavigationBar.swift +++ b/submodules/TelegramUI/Components/ChatListHeaderComponent/Sources/ChatListNavigationBar.swift @@ -137,7 +137,9 @@ public final class ChatListNavigationBar: Component { override public init(frame: CGRect) { self.backgroundView = BlurredBackgroundView(color: .clear, enableBlur: true) + self.backgroundView.layer.anchorPoint = CGPoint(x: 0.0, y: 1.0) self.separatorLayer = SimpleLayer() + self.separatorLayer.anchorPoint = CGPoint() super.init(frame: frame) @@ -167,10 +169,7 @@ public final class ChatListNavigationBar: Component { } public func applyScroll(offset: CGFloat, transition: Transition) { - var transition = transition - if self.applyScrollFractionAnimator != nil { - transition = .immediate - } + let transition = transition self.rawScrollOffset = offset @@ -211,9 +210,13 @@ public final class ChatListNavigationBar: Component { 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))) + self.backgroundView.update(size: CGSize(width: visibleSize.width, height: 1000.0), transition: transition.containedViewLayoutTransition) + + transition.setBounds(view: self.backgroundView, bounds: CGRect(origin: CGPoint(), size: CGSize(width: visibleSize.width, height: 1000.0))) + transition.animatePosition(view: self.backgroundView, from: CGPoint(x: 0.0, y: -visibleSize.height + self.backgroundView.layer.position.y), to: CGPoint(), additive: true) + self.backgroundView.layer.position = CGPoint(x: 0.0, y: visibleSize.height) + + transition.setFrameWithAdditivePosition(layer: self.separatorLayer, frame: CGRect(origin: CGPoint(x: 0.0, y: visibleSize.height), size: CGSize(width: visibleSize.width, height: UIScreenPixel))) let searchContentNode: NavigationBarSearchContentNode if let current = self.searchContentNode { @@ -247,6 +250,7 @@ public final class ChatListNavigationBar: Component { component.activateSearch(searchContentNode) } ) + searchContentNode.view.layer.anchorPoint = CGPoint() self.searchContentNode = searchContentNode self.addSubview(searchContentNode.view) } @@ -273,11 +277,17 @@ public final class ChatListNavigationBar: Component { let searchOffsetFraction = clippedSearchOffset / searchOffsetDistance searchContentNode.expansionProgress = 1.0 - searchOffsetFraction - transition.setFrame(view: searchContentNode.view, frame: searchFrame) + transition.setFrameWithAdditivePosition(view: searchContentNode.view, frame: searchFrame) + searchContentNode.updateLayout(size: searchSize, leftInset: component.sideInset, rightInset: component.sideInset, transition: transition.containedViewLayoutTransition) + var headerTransition = transition + if self.applyScrollFractionAnimator != nil { + headerTransition = .immediate + } + let headerContentSize = self.headerContent.update( - transition: transition, + transition: headerTransition, component: AnyComponent(ChatListHeaderComponent( sideInset: component.sideInset + 16.0, primaryContent: component.primaryContent, @@ -318,9 +328,10 @@ public final class ChatListNavigationBar: Component { let headerContentFrame = CGRect(origin: CGPoint(x: 0.0, y: headerContentY), size: headerContentSize) if let headerContentView = self.headerContent.view { if headerContentView.superview == nil { + headerContentView.layer.anchorPoint = CGPoint() self.addSubview(headerContentView) } - transition.setFrame(view: headerContentView, frame: headerContentFrame) + transition.setFrameWithAdditivePosition(view: headerContentView, frame: headerContentFrame) } if component.tabsNode !== self.tabsNode { @@ -342,7 +353,8 @@ public final class ChatListNavigationBar: Component { 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) + disappearingTabsView.layer.anchorPoint = CGPoint() + transition.setFrameWithAdditivePosition(view: disappearingTabsView, frame: tabsFrame) } if let tabsNode = component.tabsNode { @@ -350,6 +362,7 @@ public final class ChatListNavigationBar: Component { var tabsNodeTransition = transition if tabsNode.view.superview !== self { + tabsNode.view.layer.anchorPoint = CGPoint() tabsNodeTransition = .immediate self.addSubview(tabsNode.view) if !transition.animation.isImmediate { @@ -359,7 +372,7 @@ public final class ChatListNavigationBar: Component { } } - tabsNodeTransition.setFrame(view: tabsNode.view, frame: tabsFrame) + tabsNodeTransition.setFrameWithAdditivePosition(view: tabsNode.view, frame: tabsFrame) } } diff --git a/submodules/TelegramUI/Components/ChatListTitleView/Sources/ChatListTitleView.swift b/submodules/TelegramUI/Components/ChatListTitleView/Sources/ChatListTitleView.swift index 1792246625..d12b91bf48 100644 --- a/submodules/TelegramUI/Components/ChatListTitleView/Sources/ChatListTitleView.swift +++ b/submodules/TelegramUI/Components/ChatListTitleView/Sources/ChatListTitleView.swift @@ -62,7 +62,7 @@ public final class ChatListTitleView: UIView, NavigationBarTitleView, Navigation public var openStatusSetup: ((UIView) -> Void)? - private var validLayout: (CGSize, CGRect, CGFloat)? + private var validLayout: (CGSize, CGRect)? public var manualLayout: Bool = false @@ -316,13 +316,13 @@ public final class ChatListTitleView: UIView, NavigationBarTitleView, Navigation override public func layoutSubviews() { super.layoutSubviews() - if !self.manualLayout, let (size, clearBounds, sideContentWidth) = self.validLayout { - let _ = self.updateLayout(size: size, clearBounds: clearBounds, sideContentWidth: sideContentWidth, transition: .immediate) + if !self.manualLayout, let (size, clearBounds) = self.validLayout { + let _ = self.updateLayout(size: size, clearBounds: clearBounds, transition: .immediate) } } - public func updateLayout(size: CGSize, clearBounds: CGRect, sideContentWidth: CGFloat, transition: ContainedViewLayoutTransition) -> CGFloat { - self.validLayout = (size, clearBounds, sideContentWidth) + public func updateLayout(size: CGSize, clearBounds: CGRect, transition: ContainedViewLayoutTransition) -> CGRect { + self.validLayout = (size, clearBounds) var indicatorPadding: CGFloat = 0.0 let indicatorSize = self.activityIndicator.bounds.size @@ -344,9 +344,9 @@ public final class ChatListTitleView: UIView, NavigationBarTitleView, Navigation let combinedHeight = titleSize.height - let combinedWidth = sideContentWidth + titleSize.width + let combinedWidth = titleSize.width - var titleContentRect = CGRect(origin: CGPoint(x: indicatorPadding + floor((size.width - combinedWidth - indicatorPadding) / 2.0) + sideContentWidth, y: floor((size.height - combinedHeight) / 2.0)), size: titleSize) + var titleContentRect = CGRect(origin: CGPoint(x: indicatorPadding + floor((size.width - combinedWidth - indicatorPadding) / 2.0), y: floor((size.height - combinedHeight) / 2.0)), size: titleSize) titleContentRect.origin.x = min(titleContentRect.origin.x, clearBounds.maxX - proxyPadding - titleContentRect.width) @@ -429,7 +429,15 @@ public final class ChatListTitleView: UIView, NavigationBarTitleView, Navigation } } - return combinedWidth + var resultFrame = titleFrame + if !self.lockView.isHidden { + resultFrame = resultFrame.union(lockFrame) + } + if let titleCredibilityIconView = self.titleCredibilityIconView { + resultFrame = resultFrame.union(titleCredibilityIconView.frame) + } + + return resultFrame } @objc private func buttonPressed() { diff --git a/submodules/TelegramUI/Components/ChatTitleView/Sources/ChatTitleView.swift b/submodules/TelegramUI/Components/ChatTitleView/Sources/ChatTitleView.swift index 733a50a65b..ea24a0aad3 100644 --- a/submodules/TelegramUI/Components/ChatTitleView/Sources/ChatTitleView.swift +++ b/submodules/TelegramUI/Components/ChatTitleView/Sources/ChatTitleView.swift @@ -118,7 +118,7 @@ public final class ChatTitleView: UIView, NavigationBarTitleView { private let button: HighlightTrackingButtonNode var manualLayout: Bool = false - private var validLayout: (CGSize, CGRect, CGFloat)? + private var validLayout: (CGSize, CGRect)? private var titleLeftIcon: ChatTitleIcon = .none private var titleRightIcon: ChatTitleIcon = .none @@ -355,8 +355,8 @@ public final class ChatTitleView: UIView, NavigationBarTitleView { self.button.isUserInteractionEnabled = isEnabled if !self.updateStatus() { if updated { - if !self.manualLayout, let (size, clearBounds, sideContentWidth) = self.validLayout { - let _ = self.updateLayout(size: size, clearBounds: clearBounds, sideContentWidth: sideContentWidth, transition: .animated(duration: 0.2, curve: .easeInOut)) + if !self.manualLayout, let (size, clearBounds) = self.validLayout { + let _ = self.updateLayout(size: size, clearBounds: clearBounds, transition: .animated(duration: 0.2, curve: .easeInOut)) } } } @@ -605,8 +605,8 @@ public final class ChatTitleView: UIView, NavigationBarTitleView { } if self.activityNode.transitionToState(state, animation: .slide) { - if !self.manualLayout, let (size, clearBounds, sideContentWidth) = self.validLayout { - let _ = self.updateLayout(size: size, clearBounds: clearBounds, sideContentWidth: sideContentWidth, transition: .animated(duration: 0.3, curve: .spring)) + if !self.manualLayout, let (size, clearBounds) = self.validLayout { + let _ = self.updateLayout(size: size, clearBounds: clearBounds, transition: .animated(duration: 0.3, curve: .spring)) } return true } else { @@ -688,8 +688,8 @@ public final class ChatTitleView: UIView, NavigationBarTitleView { override public func layoutSubviews() { super.layoutSubviews() - if !self.manualLayout, let (size, clearBounds, sideContentWidth) = self.validLayout { - let _ = self.updateLayout(size: size, clearBounds: clearBounds, sideContentWidth: sideContentWidth, transition: .immediate) + if !self.manualLayout, let (size, clearBounds) = self.validLayout { + let _ = self.updateLayout(size: size, clearBounds: clearBounds, transition: .immediate) } } @@ -704,14 +704,14 @@ public final class ChatTitleView: UIView, NavigationBarTitleView { self.titleContent = titleContent let _ = self.updateStatus() - if !self.manualLayout, let (size, clearBounds, sideContentWidth) = self.validLayout { - let _ = self.updateLayout(size: size, clearBounds: clearBounds, sideContentWidth: sideContentWidth, transition: .immediate) + if !self.manualLayout, let (size, clearBounds) = self.validLayout { + let _ = self.updateLayout(size: size, clearBounds: clearBounds, transition: .immediate) } } } - public func updateLayout(size: CGSize, clearBounds: CGRect, sideContentWidth: CGFloat, transition: ContainedViewLayoutTransition) -> CGFloat { - self.validLayout = (size, clearBounds, sideContentWidth) + public func updateLayout(size: CGSize, clearBounds: CGRect, transition: ContainedViewLayoutTransition) -> CGRect { + self.validLayout = (size, clearBounds) self.button.frame = clearBounds self.contentContainer.frame = clearBounds @@ -851,7 +851,7 @@ public final class ChatTitleView: UIView, NavigationBarTitleView { self.pointerInteraction = PointerInteraction(view: self, style: .rectangle(CGSize(width: titleFrame.width + 16.0, height: 40.0))) - return titleFrame.width + return titleFrame } @objc private func buttonPressed() { @@ -1015,7 +1015,7 @@ public final class ChatTitleComponent: Component { } contentView.updateThemeAndStrings(theme: component.theme, strings: component.strings, hasEmbeddedTitleContent: false) - let _ = contentView.updateLayout(size: availableSize, clearBounds: CGRect(origin: CGPoint(), size: availableSize), sideContentWidth: 0.0, transition: transition.containedViewLayoutTransition) + let _ = contentView.updateLayout(size: availableSize, clearBounds: CGRect(origin: CGPoint(), size: availableSize), transition: transition.containedViewLayoutTransition) transition.setFrame(view: contentView, frame: CGRect(origin: CGPoint(), size: availableSize)) return availableSize diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/BUILD b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/BUILD index 756ffd8d42..e4cd9e8162 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/BUILD +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/BUILD @@ -51,6 +51,8 @@ swift_library( "//submodules/ContextUI", "//submodules/AvatarNode", "//submodules/ChatPresentationInterfaceState", + "//submodules/TelegramStringFormatting", + "//submodules/ShimmerEffect", ], visibility = [ "//visibility:public", diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/PeerListItemComponent.swift b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/PeerListItemComponent.swift new file mode 100644 index 0000000000..c657021b8a --- /dev/null +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/PeerListItemComponent.swift @@ -0,0 +1,360 @@ +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 +import AppBundle + +private let avatarFont = avatarPlaceholderFont(size: 15.0) +private let readIconImage: UIImage? = generateTintedImage(image: UIImage(bundleImageName: "Chat/Message/MenuReadIcon"), color: .white)?.withRenderingMode(.alwaysTemplate) + +final class PeerListItemComponent: Component { + final class TransitionHint { + let synchronousLoad: Bool + + init(synchronousLoad: Bool) { + self.synchronousLoad = synchronousLoad + } + } + + 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 iconView: UIImageView? + private var checkLayer: CheckLayer? + + private var component: PeerListItemComponent? + private weak var state: EmptyComponentState? + + var avatarFrame: CGRect { + return self.avatarNode.frame + } + + var titleFrame: CGRect? { + return self.title.view?.frame + } + + var labelFrame: CGRect? { + guard var value = self.label.view?.frame else { + return nil + } + if let iconView = self.iconView { + value.size.width += value.minX - iconView.frame.minX + value.origin.x = iconView.frame.minX + } + return value + } + + 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 { + var synchronousLoad = false + if let hint = transition.userData(TransitionHint.self) { + synchronousLoad = hint.synchronousLoad + } + + 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 + } + self.avatarNode.setPeer(context: component.context, theme: component.theme, peer: peer, clipStyle: clipStyle, synchronousLoad: synchronousLoad, 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 { + var iconLabelOffset: CGFloat = 0.0 + + let iconView: UIImageView + if let current = self.iconView { + iconView = current + } else { + iconView = UIImageView(image: readIconImage) + iconView.tintColor = component.theme.list.itemSecondaryTextColor + self.iconView = iconView + self.containerButton.addSubview(iconView) + } + + if let image = iconView.image { + iconLabelOffset = image.size.width + 4.0 + transition.setFrame(view: iconView, frame: CGRect(origin: CGPoint(x: titleFrame.minX, y: titleFrame.maxY + titleSpacing + 3.0 + floor((labelSize.height - image.size.height) * 0.5)), size: image.size)) + } + + if labelView.superview == nil { + labelView.isUserInteractionEnabled = false + self.containerButton.addSubview(labelView) + } + transition.setFrame(view: labelView, frame: CGRect(origin: CGPoint(x: titleFrame.minX + iconLabelOffset, 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/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift index 6758fc7e47..bb44761533 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift @@ -159,6 +159,14 @@ public final class StoryItemSetContainerComponent: Component { } } + final class ViewList { + let externalState = StoryItemSetViewListComponent.ExternalState() + let view = ComponentView() + + init() { + } + } + public final class View: UIView, UIScrollViewDelegate, UIGestureRecognizerDelegate { let sendMessageContext: StoryItemSetContainerSendMessage @@ -184,6 +192,9 @@ public final class StoryItemSetContainerComponent: Component { let footerPanel = ComponentView() let inputPanelExternalState = MessageInputPanelComponent.ExternalState() + var displayViewList: Bool = false + var viewList: ViewList? + var itemLayout: ItemLayout? var ignoreScrolling: Bool = false @@ -388,6 +399,9 @@ public final class StoryItemSetContainerComponent: Component { } else if self.displayReactions { self.displayReactions = false self.state?.updated(transition: Transition(animation: .curve(duration: 0.4, curve: .spring))) + } else if self.displayViewList { + self.displayViewList = false + self.state?.updated(transition: Transition(animation: .curve(duration: 0.4, curve: .spring))) } else if let captionItem = self.captionItem, captionItem.externalState.expandFraction > 0.0 { if let captionItemView = captionItem.view.view as? StoryContentCaptionComponent.View { captionItemView.collapse(transition: Transition(animation: .curve(duration: 0.4, curve: .spring))) @@ -485,7 +499,7 @@ public final class StoryItemSetContainerComponent: Component { itemTransition.setFrame(view: view, frame: CGRect(origin: CGPoint(), size: itemLayout.size)) if let view = view as? StoryContentItem.View { - view.setIsProgressPaused(self.inputPanelExternalState.isEditing || component.isProgressPaused || self.displayReactions || self.actionSheet != nil || self.contextController != nil || self.sendMessageContext.audioRecorderValue != nil || self.sendMessageContext.videoRecorderValue != nil) + view.setIsProgressPaused(self.inputPanelExternalState.isEditing || component.isProgressPaused || self.displayReactions || self.actionSheet != nil || self.contextController != nil || self.sendMessageContext.audioRecorderValue != nil || self.sendMessageContext.videoRecorderValue != nil || self.displayViewList) } } @@ -510,7 +524,7 @@ public final class StoryItemSetContainerComponent: Component { 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.displayReactions || self.actionSheet != nil || self.contextController != nil || self.sendMessageContext.audioRecorderValue != nil || self.sendMessageContext.videoRecorderValue != nil) + view.setIsProgressPaused(self.inputPanelExternalState.isEditing || component.isProgressPaused || self.displayReactions || self.actionSheet != nil || self.contextController != nil || self.sendMessageContext.audioRecorderValue != nil || self.sendMessageContext.videoRecorderValue != nil || self.displayViewList) } } } @@ -869,6 +883,16 @@ public final class StoryItemSetContainerComponent: Component { component: AnyComponent(StoryFooterPanelComponent( context: component.context, storyItem: currentItem?.storyItem, + expandViewStats: { [weak self] in + guard let self else { + return + } + + if !self.displayViewList { + self.displayViewList = true + self.state?.updated(transition: Transition(animation: .curve(duration: 0.4, curve: .spring))) + } + }, deleteAction: { [weak self] in guard let self, let component = self.component else { return @@ -1053,8 +1077,9 @@ public final class StoryItemSetContainerComponent: Component { ) let bottomContentInsetWithoutInput = bottomContentInset + var viewListInset: CGFloat = 0.0 - let inputPanelBottomInset: CGFloat + var inputPanelBottomInset: CGFloat let inputPanelIsOverlay: Bool if component.inputHeight == 0.0 { inputPanelBottomInset = bottomContentInset @@ -1066,9 +1091,81 @@ public final class StoryItemSetContainerComponent: Component { inputPanelIsOverlay = true } - 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: 10.0) + if self.displayViewList { + let viewList: ViewList + var viewListTransition = transition + if let current = self.viewList { + viewList = current + } else { + if !transition.animation.isImmediate { + viewListTransition = .immediate + } + viewList = ViewList() + self.viewList = viewList + } + + let viewListSize = viewList.view.update( + transition: viewListTransition, + component: AnyComponent(StoryItemSetViewListComponent( + externalState: viewList.externalState, + context: component.context, + theme: component.theme, + strings: component.strings, + safeInsets: component.safeInsets, + storyItem: component.slice.item.storyItem, + close: { [weak self] in + guard let self else { + return + } + self.displayViewList = false + self.state?.updated(transition: Transition(animation: .curve(duration: 0.4, curve: .spring))) + } + )), + environment: {}, + containerSize: availableSize + ) + let viewListFrame = CGRect(origin: CGPoint(x: 0.0, y: availableSize.height - viewListSize.height), size: viewListSize) + if let viewListView = viewList.view.view { + var animateIn = false + if viewListView.superview == nil { + self.addSubview(viewListView) + animateIn = true + } + viewListTransition.setFrame(view: viewListView, frame: viewListFrame) + + if animateIn, !transition.animation.isImmediate { + transition.animatePosition(view: viewListView, from: CGPoint(x: 0.0, y: viewListFrame.height), to: CGPoint(), additive: true) + } + } + viewListInset = viewListFrame.height + inputPanelBottomInset = viewListInset + } else if let viewList = self.viewList { + self.viewList = nil + if let viewListView = viewList.view.view { + transition.setPosition(view: viewListView, position: CGPoint(x: viewListView.center.x, y: availableSize.height + viewListView.bounds.height * 0.5), completion: { [weak viewListView] _ in + viewListView?.removeFromSuperview() + }) + } + } + + let contentDefaultBottomInset: CGFloat = bottomContentInset + let contentSize = CGSize(width: availableSize.width, height: availableSize.height - component.containerInsets.top - contentDefaultBottomInset) + + let contentVisualBottomInset: CGFloat + if self.displayViewList { + contentVisualBottomInset = viewListInset + 12.0 + } else { + contentVisualBottomInset = contentDefaultBottomInset + } + let contentVisualHeight = availableSize.height - component.containerInsets.top - contentVisualBottomInset + let contentVisualScale = contentVisualHeight / contentSize.height + + let contentFrame = CGRect(origin: CGPoint(x: 0.0, y: component.containerInsets.top - (contentSize.height - contentVisualHeight) * 0.5), size: contentSize) + + transition.setPosition(view: self.contentContainerView, position: contentFrame.center) + transition.setBounds(view: self.contentContainerView, bounds: CGRect(origin: CGPoint(), size: contentFrame.size)) + transition.setScale(view: self.contentContainerView, scale: contentVisualScale) + transition.setCornerRadius(layer: self.contentContainerView.layer, cornerRadius: 10.0 * (1.0 / contentVisualScale)) if self.closeButtonIconView.image == nil { self.closeButtonIconView.image = UIImage(bundleImageName: "Media Gallery/Close")?.withRenderingMode(.alwaysTemplate) @@ -1078,7 +1175,7 @@ public final class StoryItemSetContainerComponent: Component { 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) + transition.setAlpha(view: self.closeButton, alpha: (component.hideUI || self.displayViewList) ? 0.0 : 1.0) } let focusedItem: StoryContentItem? = component.slice.item @@ -1148,7 +1245,7 @@ public final class StoryItemSetContainerComponent: Component { 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) + transition.setAlpha(view: view, alpha: (component.hideUI || self.displayViewList) ? 0.0 : 1.0) } } @@ -1174,13 +1271,13 @@ public final class StoryItemSetContainerComponent: Component { //view.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) } - transition.setAlpha(view: view, alpha: component.hideUI ? 0.0 : 1.0) + transition.setAlpha(view: view, alpha: (component.hideUI || self.displayViewList) ? 0.0 : 1.0) } } let gradientHeight: CGFloat = 74.0 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) + transition.setAlpha(layer: self.topContentGradientLayer, alpha: (component.hideUI || self.displayViewList) ? 0.0 : 1.0) let itemLayout = ItemLayout(size: CGSize(width: contentFrame.width, height: availableSize.height - component.containerInsets.top - 44.0 - bottomContentInsetWithoutInput)) self.itemLayout = itemLayout @@ -1230,7 +1327,7 @@ public final class StoryItemSetContainerComponent: Component { self.addSubview(captionItemView) } captionItemTransition.setFrame(view: captionItemView, frame: captionFrame) - captionItemTransition.setAlpha(view: captionItemView, alpha: component.hideUI ? 0.0 : 1.0) + captionItemTransition.setAlpha(view: captionItemView, alpha: (component.hideUI || self.displayViewList) ? 0.0 : 1.0) } } @@ -1445,13 +1542,16 @@ public final class StoryItemSetContainerComponent: Component { } } - let footerPanelFrame = CGRect(origin: CGPoint(x: 0.0, y: availableSize.height - inputPanelBottomInset - footerPanelSize.height), size: footerPanelSize) + var footerPanelFrame = CGRect(origin: CGPoint(x: 0.0, y: availableSize.height - inputPanelBottomInset - footerPanelSize.height), size: footerPanelSize) + if self.displayViewList { + footerPanelFrame.origin.y += footerPanelSize.height + } if let footerPanelView = self.footerPanel.view { if footerPanelView.superview == nil { self.addSubview(footerPanelView) } transition.setFrame(view: footerPanelView, frame: footerPanelFrame) - transition.setAlpha(view: footerPanelView, alpha: focusedItem?.isMy == true ? 1.0 : 0.0) + transition.setAlpha(view: footerPanelView, alpha: (focusedItem?.isMy == true && !self.displayViewList) ? 1.0 : 0.0) } let bottomGradientHeight = inputPanelSize.height + 32.0 @@ -1464,7 +1564,7 @@ public final class StoryItemSetContainerComponent: Component { normalDimAlpha = captionItem.externalState.expandFraction } var dimAlpha: CGFloat = (inputPanelIsOverlay || self.inputPanelExternalState.isEditing) ? 1.0 : normalDimAlpha - if component.hideUI { + if component.hideUI || self.displayViewList { dimAlpha = 0.0 } @@ -1473,9 +1573,9 @@ public final class StoryItemSetContainerComponent: Component { 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 = availableSize - if contentSize != self.scrollView.contentSize { - self.scrollView.contentSize = contentSize + let scrollContentSize = availableSize + if scrollContentSize != self.scrollView.contentSize { + self.scrollView.contentSize = scrollContentSize } self.ignoreScrolling = false self.updateScrolling(transition: transition) @@ -1505,7 +1605,7 @@ public final class StoryItemSetContainerComponent: Component { self.contentContainerView.addSubview(navigationStripView) } 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) + transition.setAlpha(view: navigationStripView, alpha: (component.hideUI || self.displayViewList) ? 0.0 : 1.0) } var items: [StoryActionsComponent.Item] = [] @@ -1542,7 +1642,7 @@ public final class StoryItemSetContainerComponent: Component { if self.displayReactions { inlineActionsAlpha = 0.0 } - if component.hideUI { + if component.hideUI || self.displayViewList { inlineActionsAlpha = 0.0 } diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetViewListComponent.swift b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetViewListComponent.swift new file mode 100644 index 0000000000..b6809d9566 --- /dev/null +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetViewListComponent.swift @@ -0,0 +1,510 @@ +import Foundation +import UIKit +import Display +import ComponentFlow +import MultilineTextComponent +import TelegramCore +import TelegramPresentationData +import ComponentDisplayAdapters +import AccountContext +import SwiftSignalKit +import TelegramStringFormatting +import ShimmerEffect + +final class StoryItemSetViewListComponent: Component { + final class ExternalState { + init() { + } + } + + let externalState: ExternalState + let context: AccountContext + let theme: PresentationTheme + let strings: PresentationStrings + let safeInsets: UIEdgeInsets + let storyItem: EngineStoryItem + let close: () -> Void + + init( + externalState: ExternalState, + context: AccountContext, + theme: PresentationTheme, + strings: PresentationStrings, + safeInsets: UIEdgeInsets, + storyItem: EngineStoryItem, + close: @escaping () -> Void + ) { + self.externalState = externalState + self.context = context + self.theme = theme + self.strings = strings + self.safeInsets = safeInsets + self.storyItem = storyItem + self.close = close + } + + static func ==(lhs: StoryItemSetViewListComponent, rhs: StoryItemSetViewListComponent) -> Bool { + if lhs.theme !== rhs.theme { + return false + } + if lhs.strings !== rhs.strings { + return false + } + if lhs.safeInsets != rhs.safeInsets { + return false + } + if lhs.storyItem != rhs.storyItem { + return false + } + return true + } + + private struct ItemLayout: Equatable { + var containerSize: CGSize + var bottomInset: CGFloat + var topInset: CGFloat + var sideInset: CGFloat + var itemHeight: CGFloat + var itemCount: Int + + var contentSize: CGSize + + init(containerSize: CGSize, bottomInset: CGFloat, topInset: CGFloat, sideInset: CGFloat, itemHeight: CGFloat, itemCount: Int) { + self.containerSize = containerSize + self.bottomInset = bottomInset + self.topInset = topInset + self.sideInset = sideInset + self.itemHeight = itemHeight + self.itemCount = itemCount + + self.contentSize = CGSize(width: containerSize.width, height: topInset + CGFloat(itemCount) * itemHeight + bottomInset) + } + + func visibleItems(for rect: CGRect) -> Range? { + let offsetRect = rect.offsetBy(dx: 0.0, dy: -self.topInset) + var minVisibleRow = Int(floor((offsetRect.minY) / (self.itemHeight))) + minVisibleRow = max(0, minVisibleRow) + let maxVisibleRow = Int(ceil((offsetRect.maxY) / (self.itemHeight))) + + let minVisibleIndex = minVisibleRow + let maxVisibleIndex = maxVisibleRow + + if maxVisibleIndex >= minVisibleIndex { + return minVisibleIndex ..< (maxVisibleIndex + 1) + } else { + return nil + } + } + + func itemFrame(for index: Int) -> CGRect { + return CGRect(origin: CGPoint(x: 0.0, y: self.topInset + CGFloat(index) * self.itemHeight), size: CGSize(width: self.containerSize.width, height: self.itemHeight)) + } + } + + 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 View: UIView, UIScrollViewDelegate { + private let navigationBarBackground: BlurredBackgroundView + private let navigationSeparator: SimpleLayer + private let navigationTitle = ComponentView() + private let navigationLeftButton = ComponentView() + + private let backgroundView: UIView + private let scrollView: UIScrollView + + private var itemLayout: ItemLayout? + + private let measureItem = ComponentView() + private var placeholderImage: UIImage? + + private var visibleItems: [EnginePeer.Id: ComponentView] = [:] + private var visiblePlaceholderViews: [Int: UIImageView] = [:] + + private var component: StoryItemSetViewListComponent? + private weak var state: EmptyComponentState? + + private var ignoreScrolling: Bool = false + + private var viewList: EngineStoryViewListContext? + private var viewListDisposable: Disposable? + private var viewListState: EngineStoryViewListContext.State? + private var requestedLoadMoreToken: EngineStoryViewListContext.LoadMoreToken? + + override init(frame: CGRect) { + self.navigationBarBackground = BlurredBackgroundView(color: .clear, enableBlur: true) + self.navigationSeparator = SimpleLayer() + + self.backgroundView = UIView() + + self.scrollView = ScrollView() + self.scrollView.canCancelContentTouches = true + self.scrollView.delaysContentTouches = false + self.scrollView.showsVerticalScrollIndicator = true + self.scrollView.contentInsetAdjustmentBehavior = .never + self.scrollView.alwaysBounceVertical = true + self.scrollView.indicatorStyle = .white + + super.init(frame: frame) + + self.addSubview(self.backgroundView) + self.addSubview(self.scrollView) + + self.addSubview(self.navigationBarBackground) + self.layer.addSublayer(self.navigationSeparator) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + self.viewListDisposable?.dispose() + } + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + return super.hitTest(point, with: event) + } + + 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 + } + + let visibleBounds = self.scrollView.bounds.insetBy(dx: 0.0, dy: -200.0) + + var synchronousLoad = false + if let hint = transition.userData(PeerListItemComponent.TransitionHint.self) { + synchronousLoad = hint.synchronousLoad + } + + var validIds: [EnginePeer.Id] = [] + var validPlaceholderIds: [Int] = [] + if let range = itemLayout.visibleItems(for: visibleBounds) { + for index in range.lowerBound ..< range.upperBound { + guard let viewListState = self.viewListState, index < viewListState.totalCount else { + continue + } + + let itemFrame = itemLayout.itemFrame(for: index) + + if index >= viewListState.items.count { + validPlaceholderIds.append(index) + + let placeholderView: UIImageView + if let current = self.visiblePlaceholderViews[index] { + placeholderView = current + } else { + placeholderView = UIImageView() + self.visiblePlaceholderViews[index] = placeholderView + self.scrollView.addSubview(placeholderView) + + placeholderView.image = self.placeholderImage + } + + placeholderView.frame = itemFrame + + continue + } + + var itemTransition = transition + let item = viewListState.items[index] + validIds.append(item.peer.id) + + let visibleItem: ComponentView + if let current = self.visibleItems[item.peer.id] { + visibleItem = current + } else { + if !transition.animation.isImmediate { + itemTransition = .immediate + } + visibleItem = ComponentView() + self.visibleItems[item.peer.id] = visibleItem + } + + let dateText = humanReadableStringForTimestamp(strings: component.strings, dateTimeFormat: PresentationDateTimeFormat(), timestamp: item.timestamp, alwaysShowTime: true, allowYesterday: true, format: HumanReadableStringFormat( + dateFormatString: { value in + return PresentationStrings.FormattedString(string: component.strings.Chat_MessageSeenTimestamp_Date(value).string, ranges: []) + }, + tomorrowFormatString: { value in + return PresentationStrings.FormattedString(string: component.strings.Chat_MessageSeenTimestamp_TodayAt(value).string, ranges: []) + }, + todayFormatString: { value in + return PresentationStrings.FormattedString(string: component.strings.Chat_MessageSeenTimestamp_TodayAt(value).string, ranges: []) + }, + yesterdayFormatString: { value in + return PresentationStrings.FormattedString(string: component.strings.Chat_MessageSeenTimestamp_YesterdayAt(value).string, ranges: []) + } + )).string + + let _ = visibleItem.update( + transition: itemTransition, + component: AnyComponent(PeerListItemComponent( + context: component.context, + theme: component.theme, + strings: component.strings, + sideInset: itemLayout.sideInset, + title: item.peer.displayTitle(strings: component.strings, displayOrder: .firstLast), + peer: item.peer, + subtitle: dateText, + selectionState: .none, + hasNext: index != viewListState.totalCount - 1, + action: { _ in + + } + )), + environment: {}, + containerSize: itemFrame.size + ) + if let itemView = visibleItem.view { + var animateIn = false + if itemView.superview == nil { + animateIn = true + self.scrollView.addSubview(itemView) + } + itemTransition.setFrame(view: itemView, frame: itemFrame) + + if animateIn, synchronousLoad { + itemView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) + } + } + } + } + + var removeIds: [EnginePeer.Id] = [] + for (id, visibleItem) in self.visibleItems { + if !validIds.contains(id) { + removeIds.append(id) + if let itemView = visibleItem.view { + itemView.removeFromSuperview() + } + } + } + for id in removeIds { + self.visibleItems.removeValue(forKey: id) + } + + var removePlaceholderIds: [Int] = [] + for (id, placeholderView) in self.visiblePlaceholderViews { + if !validPlaceholderIds.contains(id) { + removePlaceholderIds.append(id) + + if synchronousLoad { + placeholderView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak placeholderView] _ in + placeholderView?.removeFromSuperview() + }) + } else { + placeholderView.removeFromSuperview() + } + } + } + for id in removePlaceholderIds { + self.visiblePlaceholderViews.removeValue(forKey: id) + } + + if let viewList = self.viewList, let viewListState = self.viewListState, visibleBounds.maxY >= self.scrollView.contentSize.height - 200.0 { + if self.requestedLoadMoreToken != viewListState.loadMoreToken { + self.requestedLoadMoreToken = viewListState.loadMoreToken + viewList.loadMore() + } + } + } + + func update(component: StoryItemSetViewListComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + let themeUpdated = self.component?.theme !== component.theme + let itemUpdated = self.component?.storyItem.id != component.storyItem.id + + self.component = component + self.state = state + + let size = CGSize(width: availableSize.width, height: min(availableSize.height, 500.0)) + + if themeUpdated { + self.backgroundView.backgroundColor = component.theme.rootController.navigationBar.blurredBackgroundColor + self.navigationBarBackground.updateColor(color: component.theme.rootController.navigationBar.blurredBackgroundColor, transition: .immediate) + self.navigationSeparator.backgroundColor = component.theme.rootController.navigationBar.separatorColor.cgColor + } + + if itemUpdated { + self.viewListState = nil + self.viewList = nil + self.viewListDisposable?.dispose() + + if let views = component.storyItem.views { + let viewList = component.context.engine.messages.storyViewList(id: component.storyItem.id, views: views) + self.viewList = viewList + var applyState = false + self.viewListDisposable = (viewList.state + |> deliverOnMainQueue).start(next: { [weak self] listState in + guard let self else { + return + } + self.viewListState = listState + if applyState { + self.state?.updated(transition: Transition.immediate.withUserData(PeerListItemComponent.TransitionHint(synchronousLoad: true))) + } + }) + applyState = true + } + } + + let sideInset: CGFloat = 16.0 + + let navigationHeight: CGFloat = 56.0 + let navigationBarFrame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: size.width, height: navigationHeight)) + transition.setFrame(view: self.navigationBarBackground, frame: navigationBarFrame) + self.navigationBarBackground.update(size: navigationBarFrame.size, cornerRadius: 10.0, maskedCorners: [.layerMinXMinYCorner, .layerMaxXMinYCorner], transition: transition.containedViewLayoutTransition) + + transition.setFrame(layer: self.navigationSeparator, frame: CGRect(origin: CGPoint(x: 0.0, y: navigationBarFrame.maxY), size: CGSize(width: size.width, height: UIScreenPixel))) + + let navigationLeftButtonSize = self.navigationLeftButton.update( + transition: transition, + component: AnyComponent(Button( + content: AnyComponent(Text(text: component.strings.Common_Close, font: Font.regular(17.0), color: component.theme.rootController.navigationBar.accentTextColor)), + action: { [weak self] in + guard let self, let component = self.component else { + return + } + component.close() + } + ).minSize(CGSize(width: 44.0, height: 56.0))), + environment: {}, + containerSize: CGSize(width: 120.0, height: 100.0) + ) + let navigationLeftButtonFrame = CGRect(origin: CGPoint(x: 16.0, y: 0.0), size: navigationLeftButtonSize) + if let navigationLeftButtonView = self.navigationLeftButton.view { + if navigationLeftButtonView.superview == nil { + self.addSubview(navigationLeftButtonView) + } + transition.setFrame(view: navigationLeftButtonView, frame: navigationLeftButtonFrame) + } + + let titleText: String + + let viewCount = self.viewListState?.totalCount ?? component.storyItem.views?.seenCount + if let viewCount { + if viewCount == 1 { + titleText = "1 View" + } else { + titleText = "\(viewCount) Views" + } + } else { + titleText = "No Views" + } + let navigationTitleSize = self.navigationTitle.update( + transition: .immediate, + component: AnyComponent(Text( + text: titleText, font: Font.semibold(17.0), color: component.theme.rootController.navigationBar.primaryTextColor + )), + environment: {}, + containerSize: CGSize(width: availableSize.width, height: navigationHeight) + ) + let navigationTitleFrame = CGRect(origin: CGPoint(x: floor((size.width - navigationTitleSize.width) * 0.5), y: floor((navigationBarFrame.height - navigationTitleSize.height) * 0.5)), size: navigationTitleSize) + if let navigationTitleView = self.navigationTitle.view { + if navigationTitleView.superview == nil { + self.addSubview(navigationTitleView) + } + transition.setPosition(view: navigationTitleView, position: navigationTitleFrame.center) + transition.setBounds(view: navigationTitleView, bounds: CGRect(origin: CGPoint(), size: navigationTitleFrame.size)) + } + + transition.setFrame(view: self.backgroundView, frame: CGRect(origin: CGPoint(x: 0.0, y: navigationBarFrame.maxY), size: CGSize(width: size.width, height: size.height - navigationBarFrame.maxY))) + + let measureItemSize = self.measureItem.update( + transition: .immediate, + component: AnyComponent(PeerListItemComponent( + context: component.context, + theme: component.theme, + strings: component.strings, + sideInset: sideInset, + title: "AAAAAAAAAAAA", + peer: nil, + subtitle: "BBBBBBB", + selectionState: .none, + hasNext: true, + action: { _ in + } + )), + environment: {}, + containerSize: CGSize(width: size.width, height: 1000.0) + ) + + if self.placeholderImage == nil || themeUpdated { + self.placeholderImage = generateImage(CGSize(width: 300.0, height: measureItemSize.height), rotatedContext: { size, context in + context.clear(CGRect(origin: CGPoint(), size: size)) + context.setFillColor(component.theme.list.itemPrimaryTextColor.withMultipliedAlpha(0.1).cgColor) + + if let measureItemView = self.measureItem.view as? PeerListItemComponent.View { + context.fillEllipse(in: measureItemView.avatarFrame) + let lineWidth: CGFloat = 8.0 + + if let titleFrame = measureItemView.titleFrame { + context.addPath(UIBezierPath(roundedRect: CGRect(origin: CGPoint(x: titleFrame.minX, y: floor(titleFrame.midY - lineWidth * 0.5)), size: CGSize(width: titleFrame.width, height: lineWidth)), cornerRadius: lineWidth * 0.5).cgPath) + context.fillPath() + } + if let labelFrame = measureItemView.labelFrame { + context.addPath(UIBezierPath(roundedRect: CGRect(origin: CGPoint(x: labelFrame.minX, y: floor(labelFrame.midY - lineWidth * 0.5)), size: CGSize(width: labelFrame.width, height: lineWidth)), cornerRadius: lineWidth * 0.5).cgPath) + context.fillPath() + } + } + })?.stretchableImage(withLeftCapWidth: 299, topCapHeight: 0) + for (_, placeholderView) in self.visiblePlaceholderViews { + placeholderView.image = self.placeholderImage + } + } + + let itemLayout = ItemLayout( + containerSize: size, + bottomInset: component.safeInsets.bottom, + topInset: 0.0, + sideInset: sideInset, + itemHeight: measureItemSize.height, + itemCount: self.viewListState?.items.count ?? 0 + ) + self.itemLayout = itemLayout + + let scrollContentSize = itemLayout.contentSize + + self.ignoreScrolling = true + + transition.setFrame(view: self.scrollView, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: size.width, height: size.height))) + let scrollContentInsets = UIEdgeInsets(top: navigationHeight, left: 0.0, bottom: 0.0, right: 0.0) + let scrollIndicatorInsets = UIEdgeInsets(top: navigationHeight, left: 0.0, bottom: component.safeInsets.bottom, right: 0.0) + if self.scrollView.contentInset != scrollContentInsets { + self.scrollView.contentInset = scrollContentInsets + } + if self.scrollView.scrollIndicatorInsets != scrollIndicatorInsets { + self.scrollView.scrollIndicatorInsets = scrollIndicatorInsets + } + if self.scrollView.contentSize != scrollContentSize { + self.scrollView.contentSize = scrollContentSize + } + + self.ignoreScrolling = false + self.updateScrolling(transition: transition) + + return size + } + } + + 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/StoryContentComponent/Sources/StoryChatContent.swift b/submodules/TelegramUI/Components/Stories/StoryContentComponent/Sources/StoryChatContent.swift index cb3e4a5b4d..8f466eb9ec 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContentComponent/Sources/StoryChatContent.swift +++ b/submodules/TelegramUI/Components/Stories/StoryContentComponent/Sources/StoryChatContent.swift @@ -19,6 +19,7 @@ public final class StoryContentContextImpl: StoryContentContext { private let peerId: EnginePeer.Id private(set) var sliceValue: StoryContentContextState.FocusedSlice? + fileprivate var nextItems: [EngineStoryItem] = [] let updated = Promise() @@ -154,6 +155,25 @@ public final class StoryContentContextImpl: StoryContentContext { isPublic: item.isPublic ) + var nextItems: [EngineStoryItem] = [] + for i in (focusedIndex + 1) ..< min(focusedIndex + 4, itemsView.items.count) { + if let item = itemsView.items[i].value.get(Stories.StoredItem.self), case let .item(item) = item, let media = item.media { + nextItems.append(EngineStoryItem( + id: item.id, + timestamp: item.timestamp, + media: EngineMedia(media), + text: item.text, + entities: item.entities, + views: nil, + privacy: item.privacy.flatMap(EngineStoryPrivacy.init), + isPinned: item.isPinned, + isExpired: item.isExpired, + isPublic: item.isPublic + )) + } + } + + self.nextItems = nextItems self.sliceValue = StoryContentContextState.FocusedSlice( peer: peer, item: StoryContentItem( @@ -314,6 +334,8 @@ public final class StoryContentContextImpl: StoryContentContext { private var requestedStoryKeys = Set() private var requestStoryDisposables = DisposableSet() + private var preloadStoryResourceDisposables: [MediaResourceId: Disposable] = [:] + public init( context: AccountContext, focusedPeerId: EnginePeer.Id? @@ -336,6 +358,9 @@ public final class StoryContentContextImpl: StoryContentContext { deinit { self.storySubscriptionsDisposable?.dispose() self.requestStoryDisposables.dispose() + for (_, disposable) in self.preloadStoryResourceDisposables { + disposable.dispose() + } } private func updatePeerContexts() { @@ -486,6 +511,82 @@ public final class StoryContentContextImpl: StoryContentContext { self.statePromise.set(.single(stateValue)) self.updatedPromise.set(.single(Void())) + + var possibleItems: [(EnginePeer, EngineStoryItem)] = [] + if let slice = currentState.centralPeerContext.sliceValue { + for item in currentState.centralPeerContext.nextItems { + possibleItems.append((slice.peer, item)) + } + } + if let nextPeerContext = currentState.nextPeerContext, let slice = nextPeerContext.sliceValue { + possibleItems.append((slice.peer, slice.item.storyItem)) + for item in nextPeerContext.nextItems { + possibleItems.append((slice.peer, item)) + } + } + + var nextPriority = 0 + var resultResources: [EngineMediaResource.Id: StoryPreloadInfo] = [:] + for i in 0 ..< min(possibleItems.count, 3) { + let peer = possibleItems[i].0 + let item = possibleItems[i].1 + if let peerReference = PeerReference(peer._asPeer()) { + if let image = item.media._asMedia() as? TelegramMediaImage, let resource = image.representations.last?.resource { + let resource = MediaResourceReference.media(media: .story(peer: peerReference, id: item.id, media: image), resource: resource) + resultResources[EngineMediaResource.Id(resource.resource.id)] = StoryPreloadInfo( + resource: resource, + size: nil, + priority: .top(position: nextPriority) + ) + nextPriority += 1 + } else if let file = item.media._asMedia() as? TelegramMediaFile { + if let preview = file.previewRepresentations.last { + let resource = MediaResourceReference.media(media: .story(peer: peerReference, id: item.id, media: file), resource: preview.resource) + resultResources[EngineMediaResource.Id(resource.resource.id)] = StoryPreloadInfo( + resource: resource, + size: nil, + priority: .top(position: nextPriority) + ) + nextPriority += 1 + } + + let resource = MediaResourceReference.media(media: .story(peer: peerReference, id: item.id, media: file), resource: file.resource) + resultResources[EngineMediaResource.Id(resource.resource.id)] = StoryPreloadInfo( + resource: resource, + size: file.preloadSize, + priority: .top(position: nextPriority) + ) + nextPriority += 1 + } + } + } + + var validIds: [MediaResourceId] = [] + for (_, info) in resultResources.sorted(by: { $0.value.priority < $1.value.priority }) { + let resource = info.resource + validIds.append(resource.resource.id) + if self.preloadStoryResourceDisposables[resource.resource.id] == nil { + var fetchRange: (Range, MediaBoxFetchPriority)? + if let size = info.size { + fetchRange = (0 ..< Int64(size), .default) + } + #if DEBUG + fetchRange = nil + #endif + self.preloadStoryResourceDisposables[resource.resource.id] = fetchedMediaResource(mediaBox: self.context.account.postbox.mediaBox, userLocation: .other, userContentType: .other, reference: resource, range: fetchRange).start() + } + } + + var removeIds: [MediaResourceId] = [] + for (id, disposable) in self.preloadStoryResourceDisposables { + if !validIds.contains(id) { + removeIds.append(id) + disposable.dispose() + } + } + for id in removeIds { + self.preloadStoryResourceDisposables.removeValue(forKey: id) + } } public func resetSideStates() { diff --git a/submodules/TelegramUI/Components/Stories/StoryContentComponent/Sources/StoryItemContentComponent.swift b/submodules/TelegramUI/Components/Stories/StoryContentComponent/Sources/StoryItemContentComponent.swift index 9221c82b58..fe3470270b 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContentComponent/Sources/StoryItemContentComponent.swift +++ b/submodules/TelegramUI/Components/Stories/StoryContentComponent/Sources/StoryItemContentComponent.swift @@ -169,13 +169,19 @@ final class StoryItemContentComponent: Component { self.videoNode = videoNode self.addSubnode(videoNode) + videoNode.playbackCompleted = { [weak self] in + guard let self else { + return + } + self.environment?.presentationProgressUpdated(1.0) + } videoNode.ownsContentNodeUpdated = { [weak self] value in guard let self else { return } if value { self.videoNode?.seek(0.0) - self.videoNode?.playOnceWithSound(playAndRecord: false) + self.videoNode?.playOnceWithSound(playAndRecord: false, actionAtEnd: .stop) } } videoNode.canAttachContent = true @@ -404,9 +410,7 @@ final class StoryItemContentComponent: Component { wasSynchronous = false } - #if DEBUG self.performActionAfterImageContentLoaded(update: false) - #endif self.fetchDisposable?.dispose() self.fetchDisposable = nil diff --git a/submodules/TelegramUI/Components/Stories/StoryFooterPanelComponent/Sources/StoryFooterPanelComponent.swift b/submodules/TelegramUI/Components/Stories/StoryFooterPanelComponent/Sources/StoryFooterPanelComponent.swift index 7c96bddc23..ebbf40a49d 100644 --- a/submodules/TelegramUI/Components/Stories/StoryFooterPanelComponent/Sources/StoryFooterPanelComponent.swift +++ b/submodules/TelegramUI/Components/Stories/StoryFooterPanelComponent/Sources/StoryFooterPanelComponent.swift @@ -12,17 +12,20 @@ import TelegramCore public final class StoryFooterPanelComponent: Component { public let context: AccountContext public let storyItem: EngineStoryItem? + public let expandViewStats: () -> Void public let deleteAction: () -> Void public let moreAction: (UIView, ContextGesture?) -> Void public init( context: AccountContext, storyItem: EngineStoryItem?, + expandViewStats: @escaping () -> Void, deleteAction: @escaping () -> Void, moreAction: @escaping (UIView, ContextGesture?) -> Void ) { self.context = context self.storyItem = storyItem + self.expandViewStats = expandViewStats self.deleteAction = deleteAction self.moreAction = moreAction } @@ -38,6 +41,7 @@ public final class StoryFooterPanelComponent: Component { } public final class View: UIView { + private let viewStatsButton: HighlightableButton private let viewStatsText = ComponentView() private let deleteButton = ComponentView() private var moreButton: MoreHeaderButton? @@ -49,18 +53,31 @@ public final class StoryFooterPanelComponent: Component { private weak var state: EmptyComponentState? override init(frame: CGRect) { + self.viewStatsButton = HighlightableButton() + self.avatarsContext = AnimatedAvatarSetContext() self.avatarsNode = AnimatedAvatarSetNode() super.init(frame: frame) - self.addSubview(self.avatarsNode.view) + self.avatarsNode.view.isUserInteractionEnabled = false + self.viewStatsButton.addSubview(self.avatarsNode.view) + self.addSubview(self.viewStatsButton) + + self.viewStatsButton.addTarget(self, action: #selector(self.viewStatsPressed), for: .touchUpInside) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + @objc private func viewStatsPressed() { + guard let component = self.component else { + return + } + component.expandViewStats() + } + func update(component: StoryFooterPanelComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { self.component = component self.state = state @@ -85,16 +102,22 @@ public final class StoryFooterPanelComponent: Component { leftOffset = avatarsNodeFrame.maxX + avatarSpacing } - let viewsText: String + var viewCount = 0 if let views = component.storyItem?.views, views.seenCount != 0 { - if views.seenCount == 1 { - viewsText = "1 view" - } else { - viewsText = "\(views.seenCount) views" - } - } else { - viewsText = "No views yet" + viewCount = views.seenCount } + + let viewsText: String + if viewCount == 0 { + viewsText = "No Views" + } else if viewCount == 1 { + viewsText = "1 view" + } else { + viewsText = "\(viewCount) views" + } + + self.viewStatsButton.isEnabled = viewCount != 0 + let viewStatsTextSize = self.viewStatsText.update( transition: .immediate, component: AnyComponent(Text(text: viewsText, font: Font.regular(15.0), color: .white)), @@ -105,12 +128,15 @@ public final class StoryFooterPanelComponent: Component { if let viewStatsTextView = self.viewStatsText.view { if viewStatsTextView.superview == nil { viewStatsTextView.layer.anchorPoint = CGPoint() - self.addSubview(viewStatsTextView) + viewStatsTextView.isUserInteractionEnabled = false + self.viewStatsButton.addSubview(viewStatsTextView) } transition.setPosition(view: viewStatsTextView, position: viewStatsTextFrame.origin) transition.setBounds(view: viewStatsTextView, bounds: CGRect(origin: CGPoint(), size: viewStatsTextFrame.size)) } + transition.setFrame(view: self.viewStatsButton, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: viewStatsTextFrame.maxX, height: viewStatsTextFrame.maxY + 8.0))) + var rightContentOffset: CGFloat = availableSize.width - 12.0 let deleteButtonSize = self.deleteButton.update( diff --git a/submodules/TelegramUI/Components/Stories/StoryPeerListComponent/BUILD b/submodules/TelegramUI/Components/Stories/StoryPeerListComponent/BUILD index 6a860cab0a..5e5793b2d8 100644 --- a/submodules/TelegramUI/Components/Stories/StoryPeerListComponent/BUILD +++ b/submodules/TelegramUI/Components/Stories/StoryPeerListComponent/BUILD @@ -19,6 +19,7 @@ swift_library( "//submodules/SSignalKit/SwiftSignalKit", "//submodules/TelegramPresentationData", "//submodules/AvatarNode", + "//submodules/ContextUI", ], visibility = [ "//visibility:public", diff --git a/submodules/TelegramUI/Components/Stories/StoryPeerListComponent/Sources/StoryPeerListComponent.swift b/submodules/TelegramUI/Components/Stories/StoryPeerListComponent/Sources/StoryPeerListComponent.swift index 52a10a84ea..9b6251804f 100644 --- a/submodules/TelegramUI/Components/Stories/StoryPeerListComponent/Sources/StoryPeerListComponent.swift +++ b/submodules/TelegramUI/Components/Stories/StoryPeerListComponent/Sources/StoryPeerListComponent.swift @@ -24,6 +24,7 @@ public final class StoryPeerListComponent: Component { public let storySubscriptions: EngineStorySubscriptions? public let collapseFraction: CGFloat public let peerAction: (EnginePeer?) -> Void + public let contextPeerAction: (ContextExtractedContentContainingNode, ContextGesture, EnginePeer) -> Void public init( externalState: ExternalState, @@ -32,7 +33,8 @@ public final class StoryPeerListComponent: Component { strings: PresentationStrings, storySubscriptions: EngineStorySubscriptions?, collapseFraction: CGFloat, - peerAction: @escaping (EnginePeer?) -> Void + peerAction: @escaping (EnginePeer?) -> Void, + contextPeerAction: @escaping (ContextExtractedContentContainingNode, ContextGesture, EnginePeer) -> Void ) { self.externalState = externalState self.context = context @@ -41,6 +43,7 @@ public final class StoryPeerListComponent: Component { self.storySubscriptions = storySubscriptions self.collapseFraction = collapseFraction self.peerAction = peerAction + self.contextPeerAction = contextPeerAction } public static func ==(lhs: StoryPeerListComponent, rhs: StoryPeerListComponent) -> Bool { @@ -315,7 +318,8 @@ public final class StoryPeerListComponent: Component { collapsedWidth: collapsedItemWidth, leftNeighborDistance: leftNeighborDistance, rightNeighborDistance: rightNeighborDistance, - action: component.peerAction + action: component.peerAction, + contextGesture: component.contextPeerAction )), environment: {}, containerSize: itemLayout.itemSize diff --git a/submodules/TelegramUI/Components/Stories/StoryPeerListComponent/Sources/StoryPeerListItemComponent.swift b/submodules/TelegramUI/Components/Stories/StoryPeerListComponent/Sources/StoryPeerListItemComponent.swift index 342d70f55d..b2ba61fb16 100644 --- a/submodules/TelegramUI/Components/Stories/StoryPeerListComponent/Sources/StoryPeerListItemComponent.swift +++ b/submodules/TelegramUI/Components/Stories/StoryPeerListComponent/Sources/StoryPeerListItemComponent.swift @@ -9,6 +9,8 @@ import TelegramCore import SwiftSignalKit import TelegramPresentationData import AvatarNode +import ContextUI +import AsyncDisplayKit private func calculateCircleIntersection(center: CGPoint, otherCenter: CGPoint, radius: CGFloat) -> (point1Angle: CGFloat, point2Angle: CGFloat)? { let distanceVector = CGPoint(x: otherCenter.x - center.x, y: otherCenter.y - center.y) @@ -142,6 +144,7 @@ public final class StoryPeerListItemComponent: Component { public let leftNeighborDistance: CGFloat? public let rightNeighborDistance: CGFloat? public let action: (EnginePeer) -> Void + public let contextGesture: (ContextExtractedContentContainingNode, ContextGesture, EnginePeer) -> Void public init( context: AccountContext, @@ -155,7 +158,8 @@ public final class StoryPeerListItemComponent: Component { collapsedWidth: CGFloat, leftNeighborDistance: CGFloat?, rightNeighborDistance: CGFloat?, - action: @escaping (EnginePeer) -> Void + action: @escaping (EnginePeer) -> Void, + contextGesture: @escaping (ContextExtractedContentContainingNode, ContextGesture, EnginePeer) -> Void ) { self.context = context self.theme = theme @@ -169,6 +173,7 @@ public final class StoryPeerListItemComponent: Component { self.leftNeighborDistance = leftNeighborDistance self.rightNeighborDistance = rightNeighborDistance self.action = action + self.contextGesture = contextGesture } public static func ==(lhs: StoryPeerListItemComponent, rhs: StoryPeerListItemComponent) -> Bool { @@ -208,7 +213,13 @@ public final class StoryPeerListItemComponent: Component { return true } - public final class View: HighlightTrackingButton { + public final class View: UIView { + private let extractedContainerNode: ContextExtractedContentContainingNode + private let containerNode: ContextControllerSourceNode + private let extractedBackgroundView: UIImageView + + private let button: HighlightTrackingButton + private let avatarContainer: UIView private var avatarNode: AvatarNode? private var avatarAddBadgeView: UIImageView? @@ -223,6 +234,13 @@ public final class StoryPeerListItemComponent: Component { private weak var componentState: EmptyComponentState? public override init(frame: CGRect) { + self.button = HighlightTrackingButton() + + self.extractedContainerNode = ContextExtractedContentContainingNode() + self.containerNode = ContextControllerSourceNode() + self.extractedBackgroundView = UIImageView() + self.extractedBackgroundView.alpha = 0.0 + self.avatarContainer = UIView() self.avatarContainer.isUserInteractionEnabled = false @@ -238,9 +256,16 @@ public final class StoryPeerListItemComponent: Component { super.init(frame: frame) - self.addSubview(self.avatarContainer) + self.extractedContainerNode.contentNode.view.addSubview(self.extractedBackgroundView) - self.layer.addSublayer(self.indicatorColorLayer) + self.containerNode.addSubnode(self.extractedContainerNode) + self.containerNode.targetNodeForActivationProgress = self.extractedContainerNode.contentNode + self.addSubview(self.containerNode.view) + + self.extractedContainerNode.contentNode.view.addSubview(self.button) + self.button.addSubview(self.avatarContainer) + + self.button.layer.addSublayer(self.indicatorColorLayer) self.indicatorMaskLayer.addSublayer(self.indicatorShapeLayer) self.indicatorColorLayer.mask = self.indicatorMaskLayer @@ -252,7 +277,7 @@ public final class StoryPeerListItemComponent: Component { self.indicatorShapeLayer.lineWidth = 2.0 self.indicatorShapeLayer.lineCap = .round - self.highligthedChanged = { [weak self] highlighted in + self.button.highligthedChanged = { [weak self] highlighted in guard let self else { return } @@ -264,7 +289,36 @@ public final class StoryPeerListItemComponent: Component { self.layer.animateAlpha(from: previousAlpha, to: self.alpha, duration: 0.25) } } - self.addTarget(self, action: #selector(self.pressed), for: .touchUpInside) + self.button.addTarget(self, action: #selector(self.pressed), for: .touchUpInside) + + self.containerNode.activated = { [weak self] gesture, _ in + guard let self, let component = self.component else { + return + } + self.button.isEnabled = false + DispatchQueue.main.async { [weak self] in + guard let self else { + return + } + self.button.isEnabled = true + } + component.contextGesture(self.extractedContainerNode, gesture, component.peer) + } + + self.extractedContainerNode.willUpdateIsExtractedToContextPreview = { [weak self] isExtracted, transition in + guard let self, let component = self.component else { + return + } + + if isExtracted { + self.extractedBackgroundView.image = generateStretchableFilledCircleImage(diameter: 24.0, color: component.theme.contextMenu.backgroundColor) + } + transition.updateAlpha(layer: self.extractedBackgroundView.layer, alpha: isExtracted ? 1.0 : 0.0, completion: { [weak self] _ in + if !isExtracted { + self?.extractedBackgroundView.image = nil + } + }) + } } required public init?(coder: NSCoder) { @@ -283,10 +337,22 @@ public final class StoryPeerListItemComponent: Component { } func update(component: StoryPeerListItemComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + let size = availableSize + + transition.setFrame(view: self.button, frame: CGRect(origin: CGPoint(), size: size)) + transition.setFrame(view: self.extractedBackgroundView, frame: CGRect(origin: CGPoint(), size: size).insetBy(dx: -4.0, dy: -4.0)) + + self.extractedContainerNode.frame = CGRect(origin: CGPoint(), size: size) + self.extractedContainerNode.contentNode.frame = CGRect(origin: CGPoint(), size: size) + self.extractedContainerNode.contentRect = CGRect(origin: CGPoint(x: self.extractedBackgroundView.frame.minX - 2.0, y: self.extractedBackgroundView.frame.minY), size: CGSize(width: self.extractedBackgroundView.frame.width + 4.0, height: self.extractedBackgroundView.frame.height)) + self.containerNode.frame = CGRect(origin: CGPoint(), size: size) + let hadUnseen = self.component?.hasUnseen let hadProgress = self.component?.progress != nil let themeUpdated = self.component?.theme !== component.theme + self.containerNode.isGestureEnabled = component.peer.id != component.context.account.peerId + self.component = component self.componentState = state @@ -440,7 +506,7 @@ public final class StoryPeerListItemComponent: Component { if titleView.superview == nil { titleView.layer.anchorPoint = CGPoint() titleView.isUserInteractionEnabled = false - self.addSubview(titleView) + self.button.addSubview(titleView) } transition.setPosition(view: titleView, position: titleFrame.origin) titleView.bounds = CGRect(origin: CGPoint(), size: titleFrame.size)