diff --git a/Telegram/Telegram-iOS/en.lproj/Localizable.strings b/Telegram/Telegram-iOS/en.lproj/Localizable.strings index 4f2b1ffa1c..818a3f3439 100644 --- a/Telegram/Telegram-iOS/en.lproj/Localizable.strings +++ b/Telegram/Telegram-iOS/en.lproj/Localizable.strings @@ -6911,7 +6911,7 @@ Sorry for the inconvenience."; "SponsoredMessageMenu.Info" = "What are sponsored\nmessages?"; "SponsoredMessageInfoScreen.Title" = "What are sponsored messages?"; -"SponsoredMessageInfoScreen.Text" = "Unlike other apps, Telegram never uses your private data to target ads. You are seeing this message only because someone chose this public one-to many channel as a space to promote their messages. This means that no user data is mined or analyzed to display ads, and every user viewing a channel on Telegram sees the same sponsored message.\n\nUnline other apps, Telegram doesn't track whether you tapped on a sponsored message and doesn't profile you based on your activity. We also prevent external links in sponsored messages to ensure that third parties can't spy on our users. We believe that everyone has the right to privacy, and technological platforms should respect that.\n\nTelegram offers free and unlimited service to hundreds of millions of users, which involves significant server and traffic costs. In order to remain independent and stay true to its values, Telegram developed a paid tool to promote messages with user privacy in mind. We welcome responsible adverticers at:\n[url]\nAds should no longer be synonymous with abuse of user privacy. Let us redefine how a tech compony should operate — together."; +"SponsoredMessageInfoScreen.MarkdownText" = "Unlike other apps, Telegram never uses your private data to target ads. [Learn more in the Privacy Policy](https://telegram.org/privacy#5-6-no-ads-based-on-user-data)\nYou are seeing this message only because someone chose this public one-to many channel as a space to promote their messages. This means that no user data is mined or analyzed to display ads, and every user viewing a channel on Telegram sees the same sponsored message.\n\nUnline other apps, Telegram doesn't track whether you tapped on a sponsored message and doesn't profile you based on your activity. We also prevent external links in sponsored messages to ensure that third parties can't spy on our users. We believe that everyone has the right to privacy, and technological platforms should respect that.\n\nTelegram offers free and unlimited service to hundreds of millions of users, which involves significant server and traffic costs. In order to remain independent and stay true to its values, Telegram developed a paid tool to promote messages with user privacy in mind. We welcome responsible adverticers at:\n[url]\nAds should no longer be synonymous with abuse of user privacy. Let us redefine how a tech compony should operate — together."; "SponsoredMessageInfo.Action" = "Learn More"; "SponsoredMessageInfo.Url" = "https://telegram.org/ads"; @@ -7090,6 +7090,7 @@ Sorry for the inconvenience."; "Time.HoursAgo_many" = "%@ hours ago"; "Time.HoursAgo_0" = "%@ hours ago"; "Time.AtDate" = "%@"; +"Time.AtPreciseDate" = "%@ at %@"; "Stickers.ShowMore" = "Show More"; diff --git a/submodules/AdUI/BUILD b/submodules/AdUI/BUILD index 3b30811950..d78e91cebc 100644 --- a/submodules/AdUI/BUILD +++ b/submodules/AdUI/BUILD @@ -17,6 +17,7 @@ swift_library( "//submodules/TelegramPresentationData:TelegramPresentationData", "//submodules/TelegramUIPreferences:TelegramUIPreferences", "//submodules/AccountContext:AccountContext", + "//submodules/Markdown", ], visibility = [ "//visibility:public", diff --git a/submodules/AdUI/Sources/AdInfoScreen.swift b/submodules/AdUI/Sources/AdInfoScreen.swift index 03a57eed51..9c835909d7 100644 --- a/submodules/AdUI/Sources/AdInfoScreen.swift +++ b/submodules/AdUI/Sources/AdInfoScreen.swift @@ -7,6 +7,7 @@ import TelegramCore import TelegramPresentationData import TelegramUIPreferences import AccountContext +import Markdown public final class AdInfoScreen: ViewController { private final class Node: ViewControllerTracingNode { @@ -84,9 +85,16 @@ public final class AdInfoScreen: ViewController { self.scrollNode.view.contentInsetAdjustmentBehavior = .never } - var openUrl: (() -> Void)? + var openUrl: ((String) -> Void)? - let rawText = self.presentationData.strings.SponsoredMessageInfoScreen_Text + #if DEBUG && false + let rawText = "First Line\n**Bold Text** [Description](http://google.com) text\n[url]\nabcdee" + #else + let rawText = self.presentationData.strings.SponsoredMessageInfoScreen_MarkdownText + #endif + + let defaultUrl = self.presentationData.strings.SponsoredMessageInfo_Url + var items: [Item] = [] var didAddUrl = false for component in rawText.components(separatedBy: "[url]") { @@ -100,20 +108,40 @@ public final class AdInfoScreen: ViewController { let textNode = ImmediateTextNode() textNode.maximumNumberOfLines = 0 - textNode.attributedText = NSAttributedString(string: itemText, font: Font.regular(16.0), textColor: self.presentationData.theme.list.itemPrimaryTextColor) + textNode.attributedText = parseMarkdownIntoAttributedString(itemText, attributes: MarkdownAttributes( + body: MarkdownAttributeSet(font: Font.regular(16.0), textColor: self.presentationData.theme.list.itemPrimaryTextColor), + bold: MarkdownAttributeSet(font: Font.semibold(16.0), textColor: self.presentationData.theme.list.itemPrimaryTextColor), + link: MarkdownAttributeSet(font: Font.regular(16.0), textColor: self.presentationData.theme.list.itemAccentColor), + linkAttribute: { url in + return ("URL", url) + } + )) items.append(.text(textNode)) + textNode.highlightAttributeAction = { attributes in + if let _ = attributes[NSAttributedString.Key(rawValue: "URL")] { + return NSAttributedString.Key(rawValue: "URL") + } else { + return nil + } + } + textNode.tapAttributeAction = { attributes, _ in + if let value = attributes[NSAttributedString.Key(rawValue: "URL")] as? String { + openUrl?(value) + } + } + textNode.linkHighlightColor = self.presentationData.theme.list.itemAccentColor.withAlphaComponent(0.5) if !didAddUrl { didAddUrl = true items.append(.link(LinkNode(text: self.presentationData.strings.SponsoredMessageInfo_Url, color: self.presentationData.theme.list.itemAccentColor, action: { - openUrl?() + openUrl?(defaultUrl) }))) } } if !didAddUrl { didAddUrl = true items.append(.link(LinkNode(text: self.presentationData.strings.SponsoredMessageInfo_Url, color: self.presentationData.theme.list.itemAccentColor, action: { - openUrl?() + openUrl?(defaultUrl) }))) } self.items = items @@ -133,11 +161,11 @@ public final class AdInfoScreen: ViewController { } } - openUrl = { [weak self] in + openUrl = { [weak self] url in guard let strongSelf = self else { return } - strongSelf.context.sharedContext.applicationBindings.openUrl(strongSelf.presentationData.strings.SponsoredMessageInfo_Url) + strongSelf.context.sharedContext.applicationBindings.openUrl(url) } } 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 35a83cc050..305fc60182 100644 --- a/submodules/ChatListUI/Sources/ChatListController.swift +++ b/submodules/ChatListUI/Sources/ChatListController.swift @@ -118,6 +118,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController private var didAppear = false private var dismissSearchOnDisappear = false + public var onDidAppear: (() -> Void)? private var passcodeLockTooltipDisposable = MetaDisposable() private var didShowPasscodeLockTooltipController = false @@ -187,6 +188,14 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController private(set) var orderedStorySubscriptions: EngineStorySubscriptions? private var displayedStoriesTooltip: Bool = false + public var hasStorySubscriptions: Bool { + if let rawStorySubscriptions = self.rawStorySubscriptions, !rawStorySubscriptions.items.isEmpty { + return true + } else { + return false + } + } + private var storyProgressDisposable: Disposable? private var storySubscriptionsDisposable: Disposable? private var preloadStorySubscriptionsDisposable: Disposable? @@ -1059,7 +1068,21 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController if let navigationController = strongSelf.navigationController as? NavigationController { let chatListController = ChatListControllerImpl(context: strongSelf.context, location: .chatList(groupId: groupId), controlsHistoryPreload: false, enableDebugActions: false) chatListController.navigationPresentation = .master + #if DEBUG && false + navigationController.pushViewController(chatListController, animated: false, completion: {}) + chatListController.onDidAppear = { [weak chatListController] in + Queue.mainQueue().after(0.1, { + guard let chatListController else { + return + } + if chatListController.hasStorySubscriptions { + chatListController.scrollToStoriesAnimated() + } + }) + } + #else navigationController.pushViewController(chatListController) + #endif strongSelf.chatListDisplayNode.mainContainerNode.currentItemNode.clearHighlightAnimated(true) } } @@ -1314,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 @@ -1813,12 +1765,18 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController |> filter { $0 } |> take(1)) } else { - self.storiesReady.set(.single(true)) + let signals: [Signal] = [ + self.primaryInfoReady.get(), + self.storiesReady.get() + ] - self.ready.set(combineLatest([ - self.chatListDisplayNode.mainContainerNode.ready, - self.primaryInfoReady.get() - ]) + if case .chatList(.archive) = self.location { + //signals.append(self.mainReady.get()) + } else { + self.storiesReady.set(.single(true)) + } + + self.ready.set(combineLatest(signals) |> map { values -> Bool in return !values.contains(where: { !$0 }) } @@ -1919,7 +1877,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController self.requestLayout(transition: transition) self.chatListDisplayNode.temporaryContentOffsetChangeTransition = nil - if rawStorySubscriptions.items.isEmpty { + if !shouldDisplayStoriesInChatListHeader(storySubscriptions: rawStorySubscriptions, isHidden: self.location == .chatList(groupId: .archive)) { self.chatListDisplayNode.scrollToTopIfStoriesAreExpanded() } @@ -2022,7 +1980,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController return } - if let orderedStorySubscriptions = self.orderedStorySubscriptions, !orderedStorySubscriptions.items.isEmpty { + if case .chatList(groupId: .root) = self.location, let orderedStorySubscriptions = self.orderedStorySubscriptions, !orderedStorySubscriptions.items.isEmpty { let _ = (ApplicationSpecificNotice.displayChatListStoriesTooltip(accountManager: self.context.sharedContext.accountManager) |> deliverOnMainQueue).start(next: { [weak self] didDisplay in guard let self else { @@ -2162,6 +2120,20 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } guard case .chatList(.root) = self.location else { + if !self.didSuggestLocalization { + self.didSuggestLocalization = true + + let _ = (self.chatListDisplayNode.mainContainerNode.ready + |> filter { $0 } + |> take(1) + |> timeout(0.5, queue: .mainQueue(), alternate: .single(true))).start(next: { [weak self] _ in + guard let self else { + return + } + self.onDidAppear?() + }) + } + return } @@ -2362,6 +2334,8 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } } }) + + self.onDidAppear?() } self.chatListDisplayNode.mainContainerNode.addedVisibleChatsWithPeerIds = { [weak self] peerIds in @@ -2941,6 +2915,10 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController self.chatListDisplayNode.scrollToStories(animated: false) } + public func scrollToStoriesAnimated() { + self.chatListDisplayNode.scrollToStories(animated: true) + } + private func updateLayout(layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { var tabContainerOffset: CGFloat = 0.0 if !self.displayNavigationBar { @@ -3584,6 +3562,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/ChatListControllerNode.swift b/submodules/ChatListUI/Sources/ChatListControllerNode.swift index eae7f41900..4f4cb6ec88 100644 --- a/submodules/ChatListUI/Sources/ChatListControllerNode.swift +++ b/submodules/ChatListUI/Sources/ChatListControllerNode.swift @@ -434,7 +434,11 @@ private final class ChatListContainerItemNode: ASDisplayNode { if case .forum = location { subject = .forum(hasGeneral: hasOnlyGeneralThread) } else { - subject = .chats(hasArchive: hasOnlyArchive) + if case .chatList(groupId: .archive) = location { + subject = .archive + } else { + subject = .chats(hasArchive: hasOnlyArchive) + } } } diff --git a/submodules/ChatListUI/Sources/ChatListEmptyNode.swift b/submodules/ChatListUI/Sources/ChatListEmptyNode.swift index f4b8cbd48f..200b8d985d 100644 --- a/submodules/ChatListUI/Sources/ChatListEmptyNode.swift +++ b/submodules/ChatListUI/Sources/ChatListEmptyNode.swift @@ -13,6 +13,7 @@ import AccountContext final class ChatListEmptyNode: ASDisplayNode { enum Subject { case chats(hasArchive: Bool) + case archive case filter(showEdit: Bool) case forum(hasGeneral: Bool) } @@ -132,11 +133,14 @@ final class ChatListEmptyNode: ASDisplayNode { func updateThemeAndStrings(theme: PresentationTheme, strings: PresentationStrings) { let text: String var descriptionText = "" - let buttonText: String + let buttonText: String? switch self.subject { case let .chats(hasArchive): text = hasArchive ? strings.ChatList_EmptyChatListWithArchive : strings.ChatList_EmptyChatList buttonText = strings.ChatList_EmptyChatListNewMessage + case .archive: + text = strings.ChatList_EmptyChatList + buttonText = nil case .filter: text = strings.ChatList_EmptyChatListFilterTitle descriptionText = strings.ChatList_EmptyChatListFilterText @@ -152,7 +156,12 @@ final class ChatListEmptyNode: ASDisplayNode { self.textNode.attributedText = string self.descriptionNode.attributedText = descriptionString - self.buttonNode.title = buttonText + if let buttonText { + self.buttonNode.title = buttonText + self.buttonNode.isHidden = false + } else { + self.buttonNode.isHidden = true + } self.activityIndicator.type = .custom(theme.list.itemAccentColor, 22.0, 1.0, false) 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..9af17c1444 100644 --- a/submodules/ChatListUI/Sources/Node/ChatListNode.swift +++ b/submodules/ChatListUI/Sources/Node/ChatListNode.swift @@ -1246,6 +1246,8 @@ public final class ChatListNode: ListView { self.animationRenderer = animationRenderer self.autoSetReady = autoSetReady + let isMainTab = chatListFilter == nil && location == .chatList(groupId: .root) + var isSelecting = false if case .peers(_, true, _, _, _, _) = mode { isSelecting = true @@ -1933,7 +1935,7 @@ public final class ChatListNode: ListView { notice = nil } - let (rawEntries, isLoading) = chatListNodeEntriesForView(view: update.list, state: state, savedMessagesPeer: savedMessagesPeer, foundPeers: state.foundPeers, hideArchivedFolderByDefault: hideArchivedFolderByDefault, displayArchiveIntro: displayArchiveIntro, notice: notice, mode: mode, chatListLocation: location, contacts: contacts, accountPeerId: accountPeerId) + let (rawEntries, isLoading) = chatListNodeEntriesForView(view: update.list, state: state, savedMessagesPeer: savedMessagesPeer, foundPeers: state.foundPeers, hideArchivedFolderByDefault: hideArchivedFolderByDefault, displayArchiveIntro: displayArchiveIntro, notice: notice, mode: mode, chatListLocation: location, contacts: contacts, accountPeerId: accountPeerId, isMainTab: isMainTab) var isEmpty = true var entries = rawEntries.filter { entry in switch entry { @@ -2403,6 +2405,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 +2421,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 +2441,9 @@ public final class ChatListNode: ListView { return state } } + if !refreshStoryPeerIds.isEmpty { + strongSelf.context.account.viewTracker.refreshStoryStatsForPeerIds(peerIds: refreshStoryPeerIds) + } } } diff --git a/submodules/ChatListUI/Sources/Node/ChatListNodeEntries.swift b/submodules/ChatListUI/Sources/Node/ChatListNodeEntries.swift index 16140b6b2e..ae22930744 100644 --- a/submodules/ChatListUI/Sources/Node/ChatListNodeEntries.swift +++ b/submodules/ChatListUI/Sources/Node/ChatListNodeEntries.swift @@ -568,7 +568,17 @@ struct ChatListContactPeer { } } -func chatListNodeEntriesForView(view: EngineChatList, state: ChatListNodeState, savedMessagesPeer: EnginePeer?, foundPeers: [(EnginePeer, EnginePeer?)], hideArchivedFolderByDefault: Bool, displayArchiveIntro: Bool, notice: ChatListNotice?, mode: ChatListNodeMode, chatListLocation: ChatListControllerLocation, contacts: [ChatListContactPeer], accountPeerId: EnginePeer.Id) -> (entries: [ChatListNodeEntry], loading: Bool) { +func chatListNodeEntriesForView(view: EngineChatList, state: ChatListNodeState, savedMessagesPeer: EnginePeer?, foundPeers: [(EnginePeer, EnginePeer?)], hideArchivedFolderByDefault: Bool, displayArchiveIntro: Bool, notice: ChatListNotice?, mode: ChatListNodeMode, chatListLocation: ChatListControllerLocation, contacts: [ChatListContactPeer], accountPeerId: EnginePeer.Id, isMainTab: Bool) -> (entries: [ChatListNodeEntry], loading: Bool) { + var groupItems = view.groupItems + if isMainTab && state.archiveStoryState != nil && groupItems.isEmpty { + groupItems.append(EngineChatList.GroupItem( + id: .archive, + topMessage: nil, + items: [], + unreadCount: 0 + )) + } + var result: [ChatListNodeEntry] = [] if !view.hasEarlier { @@ -588,7 +598,7 @@ func chatListNodeEntriesForView(view: EngineChatList, state: ChatListNodeState, if !view.hasLater, case .chatList = mode { var groupEntryCount = 0 - for _ in view.groupItems { + for _ in groupItems { groupEntryCount += 1 } pinnedIndexOffset += UInt16(groupEntryCount) @@ -831,7 +841,7 @@ func chatListNodeEntriesForView(view: EngineChatList, state: ChatListNodeState, } if !view.hasLater, case .chatList = mode { - for groupReference in view.groupItems { + for groupReference in groupItems { let messageIndex = EngineMessage.Index(id: EngineMessage.Id(peerId: EnginePeer.Id(0), namespace: 0, id: 0), timestamp: 1) var mappedStoryState: ChatListNodeState.StoryState? if let archiveStoryState = state.archiveStoryState { 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/ContactListUI/Sources/ContactsControllerNode.swift b/submodules/ContactListUI/Sources/ContactsControllerNode.swift index eb49fab201..98930ed814 100644 --- a/submodules/ContactListUI/Sources/ContactsControllerNode.swift +++ b/submodules/ContactListUI/Sources/ContactsControllerNode.swift @@ -186,7 +186,7 @@ final class ContactsControllerNode: ASDisplayNode, UIGestureRecognizerDelegate { self.contentOffset = offset self.contentOffsetChanged(offset: offset) - if self.contactListNode.listNode.isTracking { + /*if self.contactListNode.listNode.isTracking { if case let .known(value) = offset { if !self.storiesUnlocked { if value < -40.0 { @@ -220,7 +220,7 @@ final class ContactsControllerNode: ASDisplayNode, UIGestureRecognizerDelegate { default: break } - } + }*/ } self.contactListNode.contentScrollingEnded = { [weak self] listView in @@ -280,43 +280,18 @@ final class ContactsControllerNode: ASDisplayNode, UIGestureRecognizerDelegate { } private func contentScrollingEnded(listView: ListView) -> Bool { - if "".isEmpty { - return false - } - - /*if let navigationBarComponentView = self.navigationBarView.view as? ChatListNavigationBar.View { + if let navigationBarComponentView = self.navigationBarView.view as? ChatListNavigationBar.View { if let clippedScrollOffset = navigationBarComponentView.clippedScrollOffset { - if navigationBarComponentView.effectiveStoriesInsetHeight > 0.0 { - if clippedScrollOffset > 0.0 && clippedScrollOffset < navigationBarComponentView.effectiveStoriesInsetHeight { - if clippedScrollOffset < navigationBarComponentView.effectiveStoriesInsetHeight * 0.5 { - let _ = listView.scrollToOffsetFromTop(0.0, animated: true) - } else { - let _ = listView.scrollToOffsetFromTop(navigationBarComponentView.effectiveStoriesInsetHeight, animated: true) - } - return true + if clippedScrollOffset > 0.0 && clippedScrollOffset < ChatListNavigationBar.searchScrollHeight { + if clippedScrollOffset < ChatListNavigationBar.searchScrollHeight * 0.5 { + let _ = listView.scrollToOffsetFromTop(0.0, animated: true) } else { - let searchScrollOffset = clippedScrollOffset - navigationBarComponentView.effectiveStoriesInsetHeight - if searchScrollOffset > 0.0 && searchScrollOffset < ChatListNavigationBar.searchScrollHeight { - if searchScrollOffset < ChatListNavigationBar.searchScrollHeight * 0.5 { - let _ = listView.scrollToOffsetFromTop(navigationBarComponentView.effectiveStoriesInsetHeight, animated: true) - } else { - let _ = listView.scrollToOffsetFromTop(navigationBarComponentView.effectiveStoriesInsetHeight + ChatListNavigationBar.searchScrollHeight, animated: true) - } - return true - } - } - } else { - if clippedScrollOffset > 0.0 && clippedScrollOffset < ChatListNavigationBar.searchScrollHeight { - if clippedScrollOffset < ChatListNavigationBar.searchScrollHeight * 0.5 { - let _ = listView.scrollToOffsetFromTop(0.0, animated: true) - } else { - let _ = listView.scrollToOffsetFromTop(ChatListNavigationBar.searchScrollHeight, animated: true) - } - return true + let _ = listView.scrollToOffsetFromTop(ChatListNavigationBar.searchScrollHeight, animated: true) } + return true } } - }*/ + } return false } diff --git a/submodules/Display/Source/ListView.swift b/submodules/Display/Source/ListView.swift index 471f020bcb..5daeb253d2 100644 --- a/submodules/Display/Source/ListView.swift +++ b/submodules/Display/Source/ListView.swift @@ -1235,7 +1235,10 @@ open class ListView: ASDisplayNode, UIScrollViewAccessibilityDelegate, UIGesture if let keepMinimalScrollHeightWithTopInset = self.keepMinimalScrollHeightWithTopInset, topItemFound { if !self.stackFromBottom { - completeHeight = max(completeHeight, self.visibleSize.height + keepMinimalScrollHeightWithTopInset - effectiveInsets.bottom - effectiveInsets.top) + if !keepMinimalScrollHeightWithTopInset.isZero { + completeHeight = max(completeHeight, self.visibleSize.height + effectiveInsets.top + effectiveInsets.bottom) + } + //completeHeight = max(completeHeight, self.visibleSize.height + keepMinimalScrollHeightWithTopInset - effectiveInsets.bottom - effectiveInsets.top) bottomItemEdge = max(bottomItemEdge, topItemEdge + completeHeight) } else { effectiveInsets.top = max(effectiveInsets.top, self.visibleSize.height - completeHeight) @@ -1647,7 +1650,10 @@ open class ListView: ASDisplayNode, UIScrollViewAccessibilityDelegate, UIGesture if let keepMinimalScrollHeightWithTopInset = self.keepMinimalScrollHeightWithTopInset { if !self.stackFromBottom { - completeHeight = max(completeHeight, self.visibleSize.height + keepMinimalScrollHeightWithTopInset) + if !keepMinimalScrollHeightWithTopInset.isZero { + completeHeight = max(completeHeight, self.visibleSize.height + effectiveInsets.top + effectiveInsets.bottom) + } + //completeHeight = max(completeHeight, self.visibleSize.height + keepMinimalScrollHeightWithTopInset) bottomItemEdge = max(bottomItemEdge, topItemEdge + completeHeight) } } diff --git a/submodules/Display/Source/TextNode.swift b/submodules/Display/Source/TextNode.swift index 7aa5ab8860..79858fa469 100644 --- a/submodules/Display/Source/TextNode.swift +++ b/submodules/Display/Source/TextNode.swift @@ -149,6 +149,7 @@ public final class TextNodeLayoutArguments { public let textStroke: (UIColor, CGFloat)? public let displaySpoilers: Bool public let displayEmbeddedItemsUnderSpoilers: Bool + public let customTruncationToken: NSAttributedString? public init( attributedString: NSAttributedString?, @@ -167,7 +168,8 @@ public final class TextNodeLayoutArguments { textShadowBlur: CGFloat? = nil, textStroke: (UIColor, CGFloat)? = nil, displaySpoilers: Bool = false, - displayEmbeddedItemsUnderSpoilers: Bool = false + displayEmbeddedItemsUnderSpoilers: Bool = false, + customTruncationToken: NSAttributedString? = nil ) { self.attributedString = attributedString self.backgroundColor = backgroundColor @@ -186,6 +188,7 @@ public final class TextNodeLayoutArguments { self.textStroke = textStroke self.displaySpoilers = displaySpoilers self.displayEmbeddedItemsUnderSpoilers = displayEmbeddedItemsUnderSpoilers + self.customTruncationToken = customTruncationToken } public func withAttributedString(_ attributedString: NSAttributedString?) -> TextNodeLayoutArguments { @@ -206,7 +209,8 @@ public final class TextNodeLayoutArguments { textShadowBlur: self.textShadowBlur, textStroke: self.textStroke, displaySpoilers: self.displaySpoilers, - displayEmbeddedItemsUnderSpoilers: self.displayEmbeddedItemsUnderSpoilers + displayEmbeddedItemsUnderSpoilers: self.displayEmbeddedItemsUnderSpoilers, + customTruncationToken: self.customTruncationToken ) } } @@ -998,7 +1002,7 @@ open class TextNode: ASDisplayNode { } } - static func calculateLayout(attributedString: NSAttributedString?, minimumNumberOfLines: Int, maximumNumberOfLines: Int, truncationType: CTLineTruncationType, backgroundColor: UIColor?, constrainedSize: CGSize, alignment: NSTextAlignment, verticalAlignment: TextVerticalAlignment, lineSpacingFactor: CGFloat, cutout: TextNodeCutout?, insets: UIEdgeInsets, lineColor: UIColor?, textShadowColor: UIColor?, textShadowBlur: CGFloat?, textStroke: (UIColor, CGFloat)?, displaySpoilers: Bool, displayEmbeddedItemsUnderSpoilers: Bool) -> TextNodeLayout { + static func calculateLayout(attributedString: NSAttributedString?, minimumNumberOfLines: Int, maximumNumberOfLines: Int, truncationType: CTLineTruncationType, backgroundColor: UIColor?, constrainedSize: CGSize, alignment: NSTextAlignment, verticalAlignment: TextVerticalAlignment, lineSpacingFactor: CGFloat, cutout: TextNodeCutout?, insets: UIEdgeInsets, lineColor: UIColor?, textShadowColor: UIColor?, textShadowBlur: CGFloat?, textStroke: (UIColor, CGFloat)?, displaySpoilers: Bool, displayEmbeddedItemsUnderSpoilers: Bool, customTruncationToken: NSAttributedString?) -> TextNodeLayout { if let attributedString = attributedString { let stringLength = attributedString.length @@ -1168,7 +1172,17 @@ open class TextNode: ASDisplayNode { layoutSize.height += fontLineSpacing } - let lineRange = CFRange(location: lastLineCharacterIndex, length: stringLength - lastLineCharacterIndex) + var didClipLinebreak = false + var lineRange = CFRange(location: lastLineCharacterIndex, length: stringLength - lastLineCharacterIndex) + let nsString = (attributedString.string as NSString) + for i in lineRange.location ..< (lineRange.location + lineRange.length) { + if nsString.character(at: i) == 0x0a { + lineRange.length = max(0, i - lineRange.location) + didClipLinebreak = true + break + } + } + var brokenLineRange = CFRange(location: lastLineCharacterIndex, length: lineCharacterCount) if brokenLineRange.location + brokenLineRange.length > attributedString.length { brokenLineRange.length = attributedString.length - brokenLineRange.location @@ -1186,16 +1200,44 @@ open class TextNode: ASDisplayNode { lineConstrainedSize.width -= bottomCutoutSize.width } - if CTLineGetTypographicBounds(originalLine, nil, nil, nil) - CTLineGetTrailingWhitespaceWidth(originalLine) < Double(lineConstrainedSize.width) { - coreTextLine = originalLine + let truncatedTokenString: NSAttributedString + if let customTruncationToken { + truncatedTokenString = customTruncationToken } else { var truncationTokenAttributes: [NSAttributedString.Key : AnyObject] = [:] truncationTokenAttributes[NSAttributedString.Key.font] = font truncationTokenAttributes[NSAttributedString.Key(rawValue: kCTForegroundColorFromContextAttributeName as String)] = true as NSNumber let tokenString = "\u{2026}" - let truncatedTokenString = NSAttributedString(string: tokenString, attributes: truncationTokenAttributes) - let truncationToken = CTLineCreateWithAttributedString(truncatedTokenString) - + + truncatedTokenString = NSAttributedString(string: tokenString, attributes: truncationTokenAttributes) + } + let truncationToken = CTLineCreateWithAttributedString(truncatedTokenString) + + if CTLineGetTypographicBounds(originalLine, nil, nil, nil) - CTLineGetTrailingWhitespaceWidth(originalLine) < Double(lineConstrainedSize.width) { + if didClipLinebreak { + let mergedLine = NSMutableAttributedString() + mergedLine.append(attributedString.attributedSubstring(from: NSRange(location: lineRange.location, length: lineRange.length))) + mergedLine.append(truncatedTokenString) + + coreTextLine = CTLineCreateWithAttributedString(mergedLine) + + let runs = (CTLineGetGlyphRuns(coreTextLine) as [AnyObject]) as! [CTRun] + for run in runs { + let runAttributes: NSDictionary = CTRunGetAttributes(run) + if let _ = runAttributes["CTForegroundColorFromContext"] { + brokenLineRange.length = CTRunGetStringRange(run).location - brokenLineRange.location + break + } + } + if brokenLineRange.location + brokenLineRange.length > attributedString.length { + brokenLineRange.length = attributedString.length - brokenLineRange.location + } + + truncated = true + } else { + coreTextLine = originalLine + } + } else { coreTextLine = CTLineCreateTruncatedLine(originalLine, Double(lineConstrainedSize.width), truncationType, truncationToken) ?? truncationToken let runs = (CTLineGetGlyphRuns(coreTextLine) as [AnyObject]) as! [CTRun] for run in runs { @@ -1647,11 +1689,11 @@ open class TextNode: ASDisplayNode { if stringMatch { layout = existingLayout } else { - layout = TextNode.calculateLayout(attributedString: arguments.attributedString, minimumNumberOfLines: arguments.minimumNumberOfLines, maximumNumberOfLines: arguments.maximumNumberOfLines, truncationType: arguments.truncationType, backgroundColor: arguments.backgroundColor, constrainedSize: arguments.constrainedSize, alignment: arguments.alignment, verticalAlignment: arguments.verticalAlignment, lineSpacingFactor: arguments.lineSpacing, cutout: arguments.cutout, insets: arguments.insets, lineColor: arguments.lineColor, textShadowColor: arguments.textShadowColor, textShadowBlur: arguments.textShadowBlur, textStroke: arguments.textStroke, displaySpoilers: arguments.displaySpoilers, displayEmbeddedItemsUnderSpoilers: arguments.displayEmbeddedItemsUnderSpoilers) + layout = TextNode.calculateLayout(attributedString: arguments.attributedString, minimumNumberOfLines: arguments.minimumNumberOfLines, maximumNumberOfLines: arguments.maximumNumberOfLines, truncationType: arguments.truncationType, backgroundColor: arguments.backgroundColor, constrainedSize: arguments.constrainedSize, alignment: arguments.alignment, verticalAlignment: arguments.verticalAlignment, lineSpacingFactor: arguments.lineSpacing, cutout: arguments.cutout, insets: arguments.insets, lineColor: arguments.lineColor, textShadowColor: arguments.textShadowColor, textShadowBlur: arguments.textShadowBlur, textStroke: arguments.textStroke, displaySpoilers: arguments.displaySpoilers, displayEmbeddedItemsUnderSpoilers: arguments.displayEmbeddedItemsUnderSpoilers, customTruncationToken: arguments.customTruncationToken) updated = true } } else { - layout = TextNode.calculateLayout(attributedString: arguments.attributedString, minimumNumberOfLines: arguments.minimumNumberOfLines, maximumNumberOfLines: arguments.maximumNumberOfLines, truncationType: arguments.truncationType, backgroundColor: arguments.backgroundColor, constrainedSize: arguments.constrainedSize, alignment: arguments.alignment, verticalAlignment: arguments.verticalAlignment, lineSpacingFactor: arguments.lineSpacing, cutout: arguments.cutout, insets: arguments.insets, lineColor: arguments.lineColor, textShadowColor: arguments.textShadowColor, textShadowBlur: arguments.textShadowBlur, textStroke: arguments.textStroke, displaySpoilers: arguments.displaySpoilers, displayEmbeddedItemsUnderSpoilers: arguments.displayEmbeddedItemsUnderSpoilers) + layout = TextNode.calculateLayout(attributedString: arguments.attributedString, minimumNumberOfLines: arguments.minimumNumberOfLines, maximumNumberOfLines: arguments.maximumNumberOfLines, truncationType: arguments.truncationType, backgroundColor: arguments.backgroundColor, constrainedSize: arguments.constrainedSize, alignment: arguments.alignment, verticalAlignment: arguments.verticalAlignment, lineSpacingFactor: arguments.lineSpacing, cutout: arguments.cutout, insets: arguments.insets, lineColor: arguments.lineColor, textShadowColor: arguments.textShadowColor, textShadowBlur: arguments.textShadowBlur, textStroke: arguments.textStroke, displaySpoilers: arguments.displaySpoilers, displayEmbeddedItemsUnderSpoilers: arguments.displayEmbeddedItemsUnderSpoilers, customTruncationToken: arguments.customTruncationToken) updated = true } @@ -2292,11 +2334,11 @@ open class TextView: UIView { if stringMatch { layout = existingLayout } else { - layout = TextNode.calculateLayout(attributedString: arguments.attributedString, minimumNumberOfLines: arguments.minimumNumberOfLines, maximumNumberOfLines: arguments.maximumNumberOfLines, truncationType: arguments.truncationType, backgroundColor: arguments.backgroundColor, constrainedSize: arguments.constrainedSize, alignment: arguments.alignment, verticalAlignment: arguments.verticalAlignment, lineSpacingFactor: arguments.lineSpacing, cutout: arguments.cutout, insets: arguments.insets, lineColor: arguments.lineColor, textShadowColor: arguments.textShadowColor, textShadowBlur: arguments.textShadowBlur, textStroke: arguments.textStroke, displaySpoilers: arguments.displaySpoilers, displayEmbeddedItemsUnderSpoilers: arguments.displayEmbeddedItemsUnderSpoilers) + layout = TextNode.calculateLayout(attributedString: arguments.attributedString, minimumNumberOfLines: arguments.minimumNumberOfLines, maximumNumberOfLines: arguments.maximumNumberOfLines, truncationType: arguments.truncationType, backgroundColor: arguments.backgroundColor, constrainedSize: arguments.constrainedSize, alignment: arguments.alignment, verticalAlignment: arguments.verticalAlignment, lineSpacingFactor: arguments.lineSpacing, cutout: arguments.cutout, insets: arguments.insets, lineColor: arguments.lineColor, textShadowColor: arguments.textShadowColor, textShadowBlur: arguments.textShadowBlur, textStroke: arguments.textStroke, displaySpoilers: arguments.displaySpoilers, displayEmbeddedItemsUnderSpoilers: arguments.displayEmbeddedItemsUnderSpoilers, customTruncationToken: arguments.customTruncationToken) updated = true } } else { - layout = TextNode.calculateLayout(attributedString: arguments.attributedString, minimumNumberOfLines: arguments.minimumNumberOfLines, maximumNumberOfLines: arguments.maximumNumberOfLines, truncationType: arguments.truncationType, backgroundColor: arguments.backgroundColor, constrainedSize: arguments.constrainedSize, alignment: arguments.alignment, verticalAlignment: arguments.verticalAlignment, lineSpacingFactor: arguments.lineSpacing, cutout: arguments.cutout, insets: arguments.insets, lineColor: arguments.lineColor, textShadowColor: arguments.textShadowColor, textShadowBlur: arguments.textShadowBlur, textStroke: arguments.textStroke, displaySpoilers: arguments.displaySpoilers, displayEmbeddedItemsUnderSpoilers: arguments.displayEmbeddedItemsUnderSpoilers) + layout = TextNode.calculateLayout(attributedString: arguments.attributedString, minimumNumberOfLines: arguments.minimumNumberOfLines, maximumNumberOfLines: arguments.maximumNumberOfLines, truncationType: arguments.truncationType, backgroundColor: arguments.backgroundColor, constrainedSize: arguments.constrainedSize, alignment: arguments.alignment, verticalAlignment: arguments.verticalAlignment, lineSpacingFactor: arguments.lineSpacing, cutout: arguments.cutout, insets: arguments.insets, lineColor: arguments.lineColor, textShadowColor: arguments.textShadowColor, textShadowBlur: arguments.textShadowBlur, textStroke: arguments.textStroke, displaySpoilers: arguments.displaySpoilers, displayEmbeddedItemsUnderSpoilers: arguments.displayEmbeddedItemsUnderSpoilers, customTruncationToken: arguments.customTruncationToken) updated = true } diff --git a/submodules/PeerInfoAvatarListNode/Sources/PeerInfoAvatarListNode.swift b/submodules/PeerInfoAvatarListNode/Sources/PeerInfoAvatarListNode.swift index ac651cc684..189ed3c9ee 100644 --- a/submodules/PeerInfoAvatarListNode/Sources/PeerInfoAvatarListNode.swift +++ b/submodules/PeerInfoAvatarListNode/Sources/PeerInfoAvatarListNode.swift @@ -580,7 +580,7 @@ public final class PeerInfoAvatarListContainerNode: ASDisplayNode { public let topShadowNode: ASImageNode public let bottomShadowNode: ASImageNode - public var storyParams: (peer: EnginePeer, items: [EngineStoryItem], count: Int, hasUnseen: Bool)? + public var storyParams: (peer: EnginePeer, items: [EngineStoryItem], count: Int, hasUnseen: Bool, hasUnseenPrivate: Bool)? private var expandedStorySetIndicator: ComponentView? public var expandedStorySetIndicatorTransitionView: (UIView, CGRect)? { if let setView = self.expandedStorySetIndicator?.view as? StorySetIndicatorComponent.View { @@ -1268,6 +1268,7 @@ public final class PeerInfoAvatarListContainerNode: ASDisplayNode { peer: storyParams.peer, items: storyParams.items, hasUnseen: storyParams.hasUnseen, + hasUnseenPrivate: storyParams.hasUnseenPrivate, totalCount: storyParams.count, theme: defaultDarkPresentationTheme, action: { [weak self] in 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/SparseItemGrid/Sources/SparseItemGrid.swift b/submodules/SparseItemGrid/Sources/SparseItemGrid.swift index eff8bfd439..19fd2c372e 100644 --- a/submodules/SparseItemGrid/Sources/SparseItemGrid.swift +++ b/submodules/SparseItemGrid/Sources/SparseItemGrid.swift @@ -1074,6 +1074,10 @@ public final class SparseItemGrid: ASDisplayNode { } for id in removeIds { if let item = self.visibleItems.removeValue(forKey: id) { + if let blurLayer = item.blurLayer { + item.blurLayer = nil + blurLayer.removeFromSuperlayer() + } if let layer = item.layer { items.itemBinding.unbindLayer(layer: layer) layer.removeFromSuperlayer() 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/TelegramEngine/Messages/Stories.swift b/submodules/TelegramCore/Sources/TelegramEngine/Messages/Stories.swift index 3accb87b8a..60bfed09a9 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Messages/Stories.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Messages/Stories.swift @@ -607,7 +607,11 @@ private func prepareUploadStoryContent(account: Account, media: EngineStoryInput if let firstFrameFile = firstFrameFile { account.postbox.mediaBox.storeCachedResourceRepresentation(resource.id.stringRepresentation, representationId: "first-frame", keepDuration: .general, tempFile: firstFrameFile) - previewRepresentations.append(TelegramMediaImageRepresentation(dimensions: dimensions, resource: resource, progressiveSizes: [], immediateThumbnailData: nil, hasVideo: false, isPersonal: false)) + if let data = try? Data(contentsOf: URL(fileURLWithPath: firstFrameFile.path), options: .mappedIfSafe) { + let localResource = LocalFileMediaResource(fileId: Int64.random(in: Int64.min ... Int64.max), size: nil, isSecretRelated: false) + account.postbox.mediaBox.storeResourceData(localResource.id, data: data) + previewRepresentations.append(TelegramMediaImageRepresentation(dimensions: dimensions, resource: localResource, progressiveSizes: [], immediateThumbnailData: nil, hasVideo: false, isPersonal: false)) + } } let fileMedia = TelegramMediaFile( diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Messages/StoryListContext.swift b/submodules/TelegramCore/Sources/TelegramEngine/Messages/StoryListContext.swift index 4fccbf704f..fb2a491900 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Messages/StoryListContext.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Messages/StoryListContext.swift @@ -544,9 +544,9 @@ public final class PeerStoryListContext { self.requestDisposable = (self.account.postbox.transaction { transaction -> Api.InputUser? in return transaction.getPeer(peerId).flatMap(apiInputUser) } - |> mapToSignal { inputUser -> Signal<([EngineStoryItem], Int, PeerReference?), NoError> in + |> mapToSignal { inputUser -> Signal<([EngineStoryItem], Int, PeerReference?, Bool), NoError> in guard let inputUser = inputUser else { - return .single(([], 0, nil)) + return .single(([], 0, nil, false)) } let signal: Signal @@ -562,18 +562,20 @@ public final class PeerStoryListContext { |> `catch` { _ -> Signal in return .single(nil) } - |> mapToSignal { result -> Signal<([EngineStoryItem], Int, PeerReference?), NoError> in + |> mapToSignal { result -> Signal<([EngineStoryItem], Int, PeerReference?, Bool), NoError> in guard let result = result else { - return .single(([], 0, nil)) + return .single(([], 0, nil, false)) } - return account.postbox.transaction { transaction -> ([EngineStoryItem], Int, PeerReference?) in + return account.postbox.transaction { transaction -> ([EngineStoryItem], Int, PeerReference?, Bool) in var storyItems: [EngineStoryItem] = [] var totalCount: Int = 0 + var hasMore: Bool = false switch result { case let .stories(count, stories, users): totalCount = Int(count) + hasMore = stories.count >= 100 updatePeers(transaction: transaction, accountPeerId: accountPeerId, peers: AccumulatedPeers(users: users)) @@ -619,11 +621,11 @@ public final class PeerStoryListContext { } } - return (storyItems, totalCount, transaction.getPeer(peerId).flatMap(PeerReference.init)) + return (storyItems, totalCount, transaction.getPeer(peerId).flatMap(PeerReference.init), hasMore) } } } - |> deliverOn(self.queue)).start(next: { [weak self] storyItems, totalCount, peerReference in + |> deliverOn(self.queue)).start(next: { [weak self] storyItems, totalCount, peerReference, hasMore in guard let `self` = self else { return } @@ -650,7 +652,11 @@ public final class PeerStoryListContext { updatedState.peerReference = peerReference } - updatedState.loadMoreToken = (storyItems.last?.id).flatMap(Int.init) + if hasMore { + updatedState.loadMoreToken = (storyItems.last?.id).flatMap(Int.init) + } else { + updatedState.loadMoreToken = nil + } if updatedState.loadMoreToken != nil { updatedState.totalCount = max(totalCount, updatedState.items.count) } 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/TelegramStringFormatting/Sources/PresenceStrings.swift b/submodules/TelegramStringFormatting/Sources/PresenceStrings.swift index 5627ab6f09..f69fc0775a 100644 --- a/submodules/TelegramStringFormatting/Sources/PresenceStrings.swift +++ b/submodules/TelegramStringFormatting/Sources/PresenceStrings.swift @@ -362,7 +362,7 @@ public func stringForRelativeLiveLocationUpdateTimestamp(strings: PresentationSt } } -public func stringForRelativeActivityTimestamp(strings: PresentationStrings, dateTimeFormat: PresentationDateTimeFormat, relativeTimestamp: Int32, relativeTo timestamp: Int32) -> String { +public func stringForRelativeActivityTimestamp(strings: PresentationStrings, dateTimeFormat: PresentationDateTimeFormat, preciseTime: Bool = false, relativeTimestamp: Int32, relativeTo timestamp: Int32) -> String { let difference = timestamp - relativeTimestamp if difference < 60 { return strings.Time_JustNow @@ -392,6 +392,8 @@ public func stringForRelativeActivityTimestamp(strings: PresentationStrings, dat day = .yesterday } return humanReadableStringForTimestamp(strings: strings, day: day, dateTimeFormat: dateTimeFormat, hours: timeinfo.tm_hour, minutes: timeinfo.tm_min).string + } else if preciseTime { + return strings.Time_AtPreciseDate(stringForTimestamp(day: timeinfo.tm_mday, month: timeinfo.tm_mon + 1, year: timeinfo.tm_year, dateTimeFormat: dateTimeFormat), stringForShortTimestamp(hours: timeinfo.tm_hour, minutes: timeinfo.tm_min, dateTimeFormat: dateTimeFormat)).string } else { return strings.Time_AtDate(stringForTimestamp(day: timeinfo.tm_mday, month: timeinfo.tm_mon + 1, year: timeinfo.tm_year, dateTimeFormat: dateTimeFormat)).string } diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoStoryGridScreen/BUILD b/submodules/TelegramUI/Components/PeerInfo/PeerInfoStoryGridScreen/BUILD index 1abccac0f3..5b58e2600a 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoStoryGridScreen/BUILD +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoStoryGridScreen/BUILD @@ -26,6 +26,8 @@ swift_library( "//submodules/UndoUI", "//submodules/TelegramUI/Components/BottomButtonPanelComponent", "//submodules/TelegramUI/Components/MoreHeaderButton", + "//submodules/TelegramUI/Components/MediaEditorScreen", + "//submodules/SaveToCameraRoll", ], visibility = [ "//visibility:public", diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoStoryGridScreen/Sources/PeerInfoStoryGridScreen.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoStoryGridScreen/Sources/PeerInfoStoryGridScreen.swift index be12de4009..81120c734c 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoStoryGridScreen/Sources/PeerInfoStoryGridScreen.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoStoryGridScreen/Sources/PeerInfoStoryGridScreen.swift @@ -14,6 +14,8 @@ import ChatTitleView import BottomButtonPanelComponent import UndoUI import MoreHeaderButton +import MediaEditorScreen +import SaveToCameraRoll final class PeerInfoStoryGridScreenComponent: Component { typealias EnvironmentType = ViewControllerComponentContainer.Environment @@ -93,11 +95,11 @@ final class PeerInfoStoryGridScreenComponent: Component { }, action: { [weak self] _, a in a(.default) - guard let self, let component = self.component else { + guard let self else { return } - let _ = component + self.saveSelected() }))) items.append(.action(ContextMenuActionItem(text: strings.Common_Delete, textColor: .destructive, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Delete"), color: theme.contextMenu.destructiveColor) @@ -279,6 +281,72 @@ final class PeerInfoStoryGridScreenComponent: Component { controller.presentInGlobalOverlay(contextController) } + private func saveSelected() { + guard let component = self.component else { + return + } + + let _ = (component.context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: component.peerId)) + |> deliverOnMainQueue).start(next: { [weak self] peer in + guard let self, let component = self.component, let peer else { + return + } + guard let peerReference = PeerReference(peer._asPeer()) else { + return + } + + guard let paneNode = self.paneNode, !paneNode.selectedIds.isEmpty else { + return + } + + var signals: [Signal] = [] + let sortedItems = paneNode.selectedItems.sorted(by: { lhs, rhs in return lhs.key < rhs.key }) + if sortedItems.isEmpty { + return + } + + //TODO:localize + let saveScreen = SaveProgressScreen(context: component.context, content: .progress("Saving", 0.0)) + self.environment?.controller()?.present(saveScreen, in: .current) + + let valueNorm: Float = 1.0 / Float(sortedItems.count) + var progressStart: Float = 0.0 + for (_, item) in sortedItems { + let itemOffset = progressStart + progressStart += valueNorm + signals.append(saveToCameraRoll(context: component.context, postbox: component.context.account.postbox, userLocation: .other, mediaReference: .story(peer: peerReference, id: item.id, media: item.media._asMedia())) + |> map { progress -> Float in + return itemOffset + progress * valueNorm + }) + } + + var allSignal: Signal = .single(0.0) + for signal in signals { + allSignal = allSignal |> then(signal) + } + + let disposable = (allSignal + |> deliverOnMainQueue).start(next: { [weak saveScreen] progress in + guard let saveScreen else { + return + } + saveScreen.content = .progress("Saving", progress) + }, completed: { [weak saveScreen] in + guard let saveScreen else { + return + } + saveScreen.content = .completion("Saved") + Queue.mainQueue().after(3.0, { [weak saveScreen] in + saveScreen?.dismiss() + }) + }) + + saveScreen.cancelled = { + disposable.dispose() + } + }) + } + func update(component: PeerInfoStoryGridScreenComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { self.component = component self.state = state diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoStoryPaneNode.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoStoryPaneNode.swift index 41fbf7a7d3..116bd2d467 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoStoryPaneNode.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoStoryPaneNode.swift @@ -1009,11 +1009,13 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr var transitionIn: StoryContainerScreen.TransitionIn? let story = item.story + var foundItem: SparseItemGridDisplayItem? var foundItemLayer: SparseItemGridLayer? self.itemGrid.forEachVisibleItem { item in guard let itemLayer = item.layer as? ItemLayer else { return } + foundItem = item if let listItem = itemLayer.item, listItem.story.id == story.id { foundItemLayer = itemLayer } @@ -1026,6 +1028,11 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr sourceCornerRadius: 0.0, sourceIsAvatar: false ) + + if let blurLayer = foundItem?.blurLayer { + let transition = Transition(animation: .curve(duration: 0.25, curve: .easeInOut)) + transition.setAlpha(layer: blurLayer, alpha: 0.0) + } } let storyContainerScreen = StoryContainerScreen( @@ -1037,16 +1044,23 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr return nil } + var foundItem: SparseItemGridDisplayItem? var foundItemLayer: SparseItemGridLayer? self.itemGrid.forEachVisibleItem { item in guard let itemLayer = item.layer as? ItemLayer else { return } + foundItem = item if let listItem = itemLayer.item, AnyHashable(listItem.story.id) == itemId { foundItemLayer = itemLayer } } if let foundItemLayer { + if let blurLayer = foundItem?.blurLayer { + let transition = Transition(animation: .curve(duration: 0.25, curve: .easeInOut)) + transition.setAlpha(layer: blurLayer, alpha: 1.0) + } + let itemRect = self.itemGrid.frameForItem(layer: foundItemLayer) return StoryContainerScreen.TransitionOut( destinationView: self.view, @@ -1487,29 +1501,13 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr } public func loadHole(anchor: SparseItemGrid.HoleAnchor, at location: SparseItemGrid.HoleLocation) -> Signal { - //TODO:load more - /*guard let anchor = anchor as? VisualMediaHoleAnchor else { - return .never() - } - let mappedDirection: SparseMessageList.LoadHoleDirection - switch location { - case .around: - mappedDirection = .around - case .toLower: - mappedDirection = .later - case .toUpper: - mappedDirection = .earlier - } let listSource = self.listSource - return Signal { subscriber in - listSource.loadHole(anchor: anchor.messageId, direction: mappedDirection, completion: { - subscriber.putCompletion() - }) - + return Signal { _ in + listSource.loadMore() + return EmptyDisposable - }*/ - - return .never() + } + |> runOn(.mainQueue()) } public func updateContentType(contentType: ContentType) { @@ -1575,7 +1573,7 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr let timezoneOffset = Int32(TimeZone.current.secondsFromGMT()) var mappedItems: [SparseItemGrid.Item] = [] - let mappedHoles: [SparseItemGrid.HoleAnchor] = [] + var mappedHoles: [SparseItemGrid.HoleAnchor] = [] var totalCount: Int = 0 if let peerReference = state.peerReference { for item in state.items { @@ -1586,6 +1584,9 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr localMonthTimestamp: Month(localTimestamp: item.timestamp + timezoneOffset).packedValue )) } + if mappedItems.count < state.totalCount, let lastItem = state.items.last { + mappedHoles.append(VisualMediaHoleAnchor(index: mappedItems.count, storyId: 1, localMonthTimestamp: Month(localTimestamp: lastItem.timestamp + timezoneOffset).packedValue)) + } } totalCount = state.totalCount totalCount = max(mappedItems.count, totalCount) @@ -1875,11 +1876,21 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr } private func updateHiddenItems() { - self.itemGrid.forEachVisibleItem { item in - guard let itemLayer = item.layer as? ItemLayer, let item = itemLayer.item else { + self.itemGrid.forEachVisibleItem { itemValue in + guard let itemLayer = itemValue.layer as? ItemLayer, let item = itemLayer.item else { return } - itemLayer.isHidden = self.itemInteraction.hiddenMedia.contains(item.story.id) + let itemHidden = self.itemInteraction.hiddenMedia.contains(item.story.id) + itemLayer.isHidden = itemHidden + + if let blurLayer = itemValue.blurLayer { + let transition = Transition.immediate + if itemHidden { + transition.setAlpha(layer: blurLayer, alpha: 0.0) + } else { + transition.setAlpha(layer: blurLayer, alpha: 1.0) + } + } } } 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..a67530f1dc --- /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(4.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(4.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/StoryAuthorInfoComponent.swift b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryAuthorInfoComponent.swift index 93883488f1..ed343a9dfc 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryAuthorInfoComponent.swift +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryAuthorInfoComponent.swift @@ -8,15 +8,22 @@ import TelegramStringFormatting import MultilineTextComponent final class StoryAuthorInfoComponent: Component { + struct Counters: Equatable { + var position: Int + var totalCount: Int + } + let context: AccountContext let peer: EnginePeer? let timestamp: Int32 + let counters: Counters? let isEdited: Bool - init(context: AccountContext, peer: EnginePeer?, timestamp: Int32, isEdited: Bool) { + init(context: AccountContext, peer: EnginePeer?, timestamp: Int32, counters: Counters?, isEdited: Bool) { self.context = context self.peer = peer self.timestamp = timestamp + self.counters = counters self.isEdited = isEdited } @@ -30,6 +37,9 @@ final class StoryAuthorInfoComponent: Component { if lhs.timestamp != rhs.timestamp { return false } + if lhs.counters != rhs.counters { + return false + } if lhs.isEdited != rhs.isEdited { return false } @@ -39,6 +49,7 @@ final class StoryAuthorInfoComponent: Component { final class View: UIView { private let title = ComponentView() private let subtitle = ComponentView() + private var counterLabel: ComponentView? private var component: StoryAuthorInfoComponent? private weak var state: EmptyComponentState? @@ -71,7 +82,7 @@ final class StoryAuthorInfoComponent: Component { } let timestamp = Int32(CFAbsoluteTimeGetCurrent() + NSTimeIntervalSince1970) - var subtitle = stringForRelativeActivityTimestamp(strings: presentationData.strings, dateTimeFormat: presentationData.dateTimeFormat, relativeTimestamp: component.timestamp, relativeTo: timestamp) + var subtitle = stringForRelativeActivityTimestamp(strings: presentationData.strings, dateTimeFormat: presentationData.dateTimeFormat, preciseTime: true, relativeTimestamp: component.timestamp, relativeTo: timestamp) if component.isEdited { subtitle.append(" • ") @@ -117,6 +128,36 @@ final class StoryAuthorInfoComponent: Component { } transition.setFrame(view: subtitleView, frame: subtitleFrame) } + + let countersSpacing: CGFloat = 5.0 + if let counters = component.counters { + let counterLabel: ComponentView + if let current = self.counterLabel { + counterLabel = current + } else { + counterLabel = ComponentView() + self.counterLabel = counterLabel + } + let counterSize = counterLabel.update( + transition: .immediate, + component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString(string: "\(counters.position + 1)/\(counters.totalCount)", font: Font.regular(11.0), textColor: UIColor(white: 1.0, alpha: 0.43))), + truncationType: .end, + maximumNumberOfLines: 1 + )), + environment: {}, + containerSize: CGSize(width: max(1.0, availableSize.width - titleSize.width - countersSpacing), height: 100.0) + ) + if let counterLabelView = counterLabel.view { + if counterLabelView.superview == nil { + self.addSubview(counterLabelView) + } + counterLabelView.frame = CGRect(origin: CGPoint(x: titleFrame.maxX + countersSpacing, y: titleFrame.minY + 1.0 + floorToScreenPixels((titleFrame.height - counterSize.height) * 0.5)), size: counterSize) + } + } else if let counterLabel = self.counterLabel { + self.counterLabel = nil + counterLabel.view?.removeFromSuperview() + } return size } diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryChatContent.swift b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryChatContent.swift index a11fe9115e..9cc21f806e 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 @@ -283,6 +284,7 @@ public final class StoryContentContextImpl: StoryContentContext { let allItems = mappedItems.map { item in return StoryContentItem( position: nil, + dayCounters: nil, peerId: peer.id, storyItem: item, entityFiles: extractItemEntityFiles(item: item, allEntityFiles: allEntityFiles) @@ -295,6 +297,7 @@ public final class StoryContentContextImpl: StoryContentContext { additionalPeerData: additionalPeerData, item: StoryContentItem( position: mappedFocusedIndex ?? focusedIndex, + dayCounters: nil, peerId: peer.id, storyItem: mappedItem, entityFiles: extractItemEntityFiles(item: mappedItem, allEntityFiles: allEntityFiles) @@ -758,6 +761,8 @@ public final class StoryContentContextImpl: StoryContentContext { }) } } + } else { + self.updateState() } } @@ -1027,6 +1032,7 @@ public final class SingleStoryContentContextImpl: StoryContentContext { let mainItem = StoryContentItem( position: 0, + dayCounters: nil, peerId: peer.id, storyItem: mappedItem, entityFiles: extractItemEntityFiles(item: mappedItem, allEntityFiles: allEntityFiles) @@ -1171,20 +1177,58 @@ public final class PeerStoryListContentContextImpl: StoryContentContext { } } + struct DayIndex: Hashable { + var year: Int32 + var day: Int32 + + init(timestamp: Int32) { + var time: time_t = time_t(timestamp) + var timeinfo: tm = tm() + localtime_r(&time, &timeinfo) + + self.year = timeinfo.tm_year + self.day = timeinfo.tm_yday + } + } + let stateValue: StoryContentContextState if let focusedIndex = focusedIndex { let item = state.items[focusedIndex] self.focusedId = item.id var allItems: [StoryContentItem] = [] + + var dayCounts: [DayIndex: Int] = [:] + var itemDayIndices: [Int32: (Int, DayIndex)] = [:] + for i in 0 ..< state.items.count { let stateItem = state.items[i] allItems.append(StoryContentItem( position: i, + dayCounters: nil, peerId: peer.id, storyItem: stateItem, entityFiles: extractItemEntityFiles(item: stateItem, allEntityFiles: state.allEntityFiles) )) + + let day = DayIndex(timestamp: stateItem.timestamp) + let dayCount: Int + if let current = dayCounts[day] { + dayCount = current + 1 + dayCounts[day] = dayCount + } else { + dayCount = 1 + dayCounts[day] = dayCount + } + itemDayIndices[stateItem.id] = (dayCount - 1, day) + } + + var dayCounters: StoryContentItem.DayCounters? + if let (offset, day) = itemDayIndices[item.id], let dayCount = dayCounts[day] { + dayCounters = StoryContentItem.DayCounters( + position: offset, + totalCount: dayCount + ) } stateValue = StoryContentContextState( @@ -1193,6 +1237,7 @@ public final class PeerStoryListContentContextImpl: StoryContentContext { additionalPeerData: additionalPeerData, item: StoryContentItem( position: focusedIndex, + dayCounters: dayCounters, peerId: peer.id, storyItem: item, entityFiles: extractItemEntityFiles(item: item, allEntityFiles: state.allEntityFiles) @@ -1381,6 +1426,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 09b760c7ea..7ebbebd468 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContainerScreen.swift +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContainerScreen.swift @@ -1022,33 +1022,51 @@ private final class StoryContainerScreenComponent: Component { ) } - self.contentUpdatedDisposable?.dispose() var update = false - self.contentUpdatedDisposable = (component.content.updated - |> deliverOnMainQueue).start(next: { [weak self] _ in - guard let self, let component = self.component else { + + let contentUpdated: (StoryContainerScreenComponent) -> Void = { [weak self] component in + guard let self else { return } - if update { - var focusedItemId: StoryId? - var isVideo = false - if let slice = component.content.stateValue?.slice { - focusedItemId = StoryId(peerId: slice.peer.id, id: slice.item.storyItem.id) - if case .file = slice.item.storyItem.media { - isVideo = true - } + + var focusedItemId: StoryId? + var isVideo = false + if let slice = component.content.stateValue?.slice { + focusedItemId = StoryId(peerId: slice.peer.id, id: slice.item.storyItem.id) + if case .file = slice.item.storyItem.media { + isVideo = true } - self.focusedItem.set(focusedItemId) - - self.contentWantsVolumeButtonMonitoring.set(isVideo) - + } + self.focusedItem.set(focusedItemId) + self.contentWantsVolumeButtonMonitoring.set(isVideo) + + if update { if component.content.stateValue?.slice == nil { self.environment?.controller()?.dismiss() } else { self.state?.updated(transition: .immediate) } + } else { + DispatchQueue.main.async { [weak self] in + guard let self else { + return + } + self.state?.updated(transition: .immediate) + } } + } + + self.contentUpdatedDisposable?.dispose() + self.contentUpdatedDisposable = (component.content.updated + |> deliverOnMainQueue).start(next: { [weak self] _ in + guard let self, let component = self.component else { + return + } + contentUpdated(component) }) + if component.content.stateValue?.slice != nil { + contentUpdated(component) + } update = true } @@ -1247,6 +1265,10 @@ private final class StoryContainerScreenComponent: Component { } else if slice.previousItemId != nil { component.content.navigate(navigation: .item(.previous)) } else if let environment = self.environment { + if let sourceIsAvatar = component.transitionIn?.sourceIsAvatar, sourceIsAvatar { + } else { + self.dismissWithoutTransitionOut = true + } environment.controller()?.dismiss() } @@ -1562,7 +1584,7 @@ public class StoryContainerScreen: ViewControllerComponentContainer { private var didAnimateIn: Bool = false private var isDismissed: Bool = false - private let focusedItemPromise = Promise(nil) + private let focusedItemPromise = Promise() public var focusedItem: Signal { return self.focusedItemPromise.get() } @@ -1753,4 +1775,3 @@ func allowedStoryReactions(context: AccountContext) -> Signal<[ReactionItem], No return result } } - diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContent.swift b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContent.swift index aff2bd9ba7..c53f7148ab 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContent.swift +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContent.swift @@ -19,13 +19,29 @@ public final class StoryContentItem: Equatable { case off } + public struct DayCounters: Equatable { + public var position: Int + public var totalCount: Int + + public init(position: Int, totalCount: Int) { + self.position = position + self.totalCount = totalCount + } + } + public final class SharedState { public init() { } } + public enum ProgressMode { + case play + case pause + case blurred + } + open class View: UIView { - open func setIsProgressPaused(_ isProgressPaused: Bool) { + open func setProgressMode(_ progressMode: ProgressMode) { } open func rewind() { @@ -78,17 +94,20 @@ public final class StoryContentItem: Equatable { } public let position: Int? + public let dayCounters: DayCounters? public let peerId: EnginePeer.Id? public let storyItem: EngineStoryItem public let entityFiles: [EngineMedia.Id: TelegramMediaFile] public init( position: Int?, + dayCounters: DayCounters?, peerId: EnginePeer.Id?, storyItem: EngineStoryItem, entityFiles: [EngineMedia.Id: TelegramMediaFile] ) { self.position = position + self.dayCounters = dayCounters self.peerId = peerId self.storyItem = storyItem self.entityFiles = entityFiles @@ -98,6 +117,9 @@ public final class StoryContentItem: Equatable { if lhs.position != rhs.position { return false } + if lhs.dayCounters != rhs.dayCounters { + return false + } if lhs.peerId != rhs.peerId { return false } diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContentCaptionComponent.swift b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContentCaptionComponent.swift index 8623c43dd7..f029700d2d 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContentCaptionComponent.swift +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContentCaptionComponent.swift @@ -99,23 +99,36 @@ final class StoryContentCaptionComponent: Component { self.verticalInset = verticalInset } } + + private final class ContentItem { + var textNode: TextNodeWithEntities? + var spoilerTextNode: TextNodeWithEntities? + var linkHighlightingNode: LinkHighlightingNode? + var dustNode: InvisibleInkDustNode? + + init() { + } + + func update() { + + } + } final class View: UIView, UIScrollViewDelegate { private let scrollViewContainer: UIView private let scrollView: UIScrollView + private let collapsedText: ContentItem + private let expandedText: ContentItem + private let scrollMaskContainer: UIView private let scrollFullMaskView: UIView private let scrollCenterMaskView: UIView private let scrollBottomMaskView: UIImageView + private let scrollTopMaskView: UIImageView private let shadowGradientLayer: SimpleGradientLayer private let shadowPlainLayer: SimpleLayer - - private var textNode: TextNodeWithEntities? - private var spoilerTextNode: TextNodeWithEntities? - private var linkHighlightingNode: LinkHighlightingNode? - private var dustNode: InvisibleInkDustNode? private var component: StoryContentCaptionComponent? private weak var state: EmptyComponentState? @@ -125,6 +138,8 @@ final class StoryContentCaptionComponent: Component { private var ignoreScrolling: Bool = false private var ignoreExternalState: Bool = false + private var isExpanded: Bool = false + override init(frame: CGRect) { self.shadowGradientLayer = SimpleGradientLayer() self.shadowPlainLayer = SimpleLayer() @@ -154,6 +169,15 @@ final class StoryContentCaptionComponent: Component { UIColor(white: 1.0, alpha: 0.0) ], locations: [0.0, 1.0])) self.scrollMaskContainer.addSubview(self.scrollBottomMaskView) + + self.scrollTopMaskView = UIImageView(image: generateGradientImage(size: CGSize(width: 8.0, height: 8.0), colors: [ + UIColor(white: 1.0, alpha: 0.0), + UIColor(white: 1.0, alpha: 1.0) + ], locations: [0.0, 1.0])) + self.scrollMaskContainer.addSubview(self.scrollTopMaskView) + + self.collapsedText = ContentItem() + self.expandedText = ContentItem() super.init(frame: frame) @@ -178,7 +202,8 @@ final class StoryContentCaptionComponent: Component { if !self.bounds.contains(point) { return nil } - if let textView = self.textNode?.textNode.view { + + if let textView = self.collapsedText.textNode?.textNode.view { let textLocalPoint = self.convert(point, to: textView) if textLocalPoint.y >= -7.0 { return textView @@ -190,7 +215,11 @@ final class StoryContentCaptionComponent: Component { @objc private func tapGesture(_ recognizer: UITapGestureRecognizer) { if case .ended = recognizer.state { - self.expand(transition: Transition(animation: .curve(duration: 0.4, curve: .spring))) + if self.isExpanded { + self.collapse(transition: Transition(animation: .curve(duration: 0.4, curve: .spring))) + } else { + self.expand(transition: Transition(animation: .curve(duration: 0.4, curve: .spring))) + } } } @@ -238,6 +267,8 @@ final class StoryContentCaptionComponent: Component { let isExpanded = expandFraction > 0.0 + self.isExpanded = isExpanded + if component.externalState.isExpanded != isExpanded { component.externalState.isExpanded = isExpanded @@ -248,16 +279,18 @@ final class StoryContentCaptionComponent: Component { } @objc func tapLongTapOrDoubleTapGesture(_ recognizer: TapLongTapOrDoubleTapGestureRecognizer) { + let contentItem = self.isExpanded ? self.expandedText : self.collapsedText + switch recognizer.state { case .ended: - if let (gesture, location) = recognizer.lastRecognizedGestureAndLocation, let component = self.component, let textNode = self.textNode { + if let (gesture, location) = recognizer.lastRecognizedGestureAndLocation, let component = self.component, let textNode = contentItem.textNode { let titleFrame = textNode.textNode.view.bounds if titleFrame.contains(location) { if let (index, attributes) = textNode.textNode.attributesAtPoint(CGPoint(x: location.x - titleFrame.minX, y: location.y - titleFrame.minY)) { let action: Action? - if case .tap = gesture, let _ = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.Spoiler)], !(self.dustNode?.isRevealed ?? true) { - let convertedPoint = recognizer.view?.convert(location, to: self.dustNode?.view) ?? location - self.dustNode?.revealAtLocation(convertedPoint) + if case .tap = gesture, let _ = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.Spoiler)], !(contentItem.dustNode?.isRevealed ?? true) { + let convertedPoint = recognizer.view?.convert(location, to: contentItem.dustNode?.view) ?? location + contentItem.dustNode?.revealAtLocation(convertedPoint) return } else if let url = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.URL)] as? String { var concealed = true @@ -278,19 +311,24 @@ final class StoryContentCaptionComponent: Component { } else { action = nil } - guard let action else { - return + if let action { + switch gesture { + case .tap: + component.action(action) + case .longTap: + component.longTapAction(action) + default: + return + } + } else { + if case .tap = gesture { + if self.isExpanded { + self.collapse(transition: Transition(animation: .curve(duration: 0.4, curve: .spring))) + } else { + self.expand(transition: Transition(animation: .curve(duration: 0.4, curve: .spring))) + } + } } - switch gesture { - case .tap: - component.action(action) - case .longTap: - component.longTapAction(action) - default: - return - } - self.expand(transition: Transition(animation: .curve(duration: 0.4, curve: .spring))) - return } } } @@ -300,7 +338,9 @@ final class StoryContentCaptionComponent: Component { } private func updateTouchesAtPoint(_ point: CGPoint?) { - guard let textNode = self.textNode else { + let contentItem = self.isExpanded ? self.expandedText : self.collapsedText + + guard let textNode = contentItem.textNode else { return } var rects: [CGRect]? @@ -329,20 +369,20 @@ final class StoryContentCaptionComponent: Component { } } - if let spoilerRects = spoilerRects, !spoilerRects.isEmpty, let dustNode = self.dustNode, !dustNode.isRevealed { + if let spoilerRects = spoilerRects, !spoilerRects.isEmpty, let dustNode = contentItem.dustNode, !dustNode.isRevealed { } else if let rects = rects { let linkHighlightingNode: LinkHighlightingNode - if let current = self.linkHighlightingNode { + if let current = contentItem.linkHighlightingNode { linkHighlightingNode = current } else { linkHighlightingNode = LinkHighlightingNode(color: UIColor(white: 1.0, alpha: 0.5)) - self.linkHighlightingNode = linkHighlightingNode + contentItem.linkHighlightingNode = linkHighlightingNode self.scrollView.insertSubview(linkHighlightingNode.view, belowSubview: textNode.textNode.view) } linkHighlightingNode.frame = textNode.textNode.view.frame linkHighlightingNode.updateRects(rects) - } else if let linkHighlightingNode = self.linkHighlightingNode { - self.linkHighlightingNode = nil + } else if let linkHighlightingNode = contentItem.linkHighlightingNode { + contentItem.linkHighlightingNode = nil linkHighlightingNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.18, removeOnCompletion: false, completion: { [weak linkHighlightingNode] _ in linkHighlightingNode?.removeFromSupernode() }) @@ -375,8 +415,21 @@ final class StoryContentCaptionComponent: Component { entityFiles: component.entityFiles ) - let makeLayout = TextNodeWithEntities.asyncLayout(self.textNode) - let textLayout = makeLayout(TextNodeLayoutArguments( + let truncationToken = NSMutableAttributedString() + truncationToken.append(NSAttributedString(string: "\u{2026} ", font: Font.regular(16.0), textColor: .white)) + truncationToken.append(NSAttributedString(string: "Show more", font: Font.semibold(16.0), textColor: .white)) + + //TODO:localize + let collapsedTextLayout = TextNodeWithEntities.asyncLayout(self.collapsedText.textNode)(TextNodeLayoutArguments( + attributedString: attributedText, + maximumNumberOfLines: 3, + truncationType: .end, + constrainedSize: textContainerSize, + textShadowColor: UIColor(white: 0.0, alpha: 0.25), + textShadowBlur: 4.0, + customTruncationToken: truncationToken + )) + let expandedTextLayout = TextNodeWithEntities.asyncLayout(self.expandedText.textNode)(TextNodeLayoutArguments( attributedString: attributedText, maximumNumberOfLines: 0, truncationType: .end, @@ -385,92 +438,177 @@ final class StoryContentCaptionComponent: Component { textShadowBlur: 4.0 )) - let makeSpoilerLayout = TextNodeWithEntities.asyncLayout(self.spoilerTextNode) - let spoilerTextLayoutAndApply: (TextNodeLayout, (TextNodeWithEntities.Arguments?) -> TextNodeWithEntities)? - if !textLayout.0.spoilers.isEmpty { - spoilerTextLayoutAndApply = makeSpoilerLayout(TextNodeLayoutArguments(attributedString: attributedText, maximumNumberOfLines: 0, truncationType: .end, constrainedSize: textContainerSize, textShadowColor: UIColor(white: 0.0, alpha: 0.25), textShadowBlur: 4.0, displaySpoilers: true, displayEmbeddedItemsUnderSpoilers: true)) + let collapsedSpoilerTextLayoutAndApply: (TextNodeLayout, (TextNodeWithEntities.Arguments?) -> TextNodeWithEntities)? + if !collapsedTextLayout.0.spoilers.isEmpty { + collapsedSpoilerTextLayoutAndApply = TextNodeWithEntities.asyncLayout(self.collapsedText.spoilerTextNode)(TextNodeLayoutArguments(attributedString: attributedText, maximumNumberOfLines: 0, truncationType: .end, constrainedSize: textContainerSize, textShadowColor: UIColor(white: 0.0, alpha: 0.25), textShadowBlur: 4.0, displaySpoilers: true, displayEmbeddedItemsUnderSpoilers: true)) } else { - spoilerTextLayoutAndApply = nil + collapsedSpoilerTextLayoutAndApply = nil } - let maxHeight: CGFloat = 50.0 - let visibleTextHeight = min(maxHeight, textLayout.0.size.height) - let textOverflowHeight: CGFloat = textLayout.0.size.height - visibleTextHeight + let expandedSpoilerTextLayoutAndApply: (TextNodeLayout, (TextNodeWithEntities.Arguments?) -> TextNodeWithEntities)? + if !expandedTextLayout.0.spoilers.isEmpty { + expandedSpoilerTextLayoutAndApply = TextNodeWithEntities.asyncLayout(self.expandedText.spoilerTextNode)(TextNodeLayoutArguments(attributedString: attributedText, maximumNumberOfLines: 0, truncationType: .end, constrainedSize: textContainerSize, textShadowColor: UIColor(white: 0.0, alpha: 0.25), textShadowBlur: 4.0, displaySpoilers: true, displayEmbeddedItemsUnderSpoilers: true)) + } else { + expandedSpoilerTextLayoutAndApply = nil + } + + let visibleTextHeight = collapsedTextLayout.0.size.height + let textOverflowHeight: CGFloat = expandedTextLayout.0.size.height - visibleTextHeight let scrollContentSize = CGSize(width: availableSize.width, height: availableSize.height + textOverflowHeight) - let textNode = textLayout.1(TextNodeWithEntities.Arguments( - context: component.context, - cache: component.context.animationCache, - renderer: component.context.animationRenderer, - placeholderColor: UIColor(white: 0.2, alpha: 1.0), - attemptSynchronous: true - )) - if self.textNode !== textNode { - self.textNode?.textNode.view.removeFromSuperview() - - self.textNode = textNode - if textNode.textNode.view.superview == nil { - self.scrollView.addSubview(textNode.textNode.view) - - let recognizer = TapLongTapOrDoubleTapGestureRecognizer(target: self, action: #selector(self.tapLongTapOrDoubleTapGesture(_:))) - recognizer.tapActionAtPoint = { point in - return .waitForSingleTap - } - recognizer.highlight = { [weak self] point in - guard let self else { - return - } - self.updateTouchesAtPoint(point) - } - textNode.textNode.view.addGestureRecognizer(recognizer) - } - - textNode.visibilityRect = CGRect(origin: CGPoint(), size: CGSize(width: 100000.0, height: 100000.0)) - } - - let textFrame = CGRect(origin: CGPoint(x: sideInset, y: availableSize.height - visibleTextHeight - verticalInset), size: textLayout.0.size) - textNode.textNode.frame = textFrame - - if let (_, spoilerTextApply) = spoilerTextLayoutAndApply { - let spoilerTextNode = spoilerTextApply(TextNodeWithEntities.Arguments( + do { + let collapsedTextNode = collapsedTextLayout.1(TextNodeWithEntities.Arguments( context: component.context, cache: component.context.animationCache, renderer: component.context.animationRenderer, placeholderColor: UIColor(white: 0.2, alpha: 1.0), attemptSynchronous: true )) - if self.spoilerTextNode == nil { - spoilerTextNode.textNode.alpha = 0.0 - spoilerTextNode.textNode.isUserInteractionEnabled = false - spoilerTextNode.textNode.contentMode = .topLeft - spoilerTextNode.textNode.contentsScale = UIScreenScale - spoilerTextNode.textNode.displaysAsynchronously = false - self.scrollView.insertSubview(spoilerTextNode.textNode.view, belowSubview: textNode.textNode.view) + if self.collapsedText.textNode !== collapsedTextNode { + self.collapsedText.textNode?.textNode.view.removeFromSuperview() - spoilerTextNode.visibilityRect = CGRect(origin: CGPoint(), size: CGSize(width: 100000.0, height: 100000.0)) + self.collapsedText.textNode = collapsedTextNode + if collapsedTextNode.textNode.view.superview == nil { + self.scrollView.addSubview(collapsedTextNode.textNode.view) + + let recognizer = TapLongTapOrDoubleTapGestureRecognizer(target: self, action: #selector(self.tapLongTapOrDoubleTapGesture(_:))) + recognizer.tapActionAtPoint = { point in + return .waitForSingleTap + } + recognizer.highlight = { [weak self] point in + guard let self else { + return + } + self.updateTouchesAtPoint(point) + } + collapsedTextNode.textNode.view.addGestureRecognizer(recognizer) + } - self.spoilerTextNode = spoilerTextNode + collapsedTextNode.visibilityRect = CGRect(origin: CGPoint(), size: CGSize(width: 100000.0, height: 100000.0)) } - self.spoilerTextNode?.textNode.frame = textFrame + let collapsedTextFrame = CGRect(origin: CGPoint(x: sideInset, y: availableSize.height - visibleTextHeight - verticalInset), size: collapsedTextLayout.0.size) + collapsedTextNode.textNode.frame = collapsedTextFrame - let dustNode: InvisibleInkDustNode - if let current = self.dustNode { - dustNode = current - } else { - dustNode = InvisibleInkDustNode(textNode: spoilerTextNode.textNode, enableAnimations: component.context.sharedContext.energyUsageSettings.fullTranslucency) - self.dustNode = dustNode - self.scrollView.insertSubview(dustNode.view, aboveSubview: spoilerTextNode.textNode.view) + if let (_, collapsedSpoilerTextApply) = collapsedSpoilerTextLayoutAndApply { + let collapsedSpoilerTextNode = collapsedSpoilerTextApply(TextNodeWithEntities.Arguments( + context: component.context, + cache: component.context.animationCache, + renderer: component.context.animationRenderer, + placeholderColor: UIColor(white: 0.2, alpha: 1.0), + attemptSynchronous: true + )) + if self.collapsedText.spoilerTextNode == nil { + collapsedSpoilerTextNode.textNode.alpha = 0.0 + collapsedSpoilerTextNode.textNode.isUserInteractionEnabled = false + collapsedSpoilerTextNode.textNode.contentMode = .topLeft + collapsedSpoilerTextNode.textNode.contentsScale = UIScreenScale + collapsedSpoilerTextNode.textNode.displaysAsynchronously = false + self.scrollView.insertSubview(collapsedSpoilerTextNode.textNode.view, belowSubview: collapsedTextNode.textNode.view) + + collapsedSpoilerTextNode.visibilityRect = CGRect(origin: CGPoint(), size: CGSize(width: 100000.0, height: 100000.0)) + + self.collapsedText.spoilerTextNode = collapsedSpoilerTextNode + } + + self.collapsedText.spoilerTextNode?.textNode.frame = collapsedTextFrame + + let collapsedDustNode: InvisibleInkDustNode + if let current = self.collapsedText.dustNode { + collapsedDustNode = current + } else { + collapsedDustNode = InvisibleInkDustNode(textNode: collapsedSpoilerTextNode.textNode, enableAnimations: component.context.sharedContext.energyUsageSettings.fullTranslucency) + self.collapsedText.dustNode = collapsedDustNode + self.scrollView.insertSubview(collapsedDustNode.view, aboveSubview: collapsedSpoilerTextNode.textNode.view) + } + collapsedDustNode.frame = collapsedTextFrame.insetBy(dx: -3.0, dy: -3.0).offsetBy(dx: 0.0, dy: 0.0) + collapsedDustNode.update(size: collapsedDustNode.frame.size, color: .white, textColor: .white, rects: collapsedTextLayout.0.spoilers.map { $0.1.offsetBy(dx: 3.0, dy: 3.0).insetBy(dx: 1.0, dy: 1.0) }, wordRects: collapsedTextLayout.0.spoilerWords.map { $0.1.offsetBy(dx: 3.0, dy: 3.0).insetBy(dx: 1.0, dy: 1.0) }) + } else if let collapsedSpoilerTextNode = self.collapsedText.spoilerTextNode { + self.collapsedText.spoilerTextNode = nil + collapsedSpoilerTextNode.textNode.removeFromSupernode() + + if let collapsedDustNode = self.collapsedText.dustNode { + self.collapsedText.dustNode = nil + collapsedDustNode.view.removeFromSuperview() + } + } + } + + do { + let expandedTextNode = expandedTextLayout.1(TextNodeWithEntities.Arguments( + context: component.context, + cache: component.context.animationCache, + renderer: component.context.animationRenderer, + placeholderColor: UIColor(white: 0.2, alpha: 1.0), + attemptSynchronous: true + )) + if self.expandedText.textNode !== expandedTextNode { + self.expandedText.textNode?.textNode.view.removeFromSuperview() + + self.expandedText.textNode = expandedTextNode + if expandedTextNode.textNode.view.superview == nil { + self.scrollView.addSubview(expandedTextNode.textNode.view) + + let recognizer = TapLongTapOrDoubleTapGestureRecognizer(target: self, action: #selector(self.tapLongTapOrDoubleTapGesture(_:))) + recognizer.tapActionAtPoint = { point in + return .waitForSingleTap + } + recognizer.highlight = { [weak self] point in + guard let self else { + return + } + self.updateTouchesAtPoint(point) + } + expandedTextNode.textNode.view.addGestureRecognizer(recognizer) + } + + expandedTextNode.visibilityRect = CGRect(origin: CGPoint(), size: CGSize(width: 100000.0, height: 100000.0)) } - dustNode.frame = textFrame.insetBy(dx: -3.0, dy: -3.0).offsetBy(dx: 0.0, dy: 0.0) - dustNode.update(size: dustNode.frame.size, color: .white, textColor: .white, rects: textLayout.0.spoilers.map { $0.1.offsetBy(dx: 3.0, dy: 3.0).insetBy(dx: 1.0, dy: 1.0) }, wordRects: textLayout.0.spoilerWords.map { $0.1.offsetBy(dx: 3.0, dy: 3.0).insetBy(dx: 1.0, dy: 1.0) }) - } else if let spoilerTextNode = self.spoilerTextNode { - self.spoilerTextNode = nil - spoilerTextNode.textNode.removeFromSupernode() - if let dustNode = self.dustNode { - self.dustNode = nil - dustNode.removeFromSupernode() + let expandedTextFrame = CGRect(origin: CGPoint(x: sideInset, y: availableSize.height - visibleTextHeight - verticalInset), size: expandedTextLayout.0.size) + expandedTextNode.textNode.frame = expandedTextFrame + + if let (_, expandedSpoilerTextApply) = expandedSpoilerTextLayoutAndApply { + let expandedSpoilerTextNode = expandedSpoilerTextApply(TextNodeWithEntities.Arguments( + context: component.context, + cache: component.context.animationCache, + renderer: component.context.animationRenderer, + placeholderColor: UIColor(white: 0.2, alpha: 1.0), + attemptSynchronous: true + )) + if self.expandedText.spoilerTextNode == nil { + expandedSpoilerTextNode.textNode.alpha = 0.0 + expandedSpoilerTextNode.textNode.isUserInteractionEnabled = false + expandedSpoilerTextNode.textNode.contentMode = .topLeft + expandedSpoilerTextNode.textNode.contentsScale = UIScreenScale + expandedSpoilerTextNode.textNode.displaysAsynchronously = false + self.scrollView.insertSubview(expandedSpoilerTextNode.textNode.view, belowSubview: expandedTextNode.textNode.view) + + expandedSpoilerTextNode.visibilityRect = CGRect(origin: CGPoint(), size: CGSize(width: 100000.0, height: 100000.0)) + + self.expandedText.spoilerTextNode = expandedSpoilerTextNode + } + + self.expandedText.spoilerTextNode?.textNode.frame = expandedTextFrame + + let expandedDustNode: InvisibleInkDustNode + if let current = self.expandedText.dustNode { + expandedDustNode = current + } else { + expandedDustNode = InvisibleInkDustNode(textNode: expandedSpoilerTextNode.textNode, enableAnimations: component.context.sharedContext.energyUsageSettings.fullTranslucency) + self.expandedText.dustNode = expandedDustNode + self.scrollView.insertSubview(expandedDustNode.view, aboveSubview: expandedSpoilerTextNode.textNode.view) + } + expandedDustNode.frame = expandedTextFrame.insetBy(dx: -3.0, dy: -3.0).offsetBy(dx: 0.0, dy: 0.0) + expandedDustNode.update(size: expandedDustNode.frame.size, color: .white, textColor: .white, rects: expandedTextLayout.0.spoilers.map { $0.1.offsetBy(dx: 3.0, dy: 3.0).insetBy(dx: 1.0, dy: 1.0) }, wordRects: expandedTextLayout.0.spoilerWords.map { $0.1.offsetBy(dx: 3.0, dy: 3.0).insetBy(dx: 1.0, dy: 1.0) }) + } else if let expandedSpoilerTextNode = self.expandedText.spoilerTextNode { + self.expandedText.spoilerTextNode = nil + expandedSpoilerTextNode.textNode.removeFromSupernode() + + if let expandedDustNode = self.expandedText.dustNode { + self.expandedText.dustNode = nil + expandedDustNode.view.removeFromSuperview() + } } } @@ -515,12 +653,41 @@ final class StoryContentCaptionComponent: Component { let gradientEdgeHeight: CGFloat = 18.0 - transition.setFrame(view: self.scrollFullMaskView, frame: CGRect(origin: CGPoint(), size: CGSize(width: availableSize.width, height: availableSize.height))) - transition.setFrame(view: self.scrollCenterMaskView, frame: CGRect(origin: CGPoint(), size: CGSize(width: availableSize.width, height: availableSize.height - gradientEdgeHeight))) + transition.setFrame(view: self.scrollFullMaskView, frame: CGRect(origin: CGPoint(x: 0.0, y: gradientEdgeHeight), size: CGSize(width: availableSize.width, height: availableSize.height - gradientEdgeHeight))) + transition.setFrame(view: self.scrollCenterMaskView, frame: CGRect(origin: CGPoint(x: 0.0, y: gradientEdgeHeight), size: CGSize(width: availableSize.width, height: availableSize.height - gradientEdgeHeight * 2.0))) transition.setFrame(view: self.scrollBottomMaskView, frame: CGRect(origin: CGPoint(x: 0.0, y: availableSize.height - gradientEdgeHeight), size: CGSize(width: availableSize.width, height: gradientEdgeHeight))) + transition.setFrame(view: self.scrollTopMaskView, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: availableSize.width, height: gradientEdgeHeight))) self.ignoreExternalState = false + var isExpandedTransition = transition + if transition.animation.isImmediate, let hint = transition.userData(TransitionHint.self), case .isExpandedUpdated = hint.kind { + isExpandedTransition = transition.withAnimation(.curve(duration: 0.25, curve: .easeInOut)) + } + + if let textNode = self.collapsedText.textNode { + isExpandedTransition.setAlpha(view: textNode.textNode.view, alpha: self.isExpanded ? 0.0 : 1.0) + } + if let spoilerTextNode = self.collapsedText.spoilerTextNode { + isExpandedTransition.setAlpha(view: spoilerTextNode.textNode.view, alpha: self.isExpanded ? 0.0 : 1.0) + } + if let dustNode = self.collapsedText.dustNode { + isExpandedTransition.setAlpha(view: dustNode.view, alpha: self.isExpanded ? 0.0 : 1.0) + } + + if let textNode = self.expandedText.textNode { + isExpandedTransition.setAlpha(view: textNode.textNode.view, alpha: !self.isExpanded ? 0.0 : 1.0) + } + if let spoilerTextNode = self.expandedText.spoilerTextNode { + isExpandedTransition.setAlpha(view: spoilerTextNode.textNode.view, alpha: !self.isExpanded ? 0.0 : 1.0) + } + if let dustNode = self.expandedText.dustNode { + isExpandedTransition.setAlpha(view: dustNode.view, alpha: !self.isExpanded ? 0.0 : 1.0) + } + + isExpandedTransition.setAlpha(layer: self.shadowPlainLayer, alpha: self.isExpanded ? 0.0 : 1.0) + isExpandedTransition.setAlpha(layer: self.shadowGradientLayer, alpha: self.isExpanded ? 0.0 : 1.0) + return availableSize } } diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemContentComponent.swift b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemContentComponent.swift index 666c12deff..2a15c765bc 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemContentComponent.swift +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemContentComponent.swift @@ -67,7 +67,7 @@ final class StoryItemContentComponent: Component { private var unsupportedText: ComponentView? private var unsupportedButton: ComponentView? - private var isProgressPaused: Bool = true + private var progressMode: StoryContentItem.ProgressMode = .pause private var currentProgressTimer: SwiftSignalKit.Timer? private var currentProgressTimerValue: Double = 0.0 private var videoProgressDisposable: Disposable? @@ -96,7 +96,7 @@ final class StoryItemContentComponent: Component { guard let self else { return } - self.updateIsProgressPaused(update: true) + self.updateProgressMode(update: true) } } @@ -119,7 +119,7 @@ final class StoryItemContentComponent: Component { if self.videoNode != nil { return } - if self.isProgressPaused { + if case .pause = self.progressMode { return } @@ -169,7 +169,17 @@ final class StoryItemContentComponent: Component { guard let self else { return } - self.environment?.presentationProgressUpdated(1.0, false, true) + + if self.progressMode == .blurred { + self.rewind() + if let videoNode = self.videoNode { + if self.contentLoaded { + videoNode.play() + } + } + } else { + self.environment?.presentationProgressUpdated(1.0, false, true) + } } videoNode.ownsContentNodeUpdated = { [weak self] value in guard let self, let component = self.component else { @@ -206,10 +216,10 @@ final class StoryItemContentComponent: Component { } } - override func setIsProgressPaused(_ isProgressPaused: Bool) { - if self.isProgressPaused != isProgressPaused { - self.isProgressPaused = isProgressPaused - self.updateIsProgressPaused(update: true) + override func setProgressMode(_ progressMode: StoryContentItem.ProgressMode) { + if self.progressMode != progressMode { + self.progressMode = progressMode + self.updateProgressMode(update: true) } } @@ -239,9 +249,9 @@ final class StoryItemContentComponent: Component { } } - private func updateIsProgressPaused(update: Bool) { + private func updateProgressMode(update: Bool) { if let videoNode = self.videoNode { - var canPlay = !self.isProgressPaused && self.contentLoaded && self.hierarchyTrackingLayer.isInHierarchy + var canPlay = self.progressMode != .pause && self.contentLoaded && self.hierarchyTrackingLayer.isInHierarchy if let component = self.component { if component.item.isPending { canPlay = false @@ -261,7 +271,7 @@ final class StoryItemContentComponent: Component { } private func updateProgressTimer() { - var needsTimer = !self.isProgressPaused && self.contentLoaded && self.hierarchyTrackingLayer.isInHierarchy + var needsTimer = self.progressMode != .pause && self.contentLoaded && self.hierarchyTrackingLayer.isInHierarchy if let component = self.component { if component.item.isPending { needsTimer = false @@ -274,7 +284,7 @@ final class StoryItemContentComponent: Component { timeout: 1.0 / 60.0, repeat: true, completion: { [weak self] in - guard let self, !self.isProgressPaused, self.contentLoaded, self.hierarchyTrackingLayer.isInHierarchy else { + guard let self, self.progressMode != .pause, self.contentLoaded, self.hierarchyTrackingLayer.isInHierarchy else { return } @@ -288,6 +298,10 @@ final class StoryItemContentComponent: Component { } } + if self.progressMode != .play { + return + } + #if DEBUG && true let currentProgressTimerLimit: Double = 10.0 #else @@ -626,7 +640,7 @@ final class StoryItemContentComponent: Component { self.backgroundColor = UIColor(rgb: 0x181818) } - self.updateIsProgressPaused(update: false) + self.updateProgressMode(update: false) if reloadMedia && synchronousLoad { print("\(CFAbsoluteTimeGetCurrent()) Synchronous: \((CFAbsoluteTimeGetCurrent() - startTime) * 1000.0) ms") diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift index 6b7276baed..1f4d55dee3 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift @@ -877,46 +877,50 @@ public final class StoryItemSetContainerComponent: Component { } private func isProgressPaused() -> Bool { + return self.itemProgressMode() == .pause + } + + private func itemProgressMode() -> StoryContentItem.ProgressMode { guard let component = self.component else { - return false + return .pause } if component.pinchState != nil { - return true + return .pause } if self.inputPanelExternalState.isEditing || component.isProgressPaused || self.sendMessageContext.actionSheet != nil || self.sendMessageContext.isViewingAttachedStickers || self.contextController != nil || self.sendMessageContext.audioRecorderValue != nil || self.sendMessageContext.videoRecorderValue != nil || self.displayViewList { - return true + return .pause } if let reactionContextNode = self.reactionContextNode, reactionContextNode.isReactionSearchActive { - return true + return .pause } if self.privacyController != nil { - return true + return .pause } if self.isReporting { - return true + return .pause } if self.isEditingStory { - return true + return .pause } if self.sendMessageContext.attachmentController != nil { - return true + return .pause } if self.sendMessageContext.shareController != nil { - return true + return .pause } if self.sendMessageContext.tooltipScreen != nil { - return true + return .pause } if let navigationController = component.controller()?.navigationController as? NavigationController { let topViewController = navigationController.topViewController if !(topViewController is StoryContainerScreen) && !(topViewController is MediaEditorScreen) && !(topViewController is ShareWithPeersScreen) && !(topViewController is AttachmentController) { - return true + return .pause } } if let captionItem = self.captionItem, captionItem.externalState.isExpanded { - return true + return .blurred } - return false + return .play } private func updateScrolling(transition: Transition) { @@ -1094,13 +1098,13 @@ public final class StoryItemSetContainerComponent: Component { itemTransition.setCornerRadius(layer: visibleItem.contentContainerView.layer, cornerRadius: 12.0 * (1.0 / itemScale)) itemTransition.setAlpha(view: visibleItem.contentContainerView, alpha: 1.0 * (1.0 - fractionDistanceToCenter) + 0.75 * fractionDistanceToCenter) - var itemProgressPaused = self.isProgressPaused() + var itemProgressMode = self.itemProgressMode() if index != centralIndex { - itemProgressPaused = true + itemProgressMode = .pause } if let view = view as? StoryContentItem.View { - view.setIsProgressPaused(itemProgressPaused) + view.setProgressMode(itemProgressMode) } } } @@ -1121,7 +1125,7 @@ public final class StoryItemSetContainerComponent: Component { } func updateIsProgressPaused() { - let isProgressPaused = self.isProgressPaused() + let progressMode = self.itemProgressMode() var centralId: Int32? if let component = self.component { centralId = component.slice.item.storyItem.id @@ -1130,7 +1134,11 @@ public final class StoryItemSetContainerComponent: Component { for (id, visibleItem) in self.visibleItems { if let view = visibleItem.view.view { if let view = view as? StoryContentItem.View { - view.setIsProgressPaused(isProgressPaused || id != centralId) + var itemMode = progressMode + if id != centralId { + itemMode = .pause + } + view.setProgressMode(itemMode) } } } @@ -1766,7 +1774,7 @@ public final class StoryItemSetContainerComponent: Component { self.sendMessageContext.videoRecorderValue?.dismissVideo() self.sendMessageContext.discardMediaRecordingPreview(view: self) }, - attachmentAction: { [weak self] in + attachmentAction: component.slice.peer.isService ? nil : { [weak self] in guard let self else { return } @@ -2078,11 +2086,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: {}, @@ -2455,8 +2463,22 @@ public final class StoryItemSetContainerComponent: Component { } var currentCenterInfoItem: InfoItem? - if focusedItem != nil { - let centerInfoComponent = AnyComponent(StoryAuthorInfoComponent(context: component.context, peer: component.slice.peer, timestamp: component.slice.item.storyItem.timestamp, isEdited: component.slice.item.storyItem.isEdited)) + if let focusedItem { + var counters: StoryAuthorInfoComponent.Counters? + if focusedItem.dayCounters != nil, let position = focusedItem.position { + counters = StoryAuthorInfoComponent.Counters( + position: position, + totalCount: component.slice.totalCount + ) + } + + let centerInfoComponent = AnyComponent(StoryAuthorInfoComponent( + context: component.context, + peer: component.slice.peer, + timestamp: component.slice.item.storyItem.timestamp, + counters: counters, + isEdited: component.slice.item.storyItem.isEdited + )) if let centerInfoItem = self.centerInfoItem, centerInfoItem.component == centerInfoComponent { currentCenterInfoItem = centerInfoItem } else { @@ -2641,7 +2663,7 @@ public final class StoryItemSetContainerComponent: Component { } )), environment: {}, - containerSize: CGSize(width: availableSize.width, height: contentFrame.height) + containerSize: CGSize(width: availableSize.width, height: contentFrame.height - 60.0) ) captionItem.view.parentState = state let captionFrame = CGRect(origin: CGPoint(x: 0.0, y: contentFrame.height - captionSize.height), size: captionSize) @@ -2997,11 +3019,18 @@ public final class StoryItemSetContainerComponent: Component { let navigationStripSideInset: CGFloat = 8.0 let navigationStripTopInset: CGFloat = 8.0 + var index = max(0, min(index, component.slice.totalCount - 1)) + var count = component.slice.totalCount + if let dayCounters = focusedItem.dayCounters { + index = dayCounters.position + count = dayCounters.totalCount + } + let _ = self.navigationStrip.update( transition: transition, component: AnyComponent(MediaNavigationStripComponent( - index: max(0, min(index, component.slice.totalCount - 1)), - count: component.slice.totalCount + index: index, + count: count )), environment: { MediaNavigationStripComponent.EnvironmentType( @@ -3233,75 +3262,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() { @@ -3655,7 +3624,7 @@ public final class StoryItemSetContainerComponent: Component { self.requestSave() }))) - if component.slice.item.storyItem.isPublic && (component.slice.peer.addressName != nil || !component.slice.peer._asPeer().usernames.isEmpty) { + if component.slice.item.storyItem.isPublic && (component.slice.peer.addressName != nil || !component.slice.peer._asPeer().usernames.isEmpty) && component.slice.item.storyItem.expirationTimestamp > Int32(Date().timeIntervalSince1970) { items.append(.action(ContextMenuActionItem(text: "Copy Link", icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Link"), color: theme.contextMenu.primaryColor) }, action: { [weak self] _, a in @@ -3787,6 +3756,46 @@ public final class StoryItemSetContainerComponent: Component { let presentationData = component.context.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: component.theme) var items: [ContextMenuItem] = [] + + if component.slice.item.storyItem.isPublic && (component.slice.peer.addressName != nil || !component.slice.peer._asPeer().usernames.isEmpty) { + items.append(.action(ContextMenuActionItem(text: "Copy Link", icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Link"), color: theme.contextMenu.primaryColor) + }, action: { [weak self] _, a in + a(.default) + + guard let self, let component = self.component else { + return + } + + let _ = (component.context.engine.messages.exportStoryLink(peerId: component.slice.peer.id, id: component.slice.item.storyItem.id) + |> deliverOnMainQueue).start(next: { [weak self] link in + guard let self, let component = self.component else { + return + } + if let link { + UIPasteboard.general.string = link + + component.presentController(UndoOverlayController( + presentationData: presentationData, + content: .linkCopied(text: "Link copied."), + elevatedLayout: false, + animateInAsReplacement: false, + action: { _ in return false } + ), nil) + } + }) + }))) + items.append(.action(ContextMenuActionItem(text: "Share", icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Forward"), color: theme.contextMenu.primaryColor) + }, action: { [weak self] _, a in + a(.default) + + guard let self else { + return + } + self.sendMessageContext.performShareAction(view: self) + }))) + } let isMuted = resolvedAreStoriesMuted(globalSettings: globalSettings._asGlobalNotificationSettings(), peer: component.slice.peer._asPeer(), peerSettings: settings._asNotificationSettings()) @@ -3839,7 +3848,7 @@ public final class StoryItemSetContainerComponent: Component { isHidden = storiesHidden } - items.append(.action(ContextMenuActionItem(text: isHidden ? "Unhide \(component.slice.peer.compactDisplayTitle)" : "Hide \(component.slice.peer.compactDisplayTitle)", icon: { theme in + items.append(.action(ContextMenuActionItem(text: isHidden ? "Unarchive" : "Archive", icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: isHidden ? "Chat/Context Menu/MoveToChats" : "Chat/Context Menu/MoveToContacts"), color: theme.contextMenu.primaryColor) }, action: { [weak self] _, a in a(.default) @@ -3879,6 +3888,25 @@ public final class StoryItemSetContainerComponent: Component { component.controller()?.present(tooltipScreen, in: .current) }))) + #if DEBUG + let saveText: String + if case .file = component.slice.item.storyItem.media { + saveText = "Save Video" + } else { + saveText = "Save Image" + } + items.append(.action(ContextMenuActionItem(text: saveText, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Save"), color: theme.contextMenu.primaryColor) + }, action: { [weak self] _, a in + a(.default) + + guard let self else { + return + } + self.requestSave() + }))) + #endif + items.append(.action(ContextMenuActionItem(text: "Report", icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Report"), color: theme.contextMenu.primaryColor) }, action: { [weak self] c, a in diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerViewSendMessage.swift b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerViewSendMessage.swift index df3ca81399..12ff9b427a 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerViewSendMessage.swift +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerViewSendMessage.swift @@ -2462,7 +2462,7 @@ final class StoryItemSetContainerSendMessage { }) } - func openPeerMention(view: StoryItemSetContainerComponent.View, name: String, navigation: ChatControllerInteractionNavigateToPeer = .default, sourceMessageId: MessageId? = nil) { + func openPeerMention(view: StoryItemSetContainerComponent.View, name: String, navigation: ChatControllerInteractionNavigateToPeer = .info, sourceMessageId: MessageId? = nil) { guard let component = view.component, let parentController = component.controller() else { return } 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/Components/Stories/StoryPeerListComponent/Sources/StoryPeerListItemComponent.swift b/submodules/TelegramUI/Components/Stories/StoryPeerListComponent/Sources/StoryPeerListItemComponent.swift index 7254df7504..69c720d610 100644 --- a/submodules/TelegramUI/Components/Stories/StoryPeerListComponent/Sources/StoryPeerListItemComponent.swift +++ b/submodules/TelegramUI/Components/Stories/StoryPeerListComponent/Sources/StoryPeerListItemComponent.swift @@ -13,6 +13,7 @@ import ContextUI import AsyncDisplayKit import StoryContainerScreen import MultilineTextComponent +import HierarchyTrackingLayer 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) @@ -124,7 +125,7 @@ private func calculateMergingCircleShape(center: CGPoint, leftCenter: CGPoint?, return path } -private final class StoryProgressLayer: SimpleLayer { +private final class StoryProgressLayer: HierarchyTrackingLayer { enum Value: Equatable { case indefinite case progress(Float) @@ -147,12 +148,10 @@ private final class StoryProgressLayer: SimpleLayer { self.uploadProgressLayer.fillColor = nil self.uploadProgressLayer.strokeColor = UIColor.white.cgColor - self.uploadProgressLayer.lineWidth = 2.0 self.uploadProgressLayer.lineCap = .round self.indefiniteDashLayer.fillColor = nil self.indefiniteDashLayer.strokeColor = UIColor.white.cgColor - self.indefiniteDashLayer.lineWidth = 2.0 self.indefiniteDashLayer.lineCap = .round self.indefiniteDashLayer.lineJoin = .round self.indefiniteDashLayer.strokeEnd = 0.0333 @@ -164,6 +163,13 @@ private final class StoryProgressLayer: SimpleLayer { 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) { @@ -180,35 +186,12 @@ private final class StoryProgressLayer: SimpleLayer { self.uploadProgressLayer.path = nil } - func update(size: CGSize, lineWidth: CGFloat, value: Value, transition: Transition) { - let params = Params( - size: size, - lineWidth: lineWidth, - value: value - ) - if self.currentParams == params { + func updateAnimations(transition: Transition) { + guard let params = self.currentParams else { return } - self.currentParams = params - let lineWidth: CGFloat = 2.0 - let bounds = CGRect(origin: .zero, size: size) - if self.uploadProgressLayer.path == nil { - let path = CGMutablePath() - path.addEllipse(in: CGRect(origin: CGPoint(x: lineWidth * 0.5, y: lineWidth * 0.5), size: CGSize(width: size.width - lineWidth, height: size.height - lineWidth))) - self.uploadProgressLayer.path = path - self.uploadProgressLayer.frame = bounds - } - - if self.indefiniteDashLayer.path == nil { - let path = CGMutablePath() - path.addEllipse(in: CGRect(origin: CGPoint(x: lineWidth * 0.5, y: lineWidth * 0.5), size: CGSize(width: size.width - lineWidth, height: size.height - lineWidth))) - self.indefiniteDashLayer.path = path - self.indefiniteReplicatorLayer.frame = bounds - self.indefiniteDashLayer.frame = bounds - } - - switch value { + switch params.value { case let .progress(progress): if self.indefiniteReplicatorLayer.superlayer != nil { self.indefiniteReplicatorLayer.removeFromSuperlayer() @@ -258,6 +241,39 @@ private final class StoryProgressLayer: SimpleLayer { } } } + + func update(size: CGSize, lineWidth: CGFloat, radius: CGFloat, value: Value, transition: Transition) { + let params = Params( + size: size, + lineWidth: lineWidth, + value: value + ) + if self.currentParams == params { + return + } + self.currentParams = params + + self.uploadProgressLayer.lineWidth = lineWidth + self.indefiniteDashLayer.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) + } } private var sharedAvatarBackgroundImage: UIImage? @@ -845,9 +861,9 @@ public final class StoryPeerListItemComponent: Component { } else { progressTransition = .easeInOut(duration: 0.3) } - progressLayer.update(size: progressFrame.size, lineWidth: indicatorLineUnseenWidth, value: .progress(progress), transition: progressTransition) + progressLayer.update(size: progressFrame.size, lineWidth: indicatorLineUnseenWidth, radius: indicatorRadius - indicatorLineUnseenWidth * 0.5, value: .progress(progress), transition: progressTransition) case .loading: - progressLayer.update(size: progressFrame.size, lineWidth: indicatorLineUnseenWidth, value: .indefinite, transition: transition) + progressLayer.update(size: progressFrame.size, lineWidth: indicatorLineUnseenWidth, radius: indicatorRadius - indicatorLineUnseenWidth * 0.5, value: .indefinite, transition: transition) } self.indicatorShapeSeenLayer.opacity = 0.0 self.indicatorShapeUnseenLayer.opacity = 0.0 diff --git a/submodules/TelegramUI/Components/Stories/StorySetIndicatorComponent/Sources/StorySetIndicatorComponent.swift b/submodules/TelegramUI/Components/Stories/StorySetIndicatorComponent/Sources/StorySetIndicatorComponent.swift index dae56a4db9..9534a88132 100644 --- a/submodules/TelegramUI/Components/Stories/StorySetIndicatorComponent/Sources/StorySetIndicatorComponent.swift +++ b/submodules/TelegramUI/Components/Stories/StorySetIndicatorComponent/Sources/StorySetIndicatorComponent.swift @@ -92,6 +92,7 @@ public final class StorySetIndicatorComponent: Component { public let peer: EnginePeer public let items: [EngineStoryItem] public let hasUnseen: Bool + public let hasUnseenPrivate: Bool public let totalCount: Int public let theme: PresentationTheme public let action: () -> Void @@ -101,6 +102,7 @@ public final class StorySetIndicatorComponent: Component { peer: EnginePeer, items: [EngineStoryItem], hasUnseen: Bool, + hasUnseenPrivate: Bool, totalCount: Int, theme: PresentationTheme, action: @escaping () -> Void @@ -109,6 +111,7 @@ public final class StorySetIndicatorComponent: Component { self.peer = peer self.items = items self.hasUnseen = hasUnseen + self.hasUnseenPrivate = hasUnseenPrivate self.totalCount = totalCount self.theme = theme self.action = action @@ -121,6 +124,9 @@ public final class StorySetIndicatorComponent: Component { if lhs.hasUnseen != rhs.hasUnseen { return false } + if lhs.hasUnseenPrivate != rhs.hasUnseenPrivate { + return false + } if lhs.totalCount != rhs.totalCount { return false } @@ -349,7 +355,9 @@ public final class StorySetIndicatorComponent: Component { let borderColors: [UInt32] - if component.hasUnseen { + if component.hasUnseenPrivate { + borderColors = [component.theme.chatList.storyUnseenPrivateColors.topColor.argb, component.theme.chatList.storyUnseenPrivateColors.bottomColor.argb] + } else if component.hasUnseen { borderColors = [component.theme.chatList.storyUnseenColors.topColor.argb, component.theme.chatList.storyUnseenColors.bottomColor.argb] } else { borderColors = [UIColor(white: 1.0, alpha: 0.3).argb, UIColor(white: 1.0, alpha: 0.3).argb] diff --git a/submodules/TelegramUI/Images.xcassets/Stories/PanelGradient.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Stories/PanelGradient.imageset/Contents.json new file mode 100644 index 0000000000..54905e0f8b --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Stories/PanelGradient.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "smoothGradient.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/submodules/TelegramUI/Images.xcassets/Stories/PanelGradient.imageset/smoothGradient.png b/submodules/TelegramUI/Images.xcassets/Stories/PanelGradient.imageset/smoothGradient.png new file mode 100644 index 0000000000..ae7189a9cf Binary files /dev/null and b/submodules/TelegramUI/Images.xcassets/Stories/PanelGradient.imageset/smoothGradient.png differ diff --git a/submodules/TelegramUI/Sources/ApplicationContext.swift b/submodules/TelegramUI/Sources/ApplicationContext.swift index fee628d90c..f64752cd0f 100644 --- a/submodules/TelegramUI/Sources/ApplicationContext.swift +++ b/submodules/TelegramUI/Sources/ApplicationContext.swift @@ -26,6 +26,7 @@ import AccountUtils import ContextUI import TelegramCallsUI import AuthorizationUI +import ChatListUI final class UnauthorizedApplicationContext { let sharedContext: SharedAccountContextImpl @@ -852,8 +853,56 @@ final class AuthorizedApplicationContext { } func openChatWithPeerId(peerId: PeerId, threadId: Int64?, messageId: MessageId? = nil, activateInput: Bool = false, storyId: StoryId?) { - if let _ = storyId { - self.rootController.chatListController?.openStories(peerId: peerId) + if let storyId { + if let chatListController = self.rootController.chatListController as? ChatListControllerImpl { + let _ = (chatListController.context.account.postbox.transaction { transaction -> Bool in + if let peer = transaction.getPeer(storyId.peerId) as? TelegramUser, let storiesHidden = peer.storiesHidden, storiesHidden { + return true + } else { + return false + } + } + |> deliverOnMainQueue).start(next: { [weak self] isArchived in + guard let self, let chatListController = self.rootController.chatListController as? ChatListControllerImpl else { + return + } + if isArchived { + if let navigationController = (chatListController.navigationController as? NavigationController) { + var viewControllers = navigationController.viewControllers + if let index = viewControllers.firstIndex(where: { c in + if let c = c as? ChatListControllerImpl { + if case .chatList(groupId: .archive) = c.location { + return true + } + } + return false + }) { + (viewControllers[index] as? ChatListControllerImpl)?.scrollToStories() + viewControllers.removeSubrange((index + 1) ..< viewControllers.count) + navigationController.setViewControllers(viewControllers, animated: false) + } else { + let archive = ChatListControllerImpl(context: chatListController.context, location: .chatList(groupId: .archive), controlsHistoryPreload: false, hideNetworkActivityStatus: false, previewing: false, enableDebugActions: false) + archive.onDidAppear = { [weak archive] in + Queue.mainQueue().after(0.1, { + guard let archive else { + return + } + if archive.hasStorySubscriptions { + archive.scrollToStoriesAnimated() + } + }) + } + navigationController.pushViewController(archive, animated: false, completion: {}) + } + } + } else { + chatListController.scrollToStories() + if let navigationController = (chatListController.navigationController as? NavigationController) { + navigationController.popToRoot(animated: true) + } + } + }) + } } else { var visiblePeerId: PeerId? if let controller = self.rootController.topViewController as? ChatControllerImpl, controller.chatLocation.peerId == peerId, controller.chatLocation.threadId == threadId { 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/ChatInterfaceStateContextMenus.swift b/submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift index 7c35d1db72..25312c768e 100644 --- a/submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift +++ b/submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift @@ -457,9 +457,6 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState actions.append(.action(ContextMenuActionItem(text: presentationData.strings.SponsoredMessageMenu_Info, textColor: .primary, textLayout: .twoLinesMax, textFont: .custom(font: Font.regular(presentationData.listsFontSize.baseDisplaySize - 1.0), height: nil, verticalOffset: nil), badge: nil, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Info"), color: theme.actionSheet.primaryTextColor) }, iconSource: nil, action: { _, f in - /*c.dismiss(completion: { - controllerInteraction.navigationController()?.pushViewController(AdInfoScreen(context: context)) - })*/ f(.dismissWithoutContent) controllerInteraction.navigationController()?.pushViewController(AdInfoScreen(context: context)) }))) @@ -625,6 +622,14 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState } } + for media in messages[0].media { + if let story = media as? TelegramMediaStory { + if let story = message.associatedStories[story.storyId], story.data.isEmpty { + canPin = false + } + } + } + var loadStickerSaveStatusSignal: Signal = .single(nil) if let loadStickerSaveStatus = loadStickerSaveStatus { loadStickerSaveStatusSignal = context.engine.stickers.isStickerSaved(id: loadStickerSaveStatus) @@ -1943,6 +1948,10 @@ func chatAvailableMessageActionsImpl(engine: TelegramEngine, accountPeerId: Peer } } else if let action = media as? TelegramMediaAction, case .phoneCall = action.action { optionsMap[id]!.insert(.rateCall) + } else if let story = media as? TelegramMediaStory { + if let story = message.associatedStories[story.storyId], story.data.isEmpty { + isShareProtected = true + } } } if id.namespace == Namespaces.Message.ScheduledCloud { @@ -1962,7 +1971,7 @@ func chatAvailableMessageActionsImpl(engine: TelegramEngine, accountPeerId: Peer optionsMap[id]!.insert(.deleteLocally) } } else if id.peerId == accountPeerId { - if !(message.flags.isSending || message.flags.contains(.Failed)) { + if !(message.flags.isSending || message.flags.contains(.Failed)) && !isShareProtected { optionsMap[id]!.insert(.forward) } optionsMap[id]!.insert(.deleteLocally) @@ -2006,7 +2015,7 @@ func chatAvailableMessageActionsImpl(engine: TelegramEngine, accountPeerId: Peer banPeer = nil } } - if !message.containsSecretMedia && !isAction { + if !message.containsSecretMedia && !isAction && !isShareProtected { if message.id.peerId.namespace != Namespaces.Peer.SecretChat && !message.isCopyProtected() { if !(message.flags.isSending || message.flags.contains(.Failed)) { optionsMap[id]!.insert(.forward) @@ -2023,7 +2032,7 @@ func chatAvailableMessageActionsImpl(engine: TelegramEngine, accountPeerId: Peer } } else if let group = peer as? TelegramGroup { if message.id.peerId.namespace != Namespaces.Peer.SecretChat && !message.containsSecretMedia { - if !isAction && !message.isCopyProtected() { + if !isAction && !message.isCopyProtected() && !isShareProtected { if !(message.flags.isSending || message.flags.contains(.Failed)) { optionsMap[id]!.insert(.forward) } @@ -2057,7 +2066,7 @@ func chatAvailableMessageActionsImpl(engine: TelegramEngine, accountPeerId: Peer } } } else if let user = peer as? TelegramUser { - if !isScheduled && message.id.peerId.namespace != Namespaces.Peer.SecretChat && !message.containsSecretMedia && !isAction && !message.id.peerId.isReplies && !message.isCopyProtected() { + if !isScheduled && message.id.peerId.namespace != Namespaces.Peer.SecretChat && !message.containsSecretMedia && !isAction && !message.id.peerId.isReplies && !message.isCopyProtected() && !isShareProtected { if !(message.flags.isSending || message.flags.contains(.Failed)) { optionsMap[id]!.insert(.forward) } diff --git a/submodules/TelegramUI/Sources/ChatMessageActionItemNode.swift b/submodules/TelegramUI/Sources/ChatMessageActionItemNode.swift index 62e9404cfa..1c479b5518 100644 --- a/submodules/TelegramUI/Sources/ChatMessageActionItemNode.swift +++ b/submodules/TelegramUI/Sources/ChatMessageActionItemNode.swift @@ -536,7 +536,11 @@ class ChatMessageActionBubbleContentNode: ChatMessageBubbleContentNode { } if let backgroundNode = self.backgroundNode, backgroundNode.frame.contains(point) { - return .openMessage + if let item = self.item, item.message.media.contains(where: { $0 is TelegramMediaStory }) { + return .none + } else { + return .openMessage + } } else { return .none } diff --git a/submodules/TelegramUI/Sources/PeerInfo/PeerInfoScreen.swift b/submodules/TelegramUI/Sources/PeerInfo/PeerInfoScreen.swift index 60975f5cfd..2689835a83 100644 --- a/submodules/TelegramUI/Sources/PeerInfo/PeerInfoScreen.swift +++ b/submodules/TelegramUI/Sources/PeerInfo/PeerInfoScreen.swift @@ -3903,7 +3903,7 @@ final class PeerInfoScreenNode: ViewControllerTracingNode, PeerInfoScreenNodePro case .placeholder: return nil } - }, state.items.count, state.hasUnseen) + }, state.items.count, state.hasUnseen, state.hasUnseenCloseFriends) } self.requestLayout(animated: false) @@ -4107,7 +4107,15 @@ 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) + return + } + let _ = expiringStoryList let storyContent = StoryContentContextImpl(context: self.context, isHidden: false, focusedPeerId: self.peerId, singlePeer: true) let _ = (storyContent.state @@ -7141,6 +7149,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 }