diff --git a/submodules/AvatarNode/Sources/AvatarNode.swift b/submodules/AvatarNode/Sources/AvatarNode.swift index 03ecb81b85..355af220c2 100644 --- a/submodules/AvatarNode/Sources/AvatarNode.swift +++ b/submodules/AvatarNode/Sources/AvatarNode.swift @@ -669,6 +669,8 @@ public final class AvatarNode: ASDisplayNode { private var storyIndicator: ComponentView? public private(set) var storyPresentationParams: StoryPresentationParams? + private var loadingStatuses = Bag() + public struct StoryStats: Equatable { public var totalCount: Int public var unseenCount: Int @@ -742,6 +744,10 @@ public final class AvatarNode: ASDisplayNode { self.addSubnode(self.contentNode) } + deinit { + self.cancelLoading() + } + override public var frame: CGRect { get { return super.frame @@ -894,7 +900,8 @@ public final class AvatarNode: ASDisplayNode { counters: AvatarStoryIndicatorComponent.Counters( totalCount: storyStats.totalCount, unseenCount: storyStats.unseenCount - ) + ), + displayProgress: !self.loadingStatuses.isEmpty )), environment: {}, containerSize: indicatorSize @@ -918,4 +925,43 @@ public final class AvatarNode: ASDisplayNode { } } } + + public func cancelLoading() { + for disposable in self.loadingStatuses.copyItems() { + disposable.dispose() + } + self.loadingStatuses.removeAll() + self.updateStoryIndicator(transition: .immediate) + } + + public func pushLoadingStatus(signal: Signal) -> Disposable { + let disposable = MetaDisposable() + let index = self.loadingStatuses.add(disposable) + + DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.2, execute: { [weak self] in + self?.updateStoryIndicator(transition: .immediate) + }) + + disposable.set(signal.start(completed: { [weak self] in + Queue.mainQueue().async { + guard let self else { + return + } + self.loadingStatuses.remove(index) + if self.loadingStatuses.isEmpty { + self.updateStoryIndicator(transition: .immediate) + } + } + })) + + return ActionDisposable { [weak self] in + guard let self else { + return + } + self.loadingStatuses.remove(index) + if self.loadingStatuses.isEmpty { + self.updateStoryIndicator(transition: .immediate) + } + } + } } diff --git a/submodules/ChatListUI/Sources/ChatListController.swift b/submodules/ChatListUI/Sources/ChatListController.swift index ccc3d5bb4e..1cdcfe4911 100644 --- a/submodules/ChatListUI/Sources/ChatListController.swift +++ b/submodules/ChatListUI/Sources/ChatListController.swift @@ -1337,87 +1337,16 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController guard let self else { return } - - let isHidden: Bool - let focusedPeerId: EnginePeer.Id? - let singlePeer: Bool - switch subject { - case let .peer(peerId): - isHidden = self.location == .chatList(groupId: .archive) - focusedPeerId = peerId - singlePeer = true - case .archive: - isHidden = true - focusedPeerId = nil - singlePeer = false + guard let itemNode = itemNode as? ChatListItemNode else { + return } - let storyContent = StoryContentContextImpl(context: self.context, isHidden: isHidden, focusedPeerId: focusedPeerId, singlePeer: singlePeer) - let _ = (storyContent.state - |> filter { $0.slice != nil } - |> take(1) - |> deliverOnMainQueue).start(next: { [weak self, weak itemNode] state in - guard let self else { - return - } - - var transitionIn: StoryContainerScreen.TransitionIn? - if let itemNode = itemNode as? ChatListItemNode { - transitionIn = StoryContainerScreen.TransitionIn( - sourceView: itemNode.avatarNode.view, - sourceRect: itemNode.avatarNode.view.bounds, - sourceCornerRadius: itemNode.avatarNode.view.bounds.height * 0.5, - sourceIsAvatar: true - ) - itemNode.avatarNode.isHidden = true - } - - let storyContainerScreen = StoryContainerScreen( - context: self.context, - content: storyContent, - transitionIn: transitionIn, - transitionOut: { _, _ in - if let itemNode = itemNode as? ChatListItemNode { - let transitionView = itemNode.avatarNode.view - let destinationView = itemNode.view - let rect = transitionView.convert(transitionView.bounds, to: destinationView) - return StoryContainerScreen.TransitionOut( - destinationView: destinationView, - transitionView: StoryContainerScreen.TransitionView( - makeView: { [weak transitionView] in - let parentView = UIView() - if let copyView = transitionView?.snapshotContentTree(unhide: true) { - parentView.addSubview(copyView) - } - return parentView - }, - updateView: { copyView, state, transition in - guard let view = copyView.subviews.first else { - return - } - let size = state.sourceSize.interpolate(to: CGSize(width: state.destinationSize.width, height: state.destinationSize.height), amount: state.progress) - let scaleSize = state.sourceSize.interpolate(to: CGSize(width: state.destinationSize.width - 7.0, height: state.destinationSize.height - 7.0), amount: state.progress) - transition.setPosition(view: view, position: CGPoint(x: size.width * 0.5, y: size.height * 0.5)) - transition.setScale(view: view, scale: scaleSize.width / state.destinationSize.width) - }, - insertCloneTransitionView: nil - ), - destinationRect: rect, - destinationCornerRadius: rect.height * 0.5, - destinationIsAvatar: true, - completed: { [weak itemNode] in - guard let itemNode else { - return - } - itemNode.avatarNode.isHidden = false - } - ) - } - return nil - } - ) - self.push(storyContainerScreen) - }) + switch subject { + case .archive: + StoryContainerScreen.openArchivedStories(context: self.context, parentController: self, avatarNode: itemNode.avatarNode) + case let .peer(peerId): + StoryContainerScreen.openPeerStories(context: self.context, peerId: peerId, parentController: self, avatarNode: itemNode.avatarNode) + } } self.chatListDisplayNode.peerContextAction = { [weak self] peer, source, node, gesture, location in @@ -3608,6 +3537,88 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController self.shouldFixStorySubscriptionOrder = true } } + + if peerId != self.context.account.peerId { + if let navigationBarView = self.chatListDisplayNode.navigationBarView.view as? ChatListNavigationBar.View { + if navigationBarView.storiesUnlocked { + if let componentView = self.chatListHeaderView(), let storyPeerListView = componentView.storyPeerListView() { + let _ = storyPeerListView + + StoryContainerScreen.openPeerStoriesCustom( + context: self.context, + peerId: peerId, + isHidden: self.location == .chatList(groupId: .archive), + singlePeer: false, + parentController: self, + transitionIn: { [weak self] in + guard let self else { + return nil + } + var transitionIn: StoryContainerScreen.TransitionIn? + if let navigationBarView = self.chatListDisplayNode.navigationBarView.view as? ChatListNavigationBar.View { + if navigationBarView.storiesUnlocked { + if let componentView = self.chatListHeaderView() { + if let (transitionView, _) = componentView.storyPeerListView()?.transitionViewForItem(peerId: peerId) { + transitionIn = StoryContainerScreen.TransitionIn( + sourceView: transitionView, + sourceRect: transitionView.bounds, + sourceCornerRadius: transitionView.bounds.height * 0.5, + sourceIsAvatar: true + ) + } + } + } + } + return transitionIn + }, + transitionOut: { [weak self] peerId in + guard let self else { + return nil + } + + if let navigationBarView = self.chatListDisplayNode.navigationBarView.view as? ChatListNavigationBar.View { + if navigationBarView.storiesUnlocked { + if let componentView = self.chatListHeaderView() { + if let (transitionView, transitionContentView) = componentView.storyPeerListView()?.transitionViewForItem(peerId: peerId) { + return StoryContainerScreen.TransitionOut( + destinationView: transitionView, + transitionView: transitionContentView, + destinationRect: transitionView.bounds, + destinationCornerRadius: transitionView.bounds.height * 0.5, + destinationIsAvatar: true, + completed: {} + ) + } + } + } + } + + return nil + }, + setFocusedItem: { [weak self] focusedItem in + guard let self else { + return + } + if let componentView = self.chatListHeaderView() { + componentView.storyPeerListView()?.setPreviewedItem(signal: focusedItem) + } + }, + setProgress: { [weak self] signal in + guard let self else { + return + } + if let componentView = self.chatListHeaderView() { + componentView.storyPeerListView()?.setLoadingItem(peerId: peerId, signal: signal) + } + } + ) + + return + } + } + } + } + let storyContent = StoryContentContextImpl(context: self.context, isHidden: self.location == .chatList(groupId: .archive), focusedPeerId: peerId, singlePeer: false, fixedOrder: self.fixedStorySubscriptionOrder) let _ = (storyContent.state |> take(1) diff --git a/submodules/ChatListUI/Sources/Node/ChatListItem.swift b/submodules/ChatListUI/Sources/Node/ChatListItem.swift index c1910f984d..bb6980b673 100644 --- a/submodules/ChatListUI/Sources/Node/ChatListItem.swift +++ b/submodules/ChatListUI/Sources/Node/ChatListItem.swift @@ -26,7 +26,6 @@ import TextNodeWithEntities import ComponentFlow import EmojiStatusComponent import AvatarVideoNode -import AvatarStoryIndicatorComponent public enum ChatListItemContent { public struct ThreadInfo: Equatable { @@ -941,7 +940,7 @@ class ChatListItemNode: ItemListRevealOptionsItemNode { var avatarIconView: ComponentHostView? var avatarIconComponent: EmojiStatusComponent? var avatarVideoNode: AvatarVideoNode? - var avatarStoryIndicator: ComponentView? + var avatarTapRecognizer: UITapGestureRecognizer? private var inlineNavigationMarkLayer: SimpleLayer? @@ -1310,6 +1309,15 @@ class ChatListItemNode: ItemListRevealOptionsItemNode { } item.interaction.activateChatPreview(item, threadId, strongSelf.contextContainer, gesture, nil) } + + self.onDidLoad { [weak self] _ in + guard let self else { + return + } + let avatarTapRecognizer = UITapGestureRecognizer(target: self, action: #selector(self.avatarStoryTapGesture(_:))) + self.avatarTapRecognizer = avatarTapRecognizer + self.avatarNode.view.addGestureRecognizer(avatarTapRecognizer) + } } deinit { @@ -1327,28 +1335,48 @@ class ChatListItemNode: ItemListRevealOptionsItemNode { let previousItem = self.item self.item = item + var storyState: ChatListItemContent.StoryState? + if case let .peer(peerData) = item.content { + storyState = peerData.storyState + } else if case let .groupReference(groupReference) = item.content { + storyState = groupReference.storyState + } + var peer: EnginePeer? var displayAsMessage = false var enablePreview = true switch item.content { - case let .peer(peerData): - displayAsMessage = peerData.displayAsMessage - if displayAsMessage, case let .user(author) = peerData.messages.last?.author { - peer = .user(author) - } else { - peer = peerData.peer.chatMainPeer - } - if peerData.peer.peerId.namespace == Namespaces.Peer.SecretChat { - enablePreview = false - } - case let .groupReference(groupReferenceData): - if let previousItem = previousItem, case let .groupReference(previousGroupReferenceData) = previousItem.content, groupReferenceData.hiddenByDefault != previousGroupReferenceData.hiddenByDefault { - UIView.transition(with: self.avatarNode.view, duration: 0.3, options: [.transitionCrossDissolve], animations: { - }, completion: nil) - } - self.avatarNode.setPeer(context: item.context, theme: item.presentationData.theme, peer: peer, overrideImage: .archivedChatsIcon(hiddenByDefault: groupReferenceData.hiddenByDefault), emptyColor: item.presentationData.theme.list.mediaPlaceholderColor, synchronousLoad: synchronousLoads) + case let .peer(peerData): + displayAsMessage = peerData.displayAsMessage + if displayAsMessage, case let .user(author) = peerData.messages.last?.author { + peer = .user(author) + } else { + peer = peerData.peer.chatMainPeer + } + if peerData.peer.peerId.namespace == Namespaces.Peer.SecretChat { + enablePreview = false + } + case let .groupReference(groupReferenceData): + if let previousItem = previousItem, case let .groupReference(previousGroupReferenceData) = previousItem.content, groupReferenceData.hiddenByDefault != previousGroupReferenceData.hiddenByDefault { + UIView.transition(with: self.avatarNode.view, duration: 0.3, options: [.transitionCrossDissolve], animations: { + }, completion: nil) + } + self.avatarNode.setPeer(context: item.context, theme: item.presentationData.theme, peer: peer, overrideImage: .archivedChatsIcon(hiddenByDefault: groupReferenceData.hiddenByDefault), emptyColor: item.presentationData.theme.list.mediaPlaceholderColor, synchronousLoad: synchronousLoads) } + self.avatarNode.setStoryStats(storyStats: storyState.flatMap { storyState in + return AvatarNode.StoryStats( + totalCount: storyState.stats.totalCount, + unseenCount: storyState.stats.unseenCount, + hasUnseenCloseFriendsItems: storyState.hasUnseenCloseFriends + ) + }, presentationParams: AvatarNode.StoryPresentationParams( + colors: AvatarNode.Colors(theme: item.presentationData.theme), + lineWidth: 2.33, + inactiveLineWidth: 1.33 + ), transition: .immediate) + self.avatarTapRecognizer?.isEnabled = storyState != nil + if let peer = peer { var overrideImage: AvatarNodeImageOverride? if peer.id.isReplies { @@ -2792,13 +2820,6 @@ class ChatListItemNode: ItemListRevealOptionsItemNode { let contentRect = rawContentRect.offsetBy(dx: editingOffset + leftInset + revealOffset, dy: 0.0) - var storyState: ChatListItemContent.StoryState? - if case let .peer(peerData) = item.content { - storyState = peerData.storyState - } else if case let .groupReference(groupReference) = item.content { - storyState = groupReference.storyState - } - let avatarFrame = CGRect(origin: CGPoint(x: leftInset - avatarLeftInset + editingOffset + 10.0 + revealOffset, y: floor((itemHeight - avatarDiameter) / 2.0)), size: CGSize(width: avatarDiameter, height: avatarDiameter)) var avatarScaleOffset: CGFloat = 0.0 var avatarScale: CGFloat = 1.0 @@ -2810,11 +2831,6 @@ class ChatListItemNode: ItemListRevealOptionsItemNode { avatarScaleOffset = targetAvatarScaleOffset * inlineNavigationLocation.progress } - let storyIndicatorScale = avatarScale - if storyState != nil { - avatarScale *= (avatarFrame.width - 4.0 * 2.0) / avatarFrame.width - } - transition.updateFrame(node: strongSelf.avatarContainerNode, frame: avatarFrame) transition.updatePosition(node: strongSelf.avatarNode, position: avatarFrame.offsetBy(dx: -avatarFrame.minX, dy: -avatarFrame.minY).center.offsetBy(dx: avatarScaleOffset, dy: 0.0)) transition.updateBounds(node: strongSelf.avatarNode, bounds: CGRect(origin: CGPoint(), size: avatarFrame.size)) @@ -2822,55 +2838,6 @@ class ChatListItemNode: ItemListRevealOptionsItemNode { strongSelf.avatarNode.updateSize(size: avatarFrame.size) strongSelf.updateVideoVisibility() - if let storyState { - var indicatorTransition = Transition(transition) - let avatarStoryIndicator: ComponentView - if let current = strongSelf.avatarStoryIndicator { - avatarStoryIndicator = current - } else { - indicatorTransition = .immediate - avatarStoryIndicator = ComponentView() - strongSelf.avatarStoryIndicator = avatarStoryIndicator - } - - var indicatorFrame = CGRect(origin: CGPoint(x: avatarFrame.minX + 4.0, y: avatarFrame.minY + 4.0), size: CGSize(width: avatarFrame.width - 4.0 - 4.0, height: avatarFrame.height - 4.0 - 4.0)) - indicatorFrame.origin.x -= (avatarFrame.width - avatarFrame.width * storyIndicatorScale) * 0.5 - - let _ = avatarStoryIndicator.update( - transition: indicatorTransition, - component: AnyComponent(AvatarStoryIndicatorComponent( - hasUnseen: storyState.stats.unseenCount != 0, - hasUnseenCloseFriendsItems: storyState.hasUnseenCloseFriends, - colors: AvatarStoryIndicatorComponent.Colors(theme: item.presentationData.theme), - activeLineWidth: 2.33, - inactiveLineWidth: 1.33, - counters: AvatarStoryIndicatorComponent.Counters( - totalCount: storyState.stats.totalCount, - unseenCount: storyState.stats.unseenCount - ) - )), - environment: {}, - containerSize: indicatorFrame.size - ) - if let avatarStoryIndicatorView = avatarStoryIndicator.view { - if avatarStoryIndicatorView.superview == nil { - avatarStoryIndicatorView.isUserInteractionEnabled = true - avatarStoryIndicatorView.addGestureRecognizer(UITapGestureRecognizer(target: strongSelf, action: #selector(strongSelf.avatarStoryTapGesture(_:)))) - - strongSelf.contextContainer.view.insertSubview(avatarStoryIndicatorView, belowSubview: strongSelf.avatarContainerNode.view) - } - - indicatorTransition.setPosition(view: avatarStoryIndicatorView, position: indicatorFrame.center) - indicatorTransition.setBounds(view: avatarStoryIndicatorView, bounds: CGRect(origin: CGPoint(), size: indicatorFrame.size)) - indicatorTransition.setScale(view: avatarStoryIndicatorView, scale: storyIndicatorScale) - } - } else { - if let avatarStoryIndicator = strongSelf.avatarStoryIndicator { - strongSelf.avatarStoryIndicator = nil - avatarStoryIndicator.view?.removeFromSuperview() - } - } - var itemPeerId: EnginePeer.Id? if case let .chatList(index) = item.index { itemPeerId = index.messageIndex.id.peerId @@ -3900,8 +3867,10 @@ class ChatListItemNode: ItemListRevealOptionsItemNode { if let _ = item.interaction.inlineNavigationLocation { } else { - if let avatarStoryIndicatorView = self.avatarStoryIndicator?.view, let result = avatarStoryIndicatorView.hitTest(self.view.convert(point, to: avatarStoryIndicatorView), with: event) { - return result + if self.avatarNode.storyStats != nil { + if let result = self.avatarNode.view.hitTest(self.view.convert(point, to: self.avatarNode.view), with: event) { + return result + } } } @@ -3919,7 +3888,6 @@ class ChatListItemNode: ItemListRevealOptionsItemNode { case .groupReference: item.interaction.openStories(.archive, self) } - } } } diff --git a/submodules/ChatListUI/Sources/Node/ChatListNode.swift b/submodules/ChatListUI/Sources/Node/ChatListNode.swift index 834a1d8bbf..9132cd168f 100644 --- a/submodules/ChatListUI/Sources/Node/ChatListNode.swift +++ b/submodules/ChatListUI/Sources/Node/ChatListNode.swift @@ -2403,6 +2403,7 @@ public final class ChatListNode: ListView { strongSelf.enqueueHistoryPreloadUpdate() } + var refreshStoryPeerIds: [PeerId] = [] var isHiddenItemVisible = false if let range = range.visibleRange { let entryCount = chatListView.filteredEntries.count @@ -2418,6 +2419,11 @@ public final class ChatListNode: ListView { if let threadInfo, threadInfo.isHidden { isHiddenItemVisible = true } + + if let peer = peerEntry.peer.chatMainPeer, !peerEntry.isContact, case let .user(user) = peer { + refreshStoryPeerIds.append(user.id) + } + break case .GroupReferenceEntry: isHiddenItemVisible = true @@ -2433,6 +2439,9 @@ public final class ChatListNode: ListView { return state } } + if !refreshStoryPeerIds.isEmpty { + strongSelf.context.account.viewTracker.refreshStoryStatsForPeerIds(peerIds: refreshStoryPeerIds) + } } } diff --git a/submodules/ContactListUI/Sources/ContactsController.swift b/submodules/ContactListUI/Sources/ContactsController.swift index bfe2f1bf6c..f8faa34edc 100644 --- a/submodules/ContactListUI/Sources/ContactsController.swift +++ b/submodules/ContactListUI/Sources/ContactsController.swift @@ -512,52 +512,10 @@ public class ContactsController: ViewController { guard let self else { return } - - let storyContent = StoryContentContextImpl(context: self.context, isHidden: true, focusedPeerId: peer.id, singlePeer: true) - let _ = (storyContent.state - |> take(1) - |> deliverOnMainQueue).start(next: { [weak self, weak sourceNode] storyContentState in - guard let self else { - return - } - - var transitionIn: StoryContainerScreen.TransitionIn? - if let itemNode = sourceNode as? ContactsPeerItemNode { - transitionIn = StoryContainerScreen.TransitionIn( - sourceView: itemNode.avatarNode.view, - sourceRect: itemNode.avatarNode.view.bounds, - sourceCornerRadius: itemNode.avatarNode.view.bounds.height * 0.5, - sourceIsAvatar: true - ) - itemNode.avatarNode.isHidden = true - } - - let storyContainerScreen = StoryContainerScreen( - context: self.context, - content: storyContent, - transitionIn: transitionIn, - transitionOut: { _, _ in - if let itemNode = sourceNode as? ContactsPeerItemNode { - let rect = itemNode.avatarNode.view.convert(itemNode.avatarNode.view.bounds, to: itemNode.view) - return StoryContainerScreen.TransitionOut( - destinationView: itemNode.view, - transitionView: nil, - destinationRect: rect, - destinationCornerRadius: rect.height * 0.5, - destinationIsAvatar: true, - completed: { [weak itemNode] in - guard let itemNode else { - return - } - itemNode.avatarNode.isHidden = false - } - ) - } - return nil - } - ) - self.push(storyContainerScreen) - }) + + if let itemNode = sourceNode as? ContactsPeerItemNode { + StoryContainerScreen.openPeerStories(context: self.context, peerId: peer.id, parentController: self, avatarNode: itemNode.avatarNode) + } } } diff --git a/submodules/Postbox/Sources/ChatListView.swift b/submodules/Postbox/Sources/ChatListView.swift index c6d7d7ca68..8175b030d5 100644 --- a/submodules/Postbox/Sources/ChatListView.swift +++ b/submodules/Postbox/Sources/ChatListView.swift @@ -274,14 +274,17 @@ func fetchPeerStoryStats(postbox: PostboxImpl, peerId: PeerId) -> PeerStoryStats if topItems.id == 0 { return nil } - guard let state = postbox.storyPeerStatesTable.get(key: .peer(peerId)) else { - return nil + + var maxSeenId: Int32 = 0 + if let state = postbox.storyPeerStatesTable.get(key: .peer(peerId)) { + maxSeenId = state.maxSeenId } + if topItems.isExact { - let stats = postbox.storyItemsTable.getStats(peerId: peerId, maxSeenId: state.maxSeenId) + let stats = postbox.storyItemsTable.getStats(peerId: peerId, maxSeenId: maxSeenId) return PeerStoryStats(totalCount: stats.total, unseenCount: stats.unseen) } else { - return PeerStoryStats(totalCount: 1, unseenCount: topItems.id > state.maxSeenId ? 1 : 0) + return PeerStoryStats(totalCount: 1, unseenCount: topItems.id > maxSeenId ? 1 : 0) } } diff --git a/submodules/TelegramCore/Sources/State/AccountViewTracker.swift b/submodules/TelegramCore/Sources/State/AccountViewTracker.swift index 6215a58e30..6a30e583ff 100644 --- a/submodules/TelegramCore/Sources/State/AccountViewTracker.swift +++ b/submodules/TelegramCore/Sources/State/AccountViewTracker.swift @@ -296,6 +296,9 @@ public final class AccountViewTracker { private var refreshStoriesForMessageIdsAndTimestamps: [MessageId: Int32] = [:] private var nextUpdatedUnsupportedMediaDisposableId: Int32 = 0 private var updatedUnsupportedMediaDisposables = DisposableDict() + private var refreshStoriesForPeerIdsAndTimestamps: [PeerId: Int32] = [:] + private var refreshStoriesForPeerIdsDebounceDisposable: Disposable? + private var pendingRefreshStoriesForPeerIds: [PeerId] = [] private var updatedSeenPersonalMessageIds = Set() private var updatedReactionsSeenForMessageIds = Set() @@ -1262,6 +1265,89 @@ public final class AccountViewTracker { } } + public func refreshStoryStatsForPeerIds(peerIds: [PeerId]) { + self.queue.async { + self.pendingRefreshStoriesForPeerIds.append(contentsOf: peerIds) + + if self.refreshStoriesForPeerIdsDebounceDisposable == nil { + self.refreshStoriesForPeerIdsDebounceDisposable = (Signal.complete() |> delay(0.15, queue: self.queue)).start(completed: { + self.refreshStoriesForPeerIdsDebounceDisposable = nil + + let pendingPeerIds = self.pendingRefreshStoriesForPeerIds + self.pendingRefreshStoriesForPeerIds.removeAll() + self.internalRefreshStoryStatsForPeerIds(peerIds: pendingPeerIds) + }) + } + } + } + + private func internalRefreshStoryStatsForPeerIds(peerIds: [PeerId]) { + self.queue.async { + var addedPeerIds: [PeerId] = [] + let timestamp = Int32(CFAbsoluteTimeGetCurrent()) + for peerId in peerIds { + let messageTimestamp = self.refreshStoriesForPeerIdsAndTimestamps[peerId] + var refresh = false + if let messageTimestamp = messageTimestamp { + refresh = messageTimestamp < timestamp - 60 + } else { + refresh = true + } + + if refresh { + self.refreshStoriesForPeerIdsAndTimestamps[peerId] = timestamp + addedPeerIds.append(peerId) + } + } + if !addedPeerIds.isEmpty { + let disposableId = self.nextUpdatedUnsupportedMediaDisposableId + self.nextUpdatedUnsupportedMediaDisposableId += 1 + + if let account = self.account { + let signal = account.postbox.transaction { transaction -> [Api.InputUser] in + return addedPeerIds.compactMap { transaction.getPeer($0).flatMap(apiInputUser) } + } + |> mapToSignal { inputUsers -> Signal in + guard !inputUsers.isEmpty else { + return .complete() + } + + var requests: [Signal] = [] + + let batchCount = 50 + var startIndex = 0 + while startIndex < inputUsers.count { + var slice: [Api.InputUser] = [] + for i in startIndex ..< min(startIndex + batchCount, inputUsers.count) { + slice.append(inputUsers[i]) + } + startIndex += batchCount + requests.append(account.network.request(Api.functions.users.getUsers(id: slice)) + |> `catch` { _ -> Signal<[Api.User], NoError> in + return .single([]) + } + |> mapToSignal { result -> Signal in + return account.postbox.transaction { transaction in + updatePeers(transaction: transaction, accountPeerId: account.peerId, peers: AccumulatedPeers(users: result)) + } + |> ignoreValues + }) + } + + return combineLatest(requests) + |> ignoreValues + } + |> afterDisposed { [weak self] in + self?.queue.async { + self?.updatedUnsupportedMediaDisposables.set(nil, forKey: disposableId) + } + } + self.updatedUnsupportedMediaDisposables.set(signal.start(), forKey: disposableId) + } + } + } + } + public func updateMarkAllMentionsSeen(peerId: PeerId, threadId: Int64?) { self.queue.async { guard let account = self.account else { diff --git a/submodules/TelegramCore/Sources/UpdatePeers.swift b/submodules/TelegramCore/Sources/UpdatePeers.swift index 8e3150945f..1c56a05350 100644 --- a/submodules/TelegramCore/Sources/UpdatePeers.swift +++ b/submodules/TelegramCore/Sources/UpdatePeers.swift @@ -49,6 +49,9 @@ func updatePeers(transaction: Transaction, accountPeerId: PeerId, peers: Accumul if let storiesMaxId = storiesMaxId { transaction.setStoryItemsInexactMaxId(peerId: user.peerId, id: storiesMaxId) } + /*#if DEBUG + transaction.setStoryItemsInexactMaxId(peerId: user.peerId, id: 10) + #endif*/ case .userEmpty: break } diff --git a/submodules/TelegramUI/Components/Stories/AvatarStoryIndicatorComponent/BUILD b/submodules/TelegramUI/Components/Stories/AvatarStoryIndicatorComponent/BUILD index 0a8d337e78..1f197e748e 100644 --- a/submodules/TelegramUI/Components/Stories/AvatarStoryIndicatorComponent/BUILD +++ b/submodules/TelegramUI/Components/Stories/AvatarStoryIndicatorComponent/BUILD @@ -13,6 +13,7 @@ swift_library( "//submodules/Display", "//submodules/ComponentFlow", "//submodules/TelegramPresentationData", + "//submodules/Components/HierarchyTrackingLayer", ], visibility = [ "//visibility:public", diff --git a/submodules/TelegramUI/Components/Stories/AvatarStoryIndicatorComponent/Sources/AvatarStoryIndicatorComponent.swift b/submodules/TelegramUI/Components/Stories/AvatarStoryIndicatorComponent/Sources/AvatarStoryIndicatorComponent.swift index 4b5ad7fdc2..a8caa00a9f 100644 --- a/submodules/TelegramUI/Components/Stories/AvatarStoryIndicatorComponent/Sources/AvatarStoryIndicatorComponent.swift +++ b/submodules/TelegramUI/Components/Stories/AvatarStoryIndicatorComponent/Sources/AvatarStoryIndicatorComponent.swift @@ -2,6 +2,7 @@ import Foundation import UIKit import Display import ComponentFlow +import HierarchyTrackingLayer import TelegramPresentationData public final class AvatarStoryIndicatorComponent: Component { @@ -43,6 +44,7 @@ public final class AvatarStoryIndicatorComponent: Component { public let activeLineWidth: CGFloat public let inactiveLineWidth: CGFloat public let counters: Counters? + public let displayProgress: Bool public init( hasUnseen: Bool, @@ -50,7 +52,8 @@ public final class AvatarStoryIndicatorComponent: Component { colors: Colors, activeLineWidth: CGFloat, inactiveLineWidth: CGFloat, - counters: Counters? + counters: Counters?, + displayProgress: Bool = false ) { self.hasUnseen = hasUnseen self.hasUnseenCloseFriendsItems = hasUnseenCloseFriendsItems @@ -58,6 +61,7 @@ public final class AvatarStoryIndicatorComponent: Component { self.activeLineWidth = activeLineWidth self.inactiveLineWidth = inactiveLineWidth self.counters = counters + self.displayProgress = displayProgress } public static func ==(lhs: AvatarStoryIndicatorComponent, rhs: AvatarStoryIndicatorComponent) -> Bool { @@ -79,11 +83,167 @@ public final class AvatarStoryIndicatorComponent: Component { if lhs.counters != rhs.counters { return false } + if lhs.displayProgress != rhs.displayProgress { + return false + } return true } + private final class ProgressLayer: HierarchyTrackingLayer { + enum Value: Equatable { + case indefinite + case progress(Float) + } + + private struct Params: Equatable { + var size: CGSize + var lineWidth: CGFloat + var value: Value + } + private var currentParams: Params? + + private let uploadProgressLayer = SimpleShapeLayer() + + private let indefiniteDashLayer = SimpleShapeLayer() + private let indefiniteReplicatorLayer = CAReplicatorLayer() + + override init() { + super.init() + + self.uploadProgressLayer.fillColor = nil + self.uploadProgressLayer.strokeColor = UIColor.white.cgColor + self.uploadProgressLayer.lineCap = .round + + self.indefiniteDashLayer.fillColor = nil + self.indefiniteDashLayer.strokeColor = UIColor.white.cgColor + self.indefiniteDashLayer.lineCap = .round + self.indefiniteDashLayer.lineJoin = .round + self.indefiniteDashLayer.strokeEnd = 0.0333 + + let count = 1.0 / self.indefiniteDashLayer.strokeEnd + let angle = (2.0 * Double.pi) / Double(count) + self.indefiniteReplicatorLayer.addSublayer(self.indefiniteDashLayer) + self.indefiniteReplicatorLayer.instanceCount = Int(count) + self.indefiniteReplicatorLayer.instanceTransform = CATransform3DMakeRotation(CGFloat(angle), 0.0, 0.0, 1.0) + self.indefiniteReplicatorLayer.transform = CATransform3DMakeRotation(-.pi / 2.0, 0.0, 0.0, 1.0) + self.indefiniteReplicatorLayer.instanceDelay = 0.025 + + self.didEnterHierarchy = { [weak self] in + guard let self else { + return + } + self.updateAnimations(transition: .immediate) + } + } + + override init(layer: Any) { + super.init(layer: layer) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func reset() { + self.currentParams = nil + self.indefiniteDashLayer.path = nil + self.uploadProgressLayer.path = nil + } + + func updateAnimations(transition: Transition) { + guard let params = self.currentParams else { + return + } + + switch params.value { + case let .progress(progress): + if self.indefiniteReplicatorLayer.superlayer != nil { + self.indefiniteReplicatorLayer.removeFromSuperlayer() + } + if self.uploadProgressLayer.superlayer == nil { + self.addSublayer(self.uploadProgressLayer) + } + transition.setShapeLayerStrokeEnd(layer: self.uploadProgressLayer, strokeEnd: CGFloat(progress)) + if self.uploadProgressLayer.animation(forKey: "rotation") == nil { + let rotationAnimation = CABasicAnimation(keyPath: "transform.rotation.z") + rotationAnimation.duration = 2.0 + rotationAnimation.fromValue = NSNumber(value: Float(0.0)) + rotationAnimation.toValue = NSNumber(value: Float(Double.pi * 2.0)) + rotationAnimation.repeatCount = Float.infinity + rotationAnimation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.linear) + self.uploadProgressLayer.add(rotationAnimation, forKey: "rotation") + } + case .indefinite: + if self.uploadProgressLayer.superlayer == nil { + self.uploadProgressLayer.removeFromSuperlayer() + } + if self.indefiniteReplicatorLayer.superlayer == nil { + self.addSublayer(self.indefiniteReplicatorLayer) + } + if self.indefiniteReplicatorLayer.animation(forKey: "rotation") == nil { + let rotationAnimation = CABasicAnimation(keyPath: "transform.rotation.z") + rotationAnimation.duration = 4.0 + rotationAnimation.fromValue = NSNumber(value: -.pi / 2.0) + rotationAnimation.toValue = NSNumber(value: -.pi / 2.0 + Double.pi * 2.0) + rotationAnimation.repeatCount = Float.infinity + rotationAnimation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.linear) + self.indefiniteReplicatorLayer.add(rotationAnimation, forKey: "rotation") + } + if self.indefiniteDashLayer.animation(forKey: "dash") == nil { + let dashAnimation = CAKeyframeAnimation(keyPath: "strokeStart") + dashAnimation.keyTimes = [0.0, 0.45, 0.55, 1.0] + dashAnimation.values = [ + self.indefiniteDashLayer.strokeStart, + self.indefiniteDashLayer.strokeEnd, + self.indefiniteDashLayer.strokeEnd, + self.indefiniteDashLayer.strokeStart, + ] + dashAnimation.timingFunction = CAMediaTimingFunction(name: .linear) + dashAnimation.duration = 2.5 + dashAnimation.repeatCount = .infinity + self.indefiniteDashLayer.add(dashAnimation, forKey: "dash") + } + } + } + + func update(size: CGSize, radius: CGFloat, lineWidth: CGFloat, value: Value, transition: Transition) { + let params = Params( + size: size, + lineWidth: lineWidth, + value: value + ) + if self.currentParams == params { + return + } + self.currentParams = params + + self.indefiniteDashLayer.lineWidth = lineWidth + self.uploadProgressLayer.lineWidth = lineWidth + + let bounds = CGRect(origin: .zero, size: size) + if self.uploadProgressLayer.path == nil { + let path = CGMutablePath() + path.addEllipse(in: CGRect(origin: CGPoint(x: (size.width - radius * 2.0) * 0.5, y: (size.height - radius * 2.0) * 0.5), size: CGSize(width: radius * 2.0, height: radius * 2.0))) + self.uploadProgressLayer.path = path + self.uploadProgressLayer.frame = bounds + } + + if self.indefiniteDashLayer.path == nil { + let path = CGMutablePath() + path.addEllipse(in: CGRect(origin: CGPoint(x: (size.width - radius * 2.0) * 0.5, y: (size.height - radius * 2.0) * 0.5), size: CGSize(width: radius * 2.0, height: radius * 2.0))) + self.indefiniteDashLayer.path = path + self.indefiniteReplicatorLayer.frame = bounds + self.indefiniteDashLayer.frame = bounds + } + + self.updateAnimations(transition: transition) + } + } + public final class View: UIView { private let indicatorView: UIImageView + private var progressLayer: ProgressLayer? + private var colorLayer: SimpleGradientLayer? private var component: AvatarStoryIndicatorComponent? private weak var state: EmptyComponentState? @@ -110,25 +270,26 @@ public final class AvatarStoryIndicatorComponent: Component { diameter = availableSize.width + maxOuterInset * 2.0 let imageDiameter = availableSize.width + ceilToScreenPixels(maxOuterInset) * 2.0 + let activeColors: [CGColor] + let inactiveColors: [CGColor] + + if component.hasUnseenCloseFriendsItems { + activeColors = component.colors.unseenCloseFriendsColors.map(\.cgColor) + } else { + activeColors = component.colors.unseenColors.map(\.cgColor) + } + + inactiveColors = component.colors.seenColors.map(\.cgColor) + + let radius = (diameter - component.activeLineWidth) * 0.5 + self.indicatorView.image = generateImage(CGSize(width: imageDiameter, height: imageDiameter), rotatedContext: { size, context in context.clear(CGRect(origin: CGPoint(), size: size)) - let activeColors: [CGColor] - let inactiveColors: [CGColor] - - if component.hasUnseenCloseFriendsItems { - activeColors = component.colors.unseenCloseFriendsColors.map(\.cgColor) - } else { - activeColors = component.colors.unseenColors.map(\.cgColor) - } - - inactiveColors = component.colors.seenColors.map(\.cgColor) - var locations: [CGFloat] = [0.0, 1.0] if let counters = component.counters, counters.totalCount > 1 { let center = CGPoint(x: size.width * 0.5, y: size.height * 0.5) - let radius = (diameter - component.activeLineWidth) * 0.5 let spacing: CGFloat = 2.0 let angularSpacing: CGFloat = spacing / radius let circleLength = CGFloat.pi * 2.0 * radius @@ -197,7 +358,61 @@ public final class AvatarStoryIndicatorComponent: Component { context.drawLinearGradient(gradient, start: CGPoint(x: 0.0, y: 0.0), end: CGPoint(x: 0.0, y: size.height), options: CGGradientDrawingOptions()) } }) - transition.setFrame(view: self.indicatorView, frame: CGRect(origin: CGPoint(x: (availableSize.width - imageDiameter) * 0.5, y: (availableSize.height - imageDiameter) * 0.5), size: CGSize(width: imageDiameter, height: imageDiameter))) + let indicatorFrame = CGRect(origin: CGPoint(x: (availableSize.width - imageDiameter) * 0.5, y: (availableSize.height - imageDiameter) * 0.5), size: CGSize(width: imageDiameter, height: imageDiameter)) + transition.setFrame(view: self.indicatorView, frame: indicatorFrame) + + let progressTransition = Transition(animation: .curve(duration: 0.3, curve: .easeInOut)) + if component.displayProgress { + let colorLayer: SimpleGradientLayer + if let current = self.colorLayer { + colorLayer = current + } else { + colorLayer = SimpleGradientLayer() + self.colorLayer = colorLayer + self.layer.addSublayer(colorLayer) + colorLayer.opacity = 0.0 + } + + progressTransition.setAlpha(view: self.indicatorView, alpha: 0.0) + progressTransition.setAlpha(layer: colorLayer, alpha: 1.0) + + let colors: [CGColor] = activeColors + /*if component.hasUnseen { + colors = activeColors + } else { + colors = inactiveColors + }*/ + + let lineWidth: CGFloat = component.hasUnseen ? component.activeLineWidth : component.inactiveLineWidth + + colorLayer.colors = colors + colorLayer.startPoint = CGPoint(x: 0.0, y: 0.0) + colorLayer.endPoint = CGPoint(x: 0.0, y: 1.0) + + let progressLayer: ProgressLayer + if let current = self.progressLayer { + progressLayer = current + } else { + progressLayer = ProgressLayer() + self.progressLayer = progressLayer + colorLayer.mask = progressLayer + } + + colorLayer.frame = indicatorFrame + progressLayer.frame = CGRect(origin: CGPoint(), size: indicatorFrame.size) + progressLayer.update(size: indicatorFrame.size, radius: radius, lineWidth: lineWidth, value: .indefinite, transition: .immediate) + } else { + progressTransition.setAlpha(view: self.indicatorView, alpha: 1.0) + + self.progressLayer = nil + if let colorLayer = self.colorLayer { + self.colorLayer = nil + + progressTransition.setAlpha(layer: colorLayer, alpha: 0.0, completion: { [weak colorLayer] _ in + colorLayer?.removeFromSuperlayer() + }) + } + } return availableSize } diff --git a/submodules/TelegramUI/Components/Stories/PeerListItemComponent/Sources/PeerListItemComponent.swift b/submodules/TelegramUI/Components/Stories/PeerListItemComponent/Sources/PeerListItemComponent.swift index 3dfa3e7ebc..edfc9f1b98 100644 --- a/submodules/TelegramUI/Components/Stories/PeerListItemComponent/Sources/PeerListItemComponent.swift +++ b/submodules/TelegramUI/Components/Stories/PeerListItemComponent/Sources/PeerListItemComponent.swift @@ -57,7 +57,7 @@ public final class PeerListItemComponent: Component { let selectionState: SelectionState let hasNext: Bool let action: (EnginePeer) -> Void - let openStories: ((EnginePeer, UIView) -> Void)? + let openStories: ((EnginePeer, AvatarNode) -> Void)? public init( context: AccountContext, @@ -74,7 +74,7 @@ public final class PeerListItemComponent: Component { selectionState: SelectionState, hasNext: Bool, action: @escaping (EnginePeer) -> Void, - openStories: ((EnginePeer, UIView) -> Void)? = nil + openStories: ((EnginePeer, AvatarNode) -> Void)? = nil ) { self.context = context self.theme = theme @@ -211,7 +211,7 @@ public final class PeerListItemComponent: Component { guard let component = self.component, let peer = component.peer else { return } - component.openStories?(peer, self.avatarNode.view) + component.openStories?(peer, self.avatarNode) } func update(component: PeerListItemComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/BUILD b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/BUILD index f812ce4f84..0c2dc470d8 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/BUILD +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/BUILD @@ -76,6 +76,7 @@ swift_library( "//submodules/OpenInExternalAppUI", "//submodules/MediaPasteboardUI", "//submodules/WebPBinding", + "//submodules/Utils/RangeSet", ], visibility = [ "//visibility:public", diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/OpenStories.swift b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/OpenStories.swift new file mode 100644 index 0000000000..4d8ba99e91 --- /dev/null +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/OpenStories.swift @@ -0,0 +1,205 @@ +import Foundation +import UIKit +import Display +import AccountContext +import SwiftSignalKit +import TelegramCore +import Postbox +import AvatarNode + +public extension StoryContainerScreen { + static func openArchivedStories(context: AccountContext, parentController: ViewController, avatarNode: AvatarNode) { + let storyContent = StoryContentContextImpl(context: context, isHidden: true, focusedPeerId: nil, singlePeer: false) + let signal = storyContent.state + |> take(1) + |> mapToSignal { state -> Signal in + if let slice = state.slice { + return waitUntilStoryMediaPreloaded(context: context, peerId: slice.peer.id, storyItem: slice.item.storyItem) + |> timeout(2.0, queue: .mainQueue(), alternate: .complete()) + |> map { _ -> Void in + } + |> then(.single(Void())) + } else { + return .single(Void()) + } + } + |> deliverOnMainQueue + |> map { [weak parentController, weak avatarNode] _ -> Void in + var transitionIn: StoryContainerScreen.TransitionIn? + if let avatarNode { + transitionIn = StoryContainerScreen.TransitionIn( + sourceView: avatarNode.view, + sourceRect: avatarNode.view.bounds, + sourceCornerRadius: avatarNode.view.bounds.width * 0.5, + sourceIsAvatar: false + ) + avatarNode.isHidden = true + } + + let storyContainerScreen = StoryContainerScreen( + context: context, + content: storyContent, + transitionIn: transitionIn, + transitionOut: { peerId, _ in + if let avatarNode { + let destinationView = avatarNode.view + return StoryContainerScreen.TransitionOut( + destinationView: destinationView, + transitionView: StoryContainerScreen.TransitionView( + makeView: { [weak destinationView] in + let parentView = UIView() + if let copyView = destinationView?.snapshotContentTree(unhide: true) { + parentView.addSubview(copyView) + } + return parentView + }, + updateView: { copyView, state, transition in + guard let view = copyView.subviews.first else { + return + } + let size = state.sourceSize.interpolate(to: state.destinationSize, amount: state.progress) + transition.setPosition(view: view, position: CGPoint(x: size.width * 0.5, y: size.height * 0.5)) + transition.setScale(view: view, scale: size.width / state.destinationSize.width) + }, + insertCloneTransitionView: nil + ), + destinationRect: destinationView.bounds, + destinationCornerRadius: destinationView.bounds.width * 0.5, + destinationIsAvatar: false, + completed: { [weak avatarNode] in + guard let avatarNode else { + return + } + avatarNode.isHidden = false + } + ) + } else { + return nil + } + } + ) + parentController?.push(storyContainerScreen) + } + |> ignoreValues + + let _ = avatarNode.pushLoadingStatus(signal: signal) + } + + static func openPeerStories(context: AccountContext, peerId: EnginePeer.Id, parentController: ViewController, avatarNode: AvatarNode) { + return openPeerStoriesCustom( + context: context, + peerId: peerId, + isHidden: false, + singlePeer: true, + parentController: parentController, + transitionIn: { [weak avatarNode] in + if let avatarNode { + let transitionIn = StoryContainerScreen.TransitionIn( + sourceView: avatarNode.view, + sourceRect: avatarNode.view.bounds, + sourceCornerRadius: avatarNode.view.bounds.width * 0.5, + sourceIsAvatar: false + ) + avatarNode.isHidden = true + return transitionIn + } else { + return nil + } + }, + transitionOut: { [weak avatarNode] _ in + if let avatarNode { + let destinationView = avatarNode.view + return StoryContainerScreen.TransitionOut( + destinationView: destinationView, + transitionView: StoryContainerScreen.TransitionView( + makeView: { [weak destinationView] in + let parentView = UIView() + if let copyView = destinationView?.snapshotContentTree(unhide: true) { + parentView.addSubview(copyView) + } + return parentView + }, + updateView: { copyView, state, transition in + guard let view = copyView.subviews.first else { + return + } + let size = state.sourceSize.interpolate(to: state.destinationSize, amount: state.progress) + transition.setPosition(view: view, position: CGPoint(x: size.width * 0.5, y: size.height * 0.5)) + transition.setScale(view: view, scale: size.width / state.destinationSize.width) + }, + insertCloneTransitionView: nil + ), + destinationRect: destinationView.bounds, + destinationCornerRadius: destinationView.bounds.width * 0.5, + destinationIsAvatar: false, + completed: { [weak avatarNode] in + guard let avatarNode else { + return + } + avatarNode.isHidden = false + } + ) + } else { + return nil + } + }, + setFocusedItem: { _ in + }, + setProgress: { [weak avatarNode] signal in + guard let avatarNode else { + return + } + let _ = avatarNode.pushLoadingStatus(signal: signal) + } + ) + } + + static func openPeerStoriesCustom( + context: AccountContext, + peerId: EnginePeer.Id, + isHidden: Bool, + singlePeer: Bool, + parentController: ViewController, + transitionIn: @escaping () -> StoryContainerScreen.TransitionIn?, + transitionOut: @escaping (EnginePeer.Id) -> StoryContainerScreen.TransitionOut?, + setFocusedItem: @escaping (Signal) -> Void, + setProgress: @escaping (Signal) -> Void + ) { + let storyContent = StoryContentContextImpl(context: context, isHidden: isHidden, focusedPeerId: peerId, singlePeer: singlePeer) + let signal = storyContent.state + |> take(1) + |> mapToSignal { state -> Signal in + if let slice = state.slice { + return waitUntilStoryMediaPreloaded(context: context, peerId: slice.peer.id, storyItem: slice.item.storyItem) + |> timeout(2.0, queue: .mainQueue(), alternate: .complete()) + |> map { _ -> StoryContentContextState in + } + |> then(.single(state)) + } else { + return .single(state) + } + } + |> deliverOnMainQueue + |> map { [weak parentController] state -> Void in + if state.slice == nil { + return + } + + let transitionIn: StoryContainerScreen.TransitionIn? = transitionIn() + + let storyContainerScreen = StoryContainerScreen( + context: context, + content: storyContent, + transitionIn: transitionIn, + transitionOut: { peerId, _ in + return transitionOut(peerId) + } + ) + setFocusedItem(storyContainerScreen.focusedItem) + parentController?.push(storyContainerScreen) + } + |> ignoreValues + + setProgress(signal) + } +} diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryChatContent.swift b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryChatContent.swift index fe271baf37..c9bf9473d4 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryChatContent.swift +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryChatContent.swift @@ -7,6 +7,7 @@ import AccountContext import TelegramCore import Postbox import MediaResources +import RangeSet private struct StoryKey: Hashable { var peerId: EnginePeer.Id @@ -746,6 +747,8 @@ public final class StoryContentContextImpl: StoryContentContext { }) } } + } else { + self.updateState() } } @@ -1405,6 +1408,88 @@ public func preloadStoryMedia(context: AccountContext, peer: PeerReference, stor return combineLatest(signals) |> ignoreValues } +public func waitUntilStoryMediaPreloaded(context: AccountContext, peerId: EnginePeer.Id, storyItem: EngineStoryItem) -> Signal { + return context.engine.data.get( + TelegramEngine.EngineData.Item.Peer.Peer(id: peerId) + ) + |> mapToSignal { peerValue -> Signal in + guard let peerValue else { + return .complete() + } + guard let peer = PeerReference(peerValue._asPeer()) else { + return .complete() + } + + var statusSignals: [Signal] = [] + var loadSignals: [Signal] = [] + + switch storyItem.media { + case let .image(image): + if let representation = largestImageRepresentation(image.representations) { + statusSignals.append( + context.account.postbox.mediaBox.resourceData(representation.resource) + |> filter { data in + return data.complete + } + |> take(1) + |> ignoreValues + ) + + loadSignals.append(fetchedMediaResource(mediaBox: context.account.postbox.mediaBox, userLocation: .peer(peer.id), userContentType: .other, reference: .media(media: .story(peer: peer, id: storyItem.id, media: storyItem.media._asMedia()), resource: representation.resource), range: nil) + |> ignoreValues + |> `catch` { _ -> Signal in + return .complete() + }) + } + case let .file(file): + var fetchRange: (Range, MediaBoxFetchPriority)? + for attribute in file.attributes { + if case let .Video(_, _, _, preloadSize) = attribute { + if let preloadSize { + fetchRange = (0 ..< Int64(preloadSize), .default) + } + break + } + } + + statusSignals.append( + context.account.postbox.mediaBox.resourceRangesStatus(file.resource) + |> filter { ranges in + if let fetchRange { + return ranges.isSuperset(of: RangeSet(fetchRange.0)) + } else { + return true + } + } + |> take(1) + |> ignoreValues + ) + + loadSignals.append(fetchedMediaResource(mediaBox: context.account.postbox.mediaBox, userLocation: .peer(peer.id), userContentType: .other, reference: .media(media: .story(peer: peer, id: storyItem.id, media: storyItem.media._asMedia()), resource: file.resource), range: fetchRange) + |> ignoreValues + |> `catch` { _ -> Signal in + return .complete() + }) + loadSignals.append(context.account.postbox.mediaBox.cachedResourceRepresentation(file.resource, representation: CachedVideoFirstFrameRepresentation(), complete: true, fetch: true, attemptSynchronously: false) + |> ignoreValues) + default: + break + } + + return Signal { subscriber in + let statusDisposable = combineLatest(statusSignals).start(completed: { + subscriber.putCompletion() + }) + let loadDisposable = combineLatest(loadSignals).start() + + return ActionDisposable { + statusDisposable.dispose() + loadDisposable.dispose() + } + } + } +} + func extractItemEntityFiles(item: EngineStoryItem, allEntityFiles: [MediaId: TelegramMediaFile]) -> [MediaId: TelegramMediaFile] { var result: [MediaId: TelegramMediaFile] = [:] for entity in item.entities { diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContainerScreen.swift b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContainerScreen.swift index 7d14441bd1..c1e1494a02 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContainerScreen.swift +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContainerScreen.swift @@ -1757,4 +1757,3 @@ func allowedStoryReactions(context: AccountContext) -> Signal<[ReactionItem], No return result } } - diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift index c4d5620efa..f84a001833 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift @@ -2068,11 +2068,11 @@ public final class StoryItemSetContainerComponent: Component { } self.navigateToPeer(peer: peer, chat: false) }, - openPeerStories: { [weak self] peer, sourceView in + openPeerStories: { [weak self] peer, avatarNode in guard let self else { return } - self.openPeerStories(peer: peer, sourceView: sourceView) + self.openPeerStories(peer: peer, avatarNode: avatarNode) } )), environment: {}, @@ -3248,75 +3248,15 @@ public final class StoryItemSetContainerComponent: Component { } } - func openPeerStories(peer: EnginePeer, sourceView: UIView) { + func openPeerStories(peer: EnginePeer, avatarNode: AvatarNode) { guard let component = self.component else { return } + guard let controller = component.controller() else { + return + } - let storyContent = StoryContentContextImpl(context: component.context, isHidden: false, focusedPeerId: peer.id, singlePeer: true) - let _ = (storyContent.state - |> filter { $0.slice != nil } - |> take(1) - |> deliverOnMainQueue).start(next: { [weak self, weak sourceView] _ in - guard let self, let component = self.component else { - return - } - - var transitionIn: StoryContainerScreen.TransitionIn? - if let sourceView { - transitionIn = StoryContainerScreen.TransitionIn( - sourceView: sourceView, - sourceRect: sourceView.bounds, - sourceCornerRadius: sourceView.bounds.width * 0.5, - sourceIsAvatar: false - ) - sourceView.isHidden = true - } - - let storyContainerScreen = StoryContainerScreen( - context: component.context, - content: storyContent, - transitionIn: transitionIn, - transitionOut: { peerId, _ in - if let sourceView { - let destinationView = sourceView - return StoryContainerScreen.TransitionOut( - destinationView: destinationView, - transitionView: StoryContainerScreen.TransitionView( - makeView: { [weak destinationView] in - let parentView = UIView() - if let copyView = destinationView?.snapshotContentTree(unhide: true) { - parentView.addSubview(copyView) - } - return parentView - }, - updateView: { copyView, state, transition in - guard let view = copyView.subviews.first else { - return - } - let size = state.sourceSize.interpolate(to: state.destinationSize, amount: state.progress) - transition.setPosition(view: view, position: CGPoint(x: size.width * 0.5, y: size.height * 0.5)) - transition.setScale(view: view, scale: size.width / state.destinationSize.width) - }, - insertCloneTransitionView: nil - ), - destinationRect: destinationView.bounds, - destinationCornerRadius: destinationView.bounds.width * 0.5, - destinationIsAvatar: false, - completed: { [weak sourceView] in - guard let sourceView else { - return - } - sourceView.isHidden = false - } - ) - } else { - return nil - } - } - ) - component.controller()?.push(storyContainerScreen) - }) + StoryContainerScreen.openPeerStories(context: component.context, peerId: peer.id, parentController: controller, avatarNode: avatarNode) } private func openStoryEditing() { diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetViewListComponent.swift b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetViewListComponent.swift index 9ad618697a..cdd27c0cdd 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetViewListComponent.swift +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetViewListComponent.swift @@ -14,6 +14,7 @@ import ShimmerEffect import StoryFooterPanelComponent import PeerListItemComponent import AnimatedStickerComponent +import AvatarNode final class StoryItemSetViewListComponent: Component { final class AnimationHint { @@ -56,7 +57,7 @@ final class StoryItemSetViewListComponent: Component { let deleteAction: () -> Void let moreAction: (UIView, ContextGesture?) -> Void let openPeer: (EnginePeer) -> Void - let openPeerStories: (EnginePeer, UIView) -> Void + let openPeerStories: (EnginePeer, AvatarNode) -> Void init( externalState: ExternalState, @@ -75,7 +76,7 @@ final class StoryItemSetViewListComponent: Component { deleteAction: @escaping () -> Void, moreAction: @escaping (UIView, ContextGesture?) -> Void, openPeer: @escaping (EnginePeer) -> Void, - openPeerStories: @escaping (EnginePeer, UIView) -> Void + openPeerStories: @escaping (EnginePeer, AvatarNode) -> Void ) { self.externalState = externalState self.context = context @@ -499,11 +500,11 @@ final class StoryItemSetViewListComponent: Component { } component.openPeer(peer) }, - openStories: { [weak self] peer, sourceView in + openStories: { [weak self] peer, avatarNode in guard let self, let component = self.component else { return } - component.openPeerStories(peer, sourceView) + component.openPeerStories(peer, avatarNode) } )), environment: {}, diff --git a/submodules/TelegramUI/Components/Stories/StoryPeerListComponent/Sources/StoryPeerListComponent.swift b/submodules/TelegramUI/Components/Stories/StoryPeerListComponent/Sources/StoryPeerListComponent.swift index 8a93057711..4756228cbb 100644 --- a/submodules/TelegramUI/Components/Stories/StoryPeerListComponent/Sources/StoryPeerListComponent.swift +++ b/submodules/TelegramUI/Components/Stories/StoryPeerListComponent/Sources/StoryPeerListComponent.swift @@ -338,6 +338,9 @@ public final class StoryPeerListComponent: Component { private var previewedItemDisposable: Disposable? private var previewedItemId: EnginePeer.Id? + private var loadingItemDisposable: Disposable? + private var loadingItemId: EnginePeer.Id? + private var animationState: AnimationState? private var animator: ConstantDisplayLinkAnimator? @@ -403,6 +406,7 @@ public final class StoryPeerListComponent: Component { deinit { self.loadMoreDisposable.dispose() self.previewedItemDisposable?.dispose() + self.loadingItemDisposable?.dispose() } @objc private func collapsedButtonPressed() { @@ -444,6 +448,22 @@ public final class StoryPeerListComponent: Component { }) } + public func setLoadingItem(peerId: EnginePeer.Id, signal: Signal) { + self.loadingItemId = peerId + DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.2, execute: { [weak self] in + self?.state?.updated(transition: .immediate) + }) + + self.loadingItemDisposable?.dispose() + self.loadingItemDisposable = (signal |> deliverOnMainQueue).start(completed: { [weak self] in + guard let self else { + return + } + self.loadingItemId = nil + self.state?.updated(transition: .immediate) + }) + } + public func anchorForTooltip() -> (UIView, CGRect)? { return (self.collapsedButton, self.collapsedButton.bounds) } @@ -869,8 +889,9 @@ public final class StoryPeerListComponent: Component { } hasUnseenCloseFriendsItems = false + } else if peer.id == self.loadingItemId { + itemRingAnimation = .loading } - //itemRingAnimation = .loading let measuredItem = calculateItem(i) diff --git a/submodules/TelegramUI/Sources/ChatController.swift b/submodules/TelegramUI/Sources/ChatController.swift index 7fa1ce8fb2..eae7a1cc40 100644 --- a/submodules/TelegramUI/Sources/ChatController.swift +++ b/submodules/TelegramUI/Sources/ChatController.swift @@ -17033,110 +17033,9 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } private func openStories(peerId: EnginePeer.Id, avatarHeaderNode: ChatMessageAvatarHeaderNode?, avatarNode: AvatarNode?) { - let storyContent = StoryContentContextImpl(context: self.context, isHidden: false, focusedPeerId: peerId, singlePeer: true) - let _ = (storyContent.state - |> filter { $0.slice != nil } - |> take(1) - |> deliverOnMainQueue).start(next: { [weak self, weak avatarHeaderNode, weak avatarNode] _ in - guard let self else { - return - } - - var transitionIn: StoryContainerScreen.TransitionIn? - if let avatarHeaderNode { - transitionIn = StoryContainerScreen.TransitionIn( - sourceView: avatarHeaderNode.avatarNode.view, - sourceRect: avatarHeaderNode.avatarNode.view.bounds, - sourceCornerRadius: avatarHeaderNode.avatarNode.view.bounds.width * 0.5, - sourceIsAvatar: false - ) - avatarHeaderNode.avatarNode.isHidden = true - } else if let avatarNode { - transitionIn = StoryContainerScreen.TransitionIn( - sourceView: avatarNode.view, - sourceRect: avatarNode.view.bounds, - sourceCornerRadius: avatarNode.view.bounds.width * 0.5, - sourceIsAvatar: false - ) - avatarNode.isHidden = true - } - - let storyContainerScreen = StoryContainerScreen( - context: self.context, - content: storyContent, - transitionIn: transitionIn, - transitionOut: { peerId, _ in - if let avatarHeaderNode { - let destinationView = avatarHeaderNode.avatarNode.view - return StoryContainerScreen.TransitionOut( - destinationView: destinationView, - transitionView: StoryContainerScreen.TransitionView( - makeView: { [weak destinationView] in - let parentView = UIView() - if let copyView = destinationView?.snapshotContentTree(unhide: true) { - parentView.addSubview(copyView) - } - return parentView - }, - updateView: { copyView, state, transition in - guard let view = copyView.subviews.first else { - return - } - let size = state.sourceSize.interpolate(to: state.destinationSize, amount: state.progress) - transition.setPosition(view: view, position: CGPoint(x: size.width * 0.5, y: size.height * 0.5)) - transition.setScale(view: view, scale: size.width / state.destinationSize.width) - }, - insertCloneTransitionView: nil - ), - destinationRect: destinationView.bounds, - destinationCornerRadius: destinationView.bounds.width * 0.5, - destinationIsAvatar: false, - completed: { [weak avatarHeaderNode] in - guard let avatarHeaderNode else { - return - } - avatarHeaderNode.avatarNode.isHidden = false - } - ) - } else if let avatarNode { - let destinationView = avatarNode.view - return StoryContainerScreen.TransitionOut( - destinationView: destinationView, - transitionView: StoryContainerScreen.TransitionView( - makeView: { [weak destinationView] in - let parentView = UIView() - if let copyView = destinationView?.snapshotContentTree(unhide: true) { - parentView.addSubview(copyView) - } - return parentView - }, - updateView: { copyView, state, transition in - guard let view = copyView.subviews.first else { - return - } - let size = state.sourceSize.interpolate(to: state.destinationSize, amount: state.progress) - transition.setPosition(view: view, position: CGPoint(x: size.width * 0.5, y: size.height * 0.5)) - transition.setScale(view: view, scale: size.width / state.destinationSize.width) - }, - insertCloneTransitionView: nil - ), - destinationRect: destinationView.bounds, - destinationCornerRadius: destinationView.bounds.width * 0.5, - destinationIsAvatar: false, - completed: { [weak avatarNode] in - guard let avatarNode else { - return - } - avatarNode.isHidden = false - } - ) - } else { - return nil - } - } - ) - self.push(storyContainerScreen) - }) + if let avatarNode = avatarHeaderNode?.avatarNode ?? avatarNode { + StoryContainerScreen.openPeerStories(context: self.context, peerId: peerId, parentController: self, avatarNode: avatarNode) + } } private func openPeerMention(_ name: String, navigation: ChatControllerInteractionNavigateToPeer = .default, sourceMessageId: MessageId? = nil) { diff --git a/submodules/TelegramUI/Sources/ChatHistoryListNode.swift b/submodules/TelegramUI/Sources/ChatHistoryListNode.swift index 1e5fd337af..682fa385ef 100644 --- a/submodules/TelegramUI/Sources/ChatHistoryListNode.swift +++ b/submodules/TelegramUI/Sources/ChatHistoryListNode.swift @@ -2073,6 +2073,7 @@ public final class ChatHistoryListNode: ListView, ChatHistoryNode { var downloadableResourceIds: [(messageId: MessageId, resourceId: String)] = [] var allVisibleAnchorMessageIds: [(MessageId, Int)] = [] var visibleAdOpaqueIds: [Data] = [] + var peerIdsWithRefreshStories: [PeerId] = [] if indexRange.0 <= indexRange.1 { for i in (indexRange.0 ... indexRange.1) { @@ -2080,6 +2081,10 @@ public final class ChatHistoryListNode: ListView, ChatHistoryNode { switch historyView.filteredEntries[i] { case let .MessageEntry(message, _, _, _, _, _): + if let author = message.author as? TelegramUser { + peerIdsWithRefreshStories.append(author.id) + } + var hasUnconsumedMention = false var hasUnconsumedContent = false if message.tags.contains(.unseenPersonalMessage) { @@ -2187,6 +2192,10 @@ public final class ChatHistoryListNode: ListView, ChatHistoryNode { allVisibleAnchorMessageIds.append((message.id, nodeIndex)) } case let .MessageGroupEntry(_, messages, _): + if let author = messages.first?.0.author as? TelegramUser { + peerIdsWithRefreshStories.append(author.id) + } + for (message, _, _, _, _) in messages { var hasUnconsumedMention = false var hasUnconsumedContent = false @@ -2393,6 +2402,9 @@ public final class ChatHistoryListNode: ListView, ChatHistoryNode { self.markAdAsSeen(opaqueId: opaqueId) } } + if !peerIdsWithRefreshStories.isEmpty { + self.context.account.viewTracker.refreshStoryStatsForPeerIds(peerIds: peerIdsWithRefreshStories) + } self.currentEarlierPrefetchMessages = toEarlierMediaMessages self.currentLaterPrefetchMessages = toLaterMediaMessages diff --git a/submodules/TelegramUI/Sources/PeerInfo/PeerInfoScreen.swift b/submodules/TelegramUI/Sources/PeerInfo/PeerInfoScreen.swift index d20b793bba..003c0d6dcc 100644 --- a/submodules/TelegramUI/Sources/PeerInfo/PeerInfoScreen.swift +++ b/submodules/TelegramUI/Sources/PeerInfo/PeerInfoScreen.swift @@ -4107,7 +4107,14 @@ final class PeerInfoScreenNode: ViewControllerTracingNode, PeerInfoScreenNodePro } private func openStories(fromAvatar: Bool) { + guard let controller = self.controller else { + return + } if let expiringStoryList = self.expiringStoryList, let expiringStoryListState = self.expiringStoryListState, !expiringStoryListState.items.isEmpty { + if fromAvatar { + StoryContainerScreen.openPeerStories(context: self.context, peerId: self.peerId, parentController: controller, avatarNode: self.headerNode.avatarListNode.avatarContainerNode.avatarNode) + } + let _ = expiringStoryList let storyContent = StoryContentContextImpl(context: self.context, isHidden: false, focusedPeerId: self.peerId, singlePeer: true) let _ = (storyContent.state @@ -7141,6 +7148,13 @@ final class PeerInfoScreenNode: ViewControllerTracingNode, PeerInfoScreenNodePro case .remove: data.members?.membersContext.removeMember(memberId: member.id) case let .openStories(sourceView): + guard let controller = self.controller else { + return + } + if let avatarNode = sourceView.asyncdisplaykit_node as? AvatarNode { + StoryContainerScreen.openPeerStories(context: self.context, peerId: member.id, parentController: controller, avatarNode: avatarNode) + return + } let storyContent = StoryContentContextImpl(context: self.context, isHidden: false, focusedPeerId: member.id, singlePeer: true) let _ = (storyContent.state |> filter { $0.slice != nil }