From b130511450fc51672755a2ace45de69cfc824341 Mon Sep 17 00:00:00 2001 From: Ilya Laktyushin Date: Sat, 12 Apr 2025 02:19:32 +0400 Subject: [PATCH] [WIP] Gift resale --- .../Sources/AccountContext.swift | 31 +- .../Sources/ChatController.swift | 4 +- .../Sources/ChatListSearchContainerNode.swift | 6 + .../ChatListSearchFiltersContainerNode.swift | 3 + .../Sources/ChatListSearchListPaneNode.swift | 118 +- .../ChatListSearchPaneContainerNode.swift | 3 + .../ContextUI/Sources/ContextController.swift | 5 + .../ContextControllerActionsStackNode.swift | 9 +- .../Display/Source/CAAnimationUtils.swift | 4 +- .../TelegramEngine/Payments/StarGifts.swift | 282 +++- .../Sources/ServiceMessageStrings.swift | 2 +- submodules/TelegramUI/BUILD | 2 + .../ChatMessageGiftBubbleContentNode.swift | 4 +- .../ChatMessageInteractiveMediaNode.swift | 2 +- .../Sources/EmojiPagerContentComponent.swift | 2 +- .../Sources/GiftCompositionComponent.swift | 9 +- .../Sources/GiftItemComponent.swift | 141 +- .../Sources/GiftOptionsScreen.swift | 214 ++- .../Components/Gifts/GiftStoreScreen/BUILD | 51 + .../Sources/FiltersComponent.swift | 331 ++++ .../Sources/GiftStoreScreen.swift | 1343 +++++++++++++++++ .../Sources/LoadingShimmerComponent.swift | 185 +++ .../Sources/GiftTransferAlertController.swift | 2 +- .../Sources/GiftUnpinScreen.swift | 4 +- .../Sources/GiftViewScreen.swift | 384 ++++- .../Sources/GiftWithdrawAlertController.swift | 2 +- .../Components/MarqueeComponent/BUILD | 19 + .../Sources/MarqueeComponent.swift | 180 +++ .../Sources/MediaEditorScreen.swift | 40 +- ...SelectionPanelButtonContentComponent.swift | 140 ++ .../Sources/SelectionPanelComponent.swift | 7 + .../Sources/PeerInfoGiftsCoverComponent.swift | 4 +- .../Sources/PeerInfoGiftsPaneNode.swift | 63 +- .../Sources/GiftListItemComponent.swift | 2 +- .../Sources/UserApperanceScreen.swift | 7 +- .../Sources/StarsAvatarComponent.swift | 2 +- .../Sources/StarsWithdrawalScreen.swift | 67 +- .../SortNumber.imageset/Contents.json | 12 + .../Peer Info/SortNumber.imageset/hash.pdf | Bin 0 -> 4324 bytes .../Collectible/Sell.imageset/Contents.json | 12 + .../Collectible/Sell.imageset/sale.pdf | Bin 0 -> 4291 bytes .../Collectible/Unlist.imageset/Contents.json | 12 + .../Collectible/Unlist.imageset/unsale.pdf | Bin 0 -> 4022 bytes .../ChatInterfaceStateContextMenus.swift | 2 +- .../Sources/OverlayPlayerControlsNode.swift | 24 +- .../Sources/SharedAccountContext.swift | 14 + 46 files changed, 3554 insertions(+), 196 deletions(-) create mode 100644 submodules/TelegramUI/Components/Gifts/GiftStoreScreen/BUILD create mode 100644 submodules/TelegramUI/Components/Gifts/GiftStoreScreen/Sources/FiltersComponent.swift create mode 100644 submodules/TelegramUI/Components/Gifts/GiftStoreScreen/Sources/GiftStoreScreen.swift create mode 100644 submodules/TelegramUI/Components/Gifts/GiftStoreScreen/Sources/LoadingShimmerComponent.swift create mode 100644 submodules/TelegramUI/Components/MarqueeComponent/BUILD create mode 100644 submodules/TelegramUI/Components/MarqueeComponent/Sources/MarqueeComponent.swift create mode 100644 submodules/TelegramUI/Components/MediaEditorScreen/Sources/SelectionPanelButtonContentComponent.swift create mode 100644 submodules/TelegramUI/Components/MediaEditorScreen/Sources/SelectionPanelComponent.swift create mode 100644 submodules/TelegramUI/Images.xcassets/Peer Info/SortNumber.imageset/Contents.json create mode 100644 submodules/TelegramUI/Images.xcassets/Peer Info/SortNumber.imageset/hash.pdf create mode 100644 submodules/TelegramUI/Images.xcassets/Premium/Collectible/Sell.imageset/Contents.json create mode 100644 submodules/TelegramUI/Images.xcassets/Premium/Collectible/Sell.imageset/sale.pdf create mode 100644 submodules/TelegramUI/Images.xcassets/Premium/Collectible/Unlist.imageset/Contents.json create mode 100644 submodules/TelegramUI/Images.xcassets/Premium/Collectible/Unlist.imageset/unsale.pdf diff --git a/submodules/AccountContext/Sources/AccountContext.swift b/submodules/AccountContext/Sources/AccountContext.swift index 0a74b6e388..3b75430550 100644 --- a/submodules/AccountContext/Sources/AccountContext.swift +++ b/submodules/AccountContext/Sources/AccountContext.swift @@ -653,6 +653,7 @@ public enum ChatListSearchFilter: Equatable { case files case music case voice + case instantVideo case peer(PeerId, Bool, String, String) case date(Int32?, Int32, String) case publicPosts @@ -679,8 +680,10 @@ public enum ChatListSearchFilter: Equatable { return 8 case .voice: return 9 - case .publicPosts: + case .instantVideo: return 10 + case .publicPosts: + return 11 case let .peer(peerId, _, _, _): return peerId.id._internalGetInt64Value() case let .date(_, date, _): @@ -1126,6 +1129,7 @@ public protocol SharedAccountContext: AnyObject { func makeStarsGiftController(context: AccountContext, birthdays: [EnginePeer.Id: TelegramBirthday]?, completion: @escaping (([EnginePeer.Id]) -> Void)) -> ViewController func makePremiumGiftController(context: AccountContext, source: PremiumGiftSource, completion: (([EnginePeer.Id]) -> Signal)?) -> ViewController func makeGiftOptionsController(context: AccountContext, peerId: EnginePeer.Id, premiumOptions: [CachedPremiumGiftOption], hasBirthday: Bool, completion: (() -> Void)?) -> ViewController + func makeGiftStoreController(context: AccountContext, peerId: EnginePeer.Id, gift: StarGift.Gift) -> ViewController func makePremiumPrivacyControllerController(context: AccountContext, subject: PremiumPrivacySubject, peerId: EnginePeer.Id) -> ViewController func makePremiumBoostLevelsController(context: AccountContext, peerId: EnginePeer.Id, subject: BoostSubject, boostStatus: ChannelBoostStatus, myBoostStatus: MyBoostStatus, forceDark: Bool, openStats: (() -> Void)?) -> ViewController @@ -1169,6 +1173,7 @@ public protocol SharedAccountContext: AnyObject { func makeStarsAmountScreen(context: AccountContext, initialValue: Int64?, completion: @escaping (Int64) -> Void) -> ViewController func makeStarsWithdrawalScreen(context: AccountContext, stats: StarsRevenueStats, completion: @escaping (Int64) -> Void) -> ViewController func makeStarsWithdrawalScreen(context: AccountContext, completion: @escaping (Int64) -> Void) -> ViewController + func makeStarGiftResellScreen(context: AccountContext, completion: @escaping (Int64) -> Void) -> ViewController func makeStarsGiftScreen(context: AccountContext, message: EngineMessage) -> ViewController func makeStarsGiveawayBoostScreen(context: AccountContext, peerId: EnginePeer.Id, boost: ChannelBoostersContext.State.Boost) -> ViewController func makeStarsIntroScreen(context: AccountContext) -> ViewController @@ -1435,7 +1440,10 @@ public struct StarsSubscriptionConfiguration { usdWithdrawRate: 1200, paidMessageMaxAmount: 10000, paidMessageCommissionPermille: 850, - paidMessagesAvailable: false + paidMessagesAvailable: false, + starGiftResaleMinAmount: 125, + starGiftResaleMaxAmount: 3500, + starGiftCommissionPermille: 80 ) } @@ -1444,19 +1452,28 @@ public struct StarsSubscriptionConfiguration { public let paidMessageMaxAmount: Int64 public let paidMessageCommissionPermille: Int32 public let paidMessagesAvailable: Bool + public let starGiftResaleMinAmount: Int64 + public let starGiftResaleMaxAmount: Int64 + public let starGiftCommissionPermille: Int32 fileprivate init( maxFee: Int64, usdWithdrawRate: Int64, paidMessageMaxAmount: Int64, paidMessageCommissionPermille: Int32, - paidMessagesAvailable: Bool + paidMessagesAvailable: Bool, + starGiftResaleMinAmount: Int64, + starGiftResaleMaxAmount: Int64, + starGiftCommissionPermille: Int32 ) { self.maxFee = maxFee self.usdWithdrawRate = usdWithdrawRate self.paidMessageMaxAmount = paidMessageMaxAmount self.paidMessageCommissionPermille = paidMessageCommissionPermille self.paidMessagesAvailable = paidMessagesAvailable + self.starGiftResaleMinAmount = starGiftResaleMinAmount + self.starGiftResaleMaxAmount = starGiftResaleMaxAmount + self.starGiftCommissionPermille = starGiftCommissionPermille } public static func with(appConfiguration: AppConfiguration) -> StarsSubscriptionConfiguration { @@ -1466,13 +1483,19 @@ public struct StarsSubscriptionConfiguration { let paidMessageMaxAmount = (data["stars_paid_message_amount_max"] as? Double).flatMap(Int64.init) ?? StarsSubscriptionConfiguration.defaultValue.paidMessageMaxAmount let paidMessageCommissionPermille = (data["stars_paid_message_commission_permille"] as? Double).flatMap(Int32.init) ?? StarsSubscriptionConfiguration.defaultValue.paidMessageCommissionPermille let paidMessagesAvailable = (data["stars_paid_messages_available"] as? Bool) ?? StarsSubscriptionConfiguration.defaultValue.paidMessagesAvailable + let starGiftResaleMinAmount = (data["stars_stargift_resale_amount_min"] as? Double).flatMap(Int64.init) ?? StarsSubscriptionConfiguration.defaultValue.starGiftResaleMinAmount + let starGiftResaleMaxAmount = (data["stars_stargift_resale_amount_max"] as? Double).flatMap(Int64.init) ?? StarsSubscriptionConfiguration.defaultValue.starGiftResaleMaxAmount + let starGiftCommissionPermille = (data["stars_stargift_resale_commission_permille"] as? Double).flatMap(Int32.init) ?? StarsSubscriptionConfiguration.defaultValue.starGiftCommissionPermille return StarsSubscriptionConfiguration( maxFee: maxFee, usdWithdrawRate: usdWithdrawRate, paidMessageMaxAmount: paidMessageMaxAmount, paidMessageCommissionPermille: paidMessageCommissionPermille, - paidMessagesAvailable: paidMessagesAvailable + paidMessagesAvailable: paidMessagesAvailable, + starGiftResaleMinAmount: starGiftResaleMinAmount, + starGiftResaleMaxAmount: starGiftResaleMaxAmount, + starGiftCommissionPermille: starGiftCommissionPermille ) } else { return .defaultValue diff --git a/submodules/AccountContext/Sources/ChatController.swift b/submodules/AccountContext/Sources/ChatController.swift index 7c89ba3ba3..4cab8932e6 100644 --- a/submodules/AccountContext/Sources/ChatController.swift +++ b/submodules/AccountContext/Sources/ChatController.swift @@ -608,7 +608,7 @@ public struct ChatTextInputStateText: Codable, Equatable { return lhs.text == rhs.text && lhs.attributes == rhs.attributes } - public func attributedText() -> NSAttributedString { + public func attributedText(files: [Int64: TelegramMediaFile] = [:]) -> NSAttributedString { let result = NSMutableAttributedString(string: self.text) for attribute in self.attributes { switch attribute.type { @@ -623,7 +623,7 @@ public struct ChatTextInputStateText: Codable, Equatable { case let .textUrl(url): result.addAttribute(ChatTextInputAttributes.textUrl, value: ChatTextInputTextUrlAttribute(url: url), range: NSRange(location: attribute.range.lowerBound, length: attribute.range.count)) case let .customEmoji(_, fileId, enableAnimation): - result.addAttribute(ChatTextInputAttributes.customEmoji, value: ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: nil, fileId: fileId, file: nil, enableAnimation: enableAnimation), range: NSRange(location: attribute.range.lowerBound, length: attribute.range.count)) + result.addAttribute(ChatTextInputAttributes.customEmoji, value: ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: nil, fileId: fileId, file: files[fileId], enableAnimation: enableAnimation), range: NSRange(location: attribute.range.lowerBound, length: attribute.range.count)) case .strikethrough: result.addAttribute(ChatTextInputAttributes.strikethrough, value: true as NSNumber, range: NSRange(location: attribute.range.lowerBound, length: attribute.range.count)) case .underline: diff --git a/submodules/ChatListUI/Sources/ChatListSearchContainerNode.swift b/submodules/ChatListUI/Sources/ChatListSearchContainerNode.swift index f4e1600400..e7c445e693 100644 --- a/submodules/ChatListUI/Sources/ChatListSearchContainerNode.swift +++ b/submodules/ChatListUI/Sources/ChatListSearchContainerNode.swift @@ -357,6 +357,8 @@ public final class ChatListSearchContainerNode: SearchDisplayControllerContentNo key = .music case .voice: key = .voice + case .instantVideo: + key = .instantVideo case .publicPosts: key = .publicPosts case let .date(minDate, maxDate, title): @@ -685,6 +687,8 @@ public final class ChatListSearchContainerNode: SearchDisplayControllerContentNo filterKey = .music case .voice: filterKey = .voice + case .instantVideo: + filterKey = .instantVideo case .publicPosts: filterKey = .publicPosts } @@ -725,6 +729,8 @@ public final class ChatListSearchContainerNode: SearchDisplayControllerContentNo key = .music case .voice: key = .voice + case .instantVideo: + key = .instantVideo case .downloads: key = .downloads default: diff --git a/submodules/ChatListUI/Sources/ChatListSearchFiltersContainerNode.swift b/submodules/ChatListUI/Sources/ChatListSearchFiltersContainerNode.swift index fb1a9bd468..05495508a2 100644 --- a/submodules/ChatListUI/Sources/ChatListSearchFiltersContainerNode.swift +++ b/submodules/ChatListUI/Sources/ChatListSearchFiltersContainerNode.swift @@ -108,6 +108,9 @@ private final class ItemNode: ASDisplayNode { case .voice: title = presentationData.strings.ChatList_Search_FilterVoice icon = nil + case .instantVideo: + title = presentationData.strings.ChatList_Search_FilterVoice + icon = nil case .publicPosts: title = presentationData.strings.ChatList_Search_FilterPublicPosts icon = nil diff --git a/submodules/ChatListUI/Sources/ChatListSearchListPaneNode.swift b/submodules/ChatListUI/Sources/ChatListSearchListPaneNode.swift index fc7495d6d9..7904765738 100644 --- a/submodules/ChatListUI/Sources/ChatListSearchListPaneNode.swift +++ b/submodules/ChatListUI/Sources/ChatListSearchListPaneNode.swift @@ -1587,8 +1587,8 @@ final class ChatListSearchListPaneNode: ASDisplayNode, ChatListSearchPaneNode { private let recentListNode: ListView private let shimmerNode: ChatListSearchShimmerNode - private let listNode: ListView - private let mediaNode: ChatListSearchMediaNode + private let listNode: ListView? + private let mediaNode: ChatListSearchMediaNode? private var enqueuedRecentTransitions: [(ChatListSearchContainerRecentTransition, Bool)] = [] private var enqueuedTransitions: [(ChatListSearchContainerTransition, Bool)] = [] @@ -1714,6 +1714,8 @@ final class ChatListSearchListPaneNode: ASDisplayNode, ChatListSearchPaneNode { tagMask = .music case .voice: tagMask = .voiceOrInstantVideo + case .instantVideo: + tagMask = .roundVideo } self.tagMask = tagMask @@ -1737,8 +1739,8 @@ final class ChatListSearchListPaneNode: ASDisplayNode, ChatListSearchPaneNode { self.shimmerNode.allowsGroupOpacity = true self.listNode = ListView() - self.listNode.verticalScrollIndicatorColor = self.presentationData.theme.list.scrollIndicatorColor - self.listNode.accessibilityPageScrolledString = { row, count in + self.listNode?.verticalScrollIndicatorColor = self.presentationData.theme.list.scrollIndicatorColor + self.listNode?.accessibilityPageScrolledString = { row, count in return presentationData.strings.VoiceOver_ScrollStatus(row, count).string } @@ -1746,13 +1748,17 @@ final class ChatListSearchListPaneNode: ASDisplayNode, ChatListSearchPaneNode { var transitionNodeImpl: ((EngineMessage.Id, EngineMedia) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))?)? var addToTransitionSurfaceImpl: ((UIView) -> Void)? - self.mediaNode = ChatListSearchMediaNode(context: self.context, contentType: .photoOrVideo, openMessage: { message, mode in - openMediaMessageImpl?(EngineMessage(message), mode) - }, messageContextAction: { message, node, rect, gesture in - interaction.mediaMessageContextAction(EngineMessage(message), node, rect, gesture) - }, toggleMessageSelection: { messageId, selected in - interaction.toggleMessageSelection(messageId, selected) - }) + if key == .media { + self.mediaNode = ChatListSearchMediaNode(context: self.context, contentType: .photoOrVideo, openMessage: { message, mode in + openMediaMessageImpl?(EngineMessage(message), mode) + }, messageContextAction: { message, node, rect, gesture in + interaction.mediaMessageContextAction(EngineMessage(message), node, rect, gesture) + }, toggleMessageSelection: { messageId, selected in + interaction.toggleMessageSelection(messageId, selected) + }) + } else { + self.mediaNode = nil + } self.mediaAccessoryPanelContainer = PassthroughContainerNode() self.mediaAccessoryPanelContainer.clipsToBounds = true @@ -1822,8 +1828,12 @@ final class ChatListSearchListPaneNode: ASDisplayNode, ChatListSearchPaneNode { self.addSubnode(recentEmptyNode) } - self.addSubnode(self.listNode) - self.addSubnode(self.mediaNode) + if let listNode = self.listNode { + self.addSubnode(listNode) + } + if let mediaNode = self.mediaNode { + self.addSubnode(mediaNode) + } self.addSubnode(self.emptyResultsAnimationNode) self.addSubnode(self.emptyResultsTitleNode) @@ -1850,8 +1860,8 @@ final class ChatListSearchListPaneNode: ASDisplayNode, ChatListSearchPaneNode { } } - self.listNode.isHidden = true - self.mediaNode.isHidden = true + self.listNode?.isHidden = true + self.mediaNode?.isHidden = true self.recentListNode.isHidden = peersFilter.contains(.excludeRecent) let currentRemotePeers = Atomic<([FoundPeer], [FoundPeer], [AdPeer])?>(value: nil) @@ -3227,16 +3237,16 @@ final class ChatListSearchListPaneNode: ASDisplayNode, ChatListSearchPaneNode { } transitionNodeImpl = { [weak self] messageId, media in - if let strongSelf = self { - return strongSelf.mediaNode.transitionNodeForGallery(messageId: messageId, media: media._asMedia()) + if let self { + return self.mediaNode?.transitionNodeForGallery(messageId: messageId, media: media._asMedia()) } else { return nil } } addToTransitionSurfaceImpl = { [weak self] view in - if let strongSelf = self { - strongSelf.mediaNode.addToTransitionSurface(view: view) + if let self { + self.mediaNode?.addToTransitionSurface(view: view) } } @@ -3270,7 +3280,7 @@ final class ChatListSearchListPaneNode: ASDisplayNode, ChatListSearchPaneNode { case .savedMessagesChats: break } - self?.listNode.clearHighlightAnimated(true) + self?.listNode?.clearHighlightAnimated(true) }, disabledPeerSelected: { _, _, _ in }, togglePeerSelected: { _, _ in }, togglePeersSelection: { _, _ in @@ -3280,12 +3290,12 @@ final class ChatListSearchListPaneNode: ASDisplayNode, ChatListSearchPaneNode { if let strongSelf = self, let peer = message.peers[message.id.peerId] { interaction.openMessage(EnginePeer(peer), threadId, message.id, strongSelf.key == .chats) } - self?.listNode.clearHighlightAnimated(true) + self?.listNode?.clearHighlightAnimated(true) }, groupSelected: { _ in }, addContact: { [weak self] phoneNumber in interaction.dismissInput() interaction.addContact(phoneNumber) - self?.listNode.clearHighlightAnimated(true) + self?.listNode?.clearHighlightAnimated(true) }, setPeerIdWithRevealedOptions: { _, _ in }, setItemPinned: { _, _ in }, setPeerMuted: { _, _ in @@ -3328,7 +3338,7 @@ final class ChatListSearchListPaneNode: ASDisplayNode, ChatListSearchPaneNode { } interaction.dismissInput() interaction.openPeer(peer, peer, threadId, false) - self.listNode.clearHighlightAnimated(true) + self.listNode?.clearHighlightAnimated(true) }) }, openStorageManagement: { }, openPasswordSetup: { @@ -3397,7 +3407,7 @@ final class ChatListSearchListPaneNode: ASDisplayNode, ChatListSearchPaneNode { }, transitionNode: { messageId, media, _ in var transitionNode: (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? if let strongSelf = self { - strongSelf.listNode.forEachItemNode { itemNode in + strongSelf.listNode?.forEachItemNode { itemNode in if let itemNode = itemNode as? ListMessageNode { if let result = itemNode.transitionNode(id: messageId, media: media, adjustRect: false) { transitionNode = result @@ -3556,7 +3566,9 @@ final class ChatListSearchListPaneNode: ASDisplayNode, ChatListSearchPaneNode { if isSearching && (entries?.isEmpty ?? true) { entries = nil } - strongSelf.mediaNode.updateHistory(entries: entries, totalCount: 0, updateType: .Initial) + strongSelf.mediaNode?.updateHistory(entries: entries, totalCount: 0, updateType: .Initial) + } else if strongSelf.tagMask == .roundVideo { + } var peers: [EnginePeer] = [] @@ -4349,7 +4361,7 @@ final class ChatListSearchListPaneNode: ASDisplayNode, ChatListSearchPaneNode { if strongSelf.backgroundColor != nil { strongSelf.backgroundColor = presentationData.theme.chatList.backgroundColor } - strongSelf.listNode.forEachItemHeaderNode({ itemHeaderNode in + strongSelf.listNode?.forEachItemHeaderNode({ itemHeaderNode in if let itemHeaderNode = itemHeaderNode as? ChatListSearchItemHeaderNode { itemHeaderNode.updateTheme(theme: presentationData.theme) } @@ -4367,26 +4379,26 @@ final class ChatListSearchListPaneNode: ASDisplayNode, ChatListSearchPaneNode { interaction.dismissInput() } - self.listNode.beganInteractiveDragging = { _ in + self.listNode?.beganInteractiveDragging = { _ in interaction.dismissInput() } - self.mediaNode.beganInteractiveDragging = { + self.mediaNode?.beganInteractiveDragging = { interaction.dismissInput() } - self.listNode.visibleBottomContentOffsetChanged = { offset in + self.listNode?.visibleBottomContentOffsetChanged = { offset in guard case let .known(value) = offset, value < 160.0 else { return } loadMore() } - self.mediaNode.loadMore = { + self.mediaNode?.loadMore = { loadMore() } - if [.file, .music, .voiceOrInstantVideo].contains(tagMask) || self.key == .downloads { + if [.file, .music, .voiceOrInstantVideo, .voice, .roundVideo].contains(tagMask) || self.key == .downloads { let key = self.key self.mediaStatusDisposable = (context.sharedContext.mediaManager.globalMediaPlayerState |> mapToSignal { playlistStateAndType -> Signal<(Account, SharedMediaPlayerItemPlaybackState, MediaManagerPlayerType)?, NoError> in @@ -4396,7 +4408,7 @@ final class ChatListSearchListPaneNode: ASDisplayNode, ChatListSearchPaneNode { if let playlistId = state.playlistId as? PeerMessagesMediaPlaylistId, case .custom = playlistId { switch type { case .voice: - if tagMask != .voiceOrInstantVideo { + if ![.voiceOrInstantVideo, .voice, .roundVideo].contains(tagMask) { return .single(nil) |> delay(0.2, queue: .mainQueue()) } case .music: @@ -4524,8 +4536,8 @@ final class ChatListSearchListPaneNode: ASDisplayNode, ChatListSearchPaneNode { } func scrollToTop() -> Bool { - if !self.mediaNode.isHidden { - return self.mediaNode.scrollToTop() + if let mediaNode = self.mediaNode, !mediaNode.isHidden { + return mediaNode.scrollToTop() } else if !self.recentListNode.isHidden { let offset = self.recentListNode.visibleContentOffset() switch offset { @@ -4535,15 +4547,17 @@ final class ChatListSearchListPaneNode: ASDisplayNode, ChatListSearchPaneNode { self.recentListNode.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous, .LowLatency], scrollToItem: ListViewScrollToItem(index: 0, position: .top(0.0), animated: true, curve: .Default(duration: nil), directionHint: .Up), updateSizeAndInsets: nil, stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in }) return true } - } else { - let offset = self.listNode.visibleContentOffset() + } else if let listNode = self.listNode { + let offset = listNode.visibleContentOffset() switch offset { case let .known(value) where value <= CGFloat.ulpOfOne: return false default: - self.listNode.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous, .LowLatency], scrollToItem: ListViewScrollToItem(index: 0, position: .top(0.0), animated: true, curve: .Default(duration: nil), directionHint: .Up), updateSizeAndInsets: nil, stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in }) + listNode.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous, .LowLatency], scrollToItem: ListViewScrollToItem(index: 0, position: .top(0.0), animated: true, curve: .Default(duration: nil), directionHint: .Up), updateSizeAndInsets: nil, stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in }) return true } + } else { + return false } } @@ -4852,11 +4866,11 @@ final class ChatListSearchListPaneNode: ASDisplayNode, ChatListSearchPaneNode { emptyRecentAnimationNode.updateLayout(size: emptyRecentAnimationSize) } - self.listNode.frame = CGRect(origin: CGPoint(), size: size) - self.listNode.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous], scrollToItem: nil, updateSizeAndInsets: ListViewUpdateSizeAndInsets(size: size, insets: insets, duration: duration, curve: curve), stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in }) + self.listNode?.frame = CGRect(origin: CGPoint(), size: size) + self.listNode?.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous], scrollToItem: nil, updateSizeAndInsets: ListViewUpdateSizeAndInsets(size: size, insets: insets, duration: duration, curve: curve), stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in }) - self.mediaNode.frame = CGRect(origin: CGPoint(x: 0.0, y: topInset), size: CGSize(width: size.width, height: size.height)) - self.mediaNode.update(size: size, sideInset: sideInset, bottomInset: bottomInset, visibleHeight: visibleHeight, isScrollingLockedAtTop: false, expandProgress: 1.0, presentationData: self.presentationData, synchronous: true, transition: transition) + self.mediaNode?.frame = CGRect(origin: CGPoint(x: 0.0, y: topInset), size: CGSize(width: size.width, height: size.height)) + self.mediaNode?.update(size: size, sideInset: sideInset, bottomInset: bottomInset, visibleHeight: visibleHeight, isScrollingLockedAtTop: false, expandProgress: 1.0, presentationData: self.presentationData, synchronous: true, transition: transition) do { let padding: CGFloat = 16.0 @@ -4887,7 +4901,7 @@ final class ChatListSearchListPaneNode: ASDisplayNode, ChatListSearchPaneNode { } func updateHiddenMedia() { - self.listNode.forEachItemNode { itemNode in + self.listNode?.forEachItemNode { itemNode in if let itemNode = itemNode as? ListMessageNode { itemNode.updateHiddenMedia() } @@ -4899,7 +4913,7 @@ final class ChatListSearchListPaneNode: ASDisplayNode, ChatListSearchPaneNode { func transitionNodeForGallery(messageId: EngineMessage.Id, media: EngineMedia) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? { var transitionNode: (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? - self.listNode.forEachItemNode { itemNode in + self.listNode?.forEachItemNode { itemNode in if let itemNode = itemNode as? ListMessageNode { if let result = itemNode.transitionNode(id: messageId, media: media._asMedia(), adjustRect: false) { transitionNode = result @@ -4915,8 +4929,8 @@ final class ChatListSearchListPaneNode: ASDisplayNode, ChatListSearchPaneNode { func updateSelectedMessages(animated: Bool) { self.selectedMessages = self.interaction.getSelectedMessageIds() - self.mediaNode.selectedMessageIds = self.selectedMessages - self.mediaNode.updateSelectedMessages(animated: animated) + self.mediaNode?.selectedMessageIds = self.selectedMessages + self.mediaNode?.updateSelectedMessages(animated: animated) } func removeAds() { @@ -5006,16 +5020,16 @@ final class ChatListSearchListPaneNode: ASDisplayNode, ChatListSearchPaneNode { options.insert(.PreferSynchronousResourceLoading) } - self.listNode.transaction(deleteIndices: transition.deletions, insertIndicesAndItems: transition.insertions, updateIndicesAndItems: transition.updates, options: options, updateSizeAndInsets: nil, updateOpaqueState: nil, completion: { [weak self] _ in + self.listNode?.transaction(deleteIndices: transition.deletions, insertIndicesAndItems: transition.insertions, updateIndicesAndItems: transition.updates, options: options, updateSizeAndInsets: nil, updateOpaqueState: nil, completion: { [weak self] _ in if let strongSelf = self { let searchOptions = strongSelf.searchOptionsValue - strongSelf.listNode.isHidden = strongSelf.tagMask == .photoOrVideo && (strongSelf.searchQueryValue ?? "").isEmpty - strongSelf.mediaNode.isHidden = !strongSelf.listNode.isHidden + strongSelf.listNode?.isHidden = strongSelf.tagMask == .photoOrVideo && (strongSelf.searchQueryValue ?? "").isEmpty + strongSelf.mediaNode?.isHidden = !(strongSelf.listNode?.isHidden ?? true) let displayingResults = transition.displayingResults if !displayingResults { - strongSelf.listNode.isHidden = true - strongSelf.mediaNode.isHidden = true + strongSelf.listNode?.isHidden = true + strongSelf.mediaNode?.isHidden = true } let emptyResults = displayingResults && transition.isEmpty @@ -5103,7 +5117,7 @@ final class ChatListSearchListPaneNode: ASDisplayNode, ChatListSearchPaneNode { } } else { let adjustedLocation = self.convert(location, to: self.listNode) - self.listNode.forEachItemNode { itemNode in + self.listNode?.forEachItemNode { itemNode in if itemNode.frame.contains(adjustedLocation) { selectedItemNode = itemNode } @@ -5506,7 +5520,7 @@ public final class ChatListSearchShimmerNode: ASDisplayNode { ) return ListMessageItem(presentationData: ChatPresentationData(presentationData: presentationData), context: context, chatLocation: .peer(id: peer1.id), interaction: ListMessageItemInteraction.default, message: message._asMessage(), selection: hasSelection ? .selectable(selected: false) : .none, displayHeader: false, customHeader: nil, hintIsLink: false, isGlobalSearchResult: true) - case .voice: + case .voice, .instantVideo: var media: [EngineMedia] = [] media.append(.file(TelegramMediaFile(fileId: EngineMedia.Id(namespace: 0, id: 0), partialReference: nil, resource: LocalFileMediaResource(fileId: 0), previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "audio/ogg", size: 0, attributes: [.Audio(isVoice: true, duration: 0, title: nil, performer: nil, waveform: Data())], alternativeRepresentations: []))) let message = EngineMessage( diff --git a/submodules/ChatListUI/Sources/ChatListSearchPaneContainerNode.swift b/submodules/ChatListUI/Sources/ChatListSearchPaneContainerNode.swift index ed8c8ae367..ef5021db86 100644 --- a/submodules/ChatListUI/Sources/ChatListSearchPaneContainerNode.swift +++ b/submodules/ChatListUI/Sources/ChatListSearchPaneContainerNode.swift @@ -61,6 +61,7 @@ public enum ChatListSearchPaneKey { case files case music case voice + case instantVideo } extension ChatListSearchPaneKey { @@ -88,6 +89,8 @@ extension ChatListSearchPaneKey { return .music case .voice: return .voice + case .instantVideo: + return .instantVideo } } } diff --git a/submodules/ContextUI/Sources/ContextController.swift b/submodules/ContextUI/Sources/ContextController.swift index 58711d3b14..a53723b0a5 100644 --- a/submodules/ContextUI/Sources/ContextController.swift +++ b/submodules/ContextUI/Sources/ContextController.swift @@ -126,6 +126,7 @@ public final class ContextMenuActionItem { public let id: AnyHashable? public let text: String public let entities: [MessageTextEntity] + public let entityFiles: [Int64: TelegramMediaFile] public let enableEntityAnimations: Bool public let textColor: ContextMenuActionItemTextColor public let textFont: ContextMenuActionItemFont @@ -147,6 +148,7 @@ public final class ContextMenuActionItem { id: AnyHashable? = nil, text: String, entities: [MessageTextEntity] = [], + entityFiles: [Int64: TelegramMediaFile] = [:], enableEntityAnimations: Bool = true, textColor: ContextMenuActionItemTextColor = .primary, textLayout: ContextMenuActionItemTextLayout = .twoLinesMax, @@ -168,6 +170,7 @@ public final class ContextMenuActionItem { id: id, text: text, entities: entities, + entityFiles: entityFiles, enableEntityAnimations: enableEntityAnimations, textColor: textColor, textLayout: textLayout, @@ -199,6 +202,7 @@ public final class ContextMenuActionItem { id: AnyHashable? = nil, text: String, entities: [MessageTextEntity] = [], + entityFiles: [Int64: TelegramMediaFile] = [:], enableEntityAnimations: Bool = true, textColor: ContextMenuActionItemTextColor = .primary, textLayout: ContextMenuActionItemTextLayout = .twoLinesMax, @@ -219,6 +223,7 @@ public final class ContextMenuActionItem { self.id = id self.text = text self.entities = entities + self.entityFiles = entityFiles self.enableEntityAnimations = enableEntityAnimations self.textColor = textColor self.textFont = textFont diff --git a/submodules/ContextUI/Sources/ContextControllerActionsStackNode.swift b/submodules/ContextUI/Sources/ContextControllerActionsStackNode.swift index eae0aa5176..e26bf1de82 100644 --- a/submodules/ContextUI/Sources/ContextControllerActionsStackNode.swift +++ b/submodules/ContextUI/Sources/ContextControllerActionsStackNode.swift @@ -361,14 +361,21 @@ public final class ContextControllerActionsListActionItemNode: HighlightTracking let inputStateText = ChatTextInputStateText(text: self.item.text, attributes: self.item.entities.compactMap { entity -> ChatTextInputStateTextAttribute? in if case let .CustomEmoji(_, fileId) = entity.type { return ChatTextInputStateTextAttribute(type: .customEmoji(stickerPack: nil, fileId: fileId, enableAnimation: true), range: entity.range) + } else if case .Bold = entity.type { + return ChatTextInputStateTextAttribute(type: .bold, range: entity.range) } return nil }) - let result = NSMutableAttributedString(attributedString: inputStateText.attributedText()) + let result = NSMutableAttributedString(attributedString: inputStateText.attributedText(files: self.item.entityFiles)) result.addAttributes([ .font: titleFont, .foregroundColor: titleColor ], range: NSRange(location: 0, length: result.length)) + for attribute in inputStateText.attributes { + if case .bold = attribute.type { + result.addAttribute(NSAttributedString.Key.font, value: Font.semibold(15.0), range: NSRange(location: attribute.range.lowerBound, length: attribute.range.count)) + } + } attributedText = result } else { attributedText = parseMarkdownIntoAttributedString( diff --git a/submodules/Display/Source/CAAnimationUtils.swift b/submodules/Display/Source/CAAnimationUtils.swift index ad9317de39..952098bdee 100644 --- a/submodules/Display/Source/CAAnimationUtils.swift +++ b/submodules/Display/Source/CAAnimationUtils.swift @@ -437,8 +437,8 @@ public extension CALayer { self.animate(from: from as NSNumber, to: to as NSNumber, keyPath: "bounds.size.height", timingFunction: timingFunction, duration: duration, delay: delay, mediaTimingFunction: mediaTimingFunction, removeOnCompletion: removeOnCompletion, additive: additive, completion: completion) } - func animateBoundsOriginXAdditive(from: CGFloat, to: CGFloat, duration: Double, timingFunction: String = CAMediaTimingFunctionName.easeInEaseOut.rawValue, mediaTimingFunction: CAMediaTimingFunction? = nil, removeOnCompletion: Bool = true, completion: ((Bool) -> Void)? = nil) { - self.animate(from: from as NSNumber, to: to as NSNumber, keyPath: "bounds.origin.x", timingFunction: timingFunction, duration: duration, mediaTimingFunction: mediaTimingFunction, removeOnCompletion: removeOnCompletion, additive: true, completion: completion) + func animateBoundsOriginXAdditive(from: CGFloat, to: CGFloat, duration: Double, delay: Double = 0.0, timingFunction: String = CAMediaTimingFunctionName.easeInEaseOut.rawValue, mediaTimingFunction: CAMediaTimingFunction? = nil, removeOnCompletion: Bool = true, completion: ((Bool) -> Void)? = nil) { + self.animate(from: from as NSNumber, to: to as NSNumber, keyPath: "bounds.origin.x", timingFunction: timingFunction, duration: duration, delay: delay, mediaTimingFunction: mediaTimingFunction, removeOnCompletion: removeOnCompletion, additive: true, completion: completion) } func animateBoundsOriginYAdditive(from: CGFloat, to: CGFloat, duration: Double, timingFunction: String = CAMediaTimingFunctionName.easeInEaseOut.rawValue, mediaTimingFunction: CAMediaTimingFunction? = nil, removeOnCompletion: Bool = true, completion: ((Bool) -> Void)? = nil) { diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Payments/StarGifts.swift b/submodules/TelegramCore/Sources/TelegramEngine/Payments/StarGifts.swift index 2d77c8599c..a4e7b20c1e 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Payments/StarGifts.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Payments/StarGifts.swift @@ -1155,7 +1155,7 @@ private final class ProfileGiftsContextImpl { if !filter.contains(.unique) { flags |= (1 << 4) } - return network.request(Api.functions.payments.getSavedStarGifts(flags: flags, peer: inputPeer, offset: initialNextOffset ?? "", limit: 32)) + return network.request(Api.functions.payments.getSavedStarGifts(flags: flags, peer: inputPeer, offset: initialNextOffset ?? "", limit: 36)) |> map(Optional.init) |> `catch` { _ -> Signal in return .single(nil) @@ -2220,3 +2220,283 @@ public extension StarGift.UniqueGift { return nil } } + +private final class ResaleGiftsContextImpl { + private let queue: Queue + private let account: Account + private let giftId: Int64 + + private let disposable = MetaDisposable() + + private var sorting: ResaleGiftsContext.Sorting = .date + private var filterAttributes: [ResaleGiftsContext.Attribute] = [] + + private var gifts: [StarGift] = [] + private var attributes: [StarGift.UniqueGift.Attribute] = [] + private var attributeCount: [ResaleGiftsContext.Attribute: Int32] = [:] + + private var count: Int32? + private var dataState: ResaleGiftsContext.State.DataState = .ready(canLoadMore: true, nextOffset: nil) + + var _state: ResaleGiftsContext.State? + private let stateValue = Promise() + var state: Signal { + return self.stateValue.get() + } + + init( + queue: Queue, + account: Account, + giftId: Int64 + ) { + self.queue = queue + self.account = account + self.giftId = giftId + + self.loadMore() + } + + deinit { + self.disposable.dispose() + } + + func reload() { + self.gifts = [] + self.dataState = .ready(canLoadMore: true, nextOffset: nil) + self.loadMore(reload: true) + } + + func loadMore(reload: Bool = false) { + let giftId = self.giftId + let accountPeerId = self.account.peerId + let network = self.account.network + let postbox = self.account.postbox + let sorting = self.sorting + let filterAttributes = self.filterAttributes + + let dataState = self.dataState + + if case let .ready(true, initialNextOffset) = dataState { + self.dataState = .loading + if !reload { + self.pushState() + } + + var flags: Int32 = 0 + switch sorting { + case .date: + break + case .value: + flags |= (1 << 1) + case .number: + flags |= (1 << 2) + } + + var apiAttributes: [Api.StarGiftAttributeId]? + if !filterAttributes.isEmpty { + flags |= (1 << 3) + apiAttributes = filterAttributes.map { + switch $0 { + case let .model(id): + return .starGiftAttributeIdModel(documentId: id) + case let .pattern(id): + return .starGiftAttributeIdPattern(documentId: id) + case let .backdrop(id): + return .starGiftAttributeIdBackdrop(backdropId: id) + } + } + } + + var attributesHash: Int64? + if "".isEmpty { + flags |= (1 << 0) + attributesHash = 0 + } + + let signal = network.request(Api.functions.payments.getResaleStarGifts(flags: flags, attributesHash: attributesHash, giftId: giftId, attributes: apiAttributes, offset: initialNextOffset ?? "", limit: 36)) + |> map(Optional.init) + |> `catch` { _ -> Signal in + return .single(nil) + } + |> mapToSignal { result -> Signal<([StarGift], [StarGift.UniqueGift.Attribute], [ResaleGiftsContext.Attribute: Int32], Int32, String?), NoError> in + guard let result else { + return .single(([], [], [:], 0, nil)) + } + return postbox.transaction { transaction -> ([StarGift], [StarGift.UniqueGift.Attribute], [ResaleGiftsContext.Attribute: Int32], Int32, String?) in + switch result { + case let .resaleStarGifts(_, count, gifts, nextOffset, attributes, attributesHash, chats, counters, users): + let _ = attributesHash + + var resultAttributes: [StarGift.UniqueGift.Attribute] = [] + if let attributes { + resultAttributes = attributes.compactMap { StarGift.UniqueGift.Attribute(apiAttribute: $0) } + } + + var attributeCount: [ResaleGiftsContext.Attribute: Int32] = [:] + if let counters { + for counter in counters { + switch counter { + case let .starGiftAttributeCounter(attribute, count): + switch attribute { + case let .starGiftAttributeIdModel(documentId): + attributeCount[.model(documentId)] = count + case let .starGiftAttributeIdPattern(documentId): + attributeCount[.pattern(documentId)] = count + case let .starGiftAttributeIdBackdrop(backdropId): + attributeCount[.backdrop(backdropId)] = count + } + } + } + } + + let parsedPeers = AccumulatedPeers(transaction: transaction, chats: chats, users: users) + updatePeers(transaction: transaction, accountPeerId: accountPeerId, peers: parsedPeers) + return (gifts.compactMap { StarGift(apiStarGift: $0) }, resultAttributes, attributeCount, count, nextOffset) + } + } + } + + self.disposable.set((signal + |> deliverOn(self.queue)).start(next: { [weak self] (gifts, attributes, attributeCount, count, nextOffset) in + guard let self else { + return + } + if initialNextOffset == nil || reload { + self.gifts = gifts + } else { + for gift in gifts { + self.gifts.append(gift) + } + } + + let updatedCount = max(Int32(self.gifts.count), count) + self.count = updatedCount + self.attributes = attributes + if !attributeCount.isEmpty { + self.attributeCount = attributeCount + } + self.dataState = .ready(canLoadMore: count != 0 && updatedCount > self.gifts.count && nextOffset != nil, nextOffset: nextOffset) + + self.pushState() + })) + } + } + + func updateFilterAttributes(_ filterAttributes: [ResaleGiftsContext.Attribute]) { + guard self.filterAttributes != filterAttributes else { + return + } + self.filterAttributes = filterAttributes + self.dataState = .ready(canLoadMore: true, nextOffset: nil) + self.pushState() + + self.loadMore() + } + + func updateSorting(_ sorting: ResaleGiftsContext.Sorting) { + guard self.sorting != sorting else { + return + } + self.sorting = sorting + self.dataState = .ready(canLoadMore: true, nextOffset: nil) + self.pushState() + + self.loadMore() + } + + private func pushState() { + let state = ResaleGiftsContext.State( + sorting: self.sorting, + filterAttributes: self.filterAttributes, + gifts: self.gifts, + attributes: self.attributes, + attributeCount: self.attributeCount, + count: self.count, + dataState: self.dataState + ) + self._state = state + self.stateValue.set(.single(state)) + } +} + +public final class ResaleGiftsContext { + public enum Sorting: Equatable { + case date + case value + case number + } + + public enum Attribute: Equatable, Hashable { + case model(Int64) + case pattern(Int64) + case backdrop(Int32) + } + + public struct State: Equatable { + public enum DataState: Equatable { + case loading + case ready(canLoadMore: Bool, nextOffset: String?) + } + + public var sorting: Sorting + public var filterAttributes: [Attribute] + public var gifts: [StarGift] + public var attributes: [StarGift.UniqueGift.Attribute] + public var attributeCount: [Attribute: Int32] + public var count: Int32? + public var dataState: ResaleGiftsContext.State.DataState + } + + private let queue: Queue = .mainQueue() + private let impl: QueueLocalObject + + public var state: Signal { + return Signal { subscriber in + let disposable = MetaDisposable() + + self.impl.with { impl in + disposable.set(impl.state.start(next: { value in + subscriber.putNext(value) + })) + } + + return disposable + } + } + + public init( + account: Account, + giftId: Int64 + ) { + let queue = self.queue + self.impl = QueueLocalObject(queue: queue, generate: { + return ResaleGiftsContextImpl(queue: queue, account: account, giftId: giftId) + }) + } + + public func loadMore() { + self.impl.with { impl in + impl.loadMore() + } + } + + public func updateSorting(_ sorting: ResaleGiftsContext.Sorting) { + self.impl.with { impl in + impl.updateSorting(sorting) + } + } + + public func updateFilterAttributes(_ attributes: [ResaleGiftsContext.Attribute]) { + self.impl.with { impl in + impl.updateFilterAttributes(attributes) + } + } + + public var currentState: ResaleGiftsContext.State? { + var state: ResaleGiftsContext.State? + self.impl.syncWith { impl in + state = impl._state + } + return state + } +} diff --git a/submodules/TelegramStringFormatting/Sources/ServiceMessageStrings.swift b/submodules/TelegramStringFormatting/Sources/ServiceMessageStrings.swift index b013f04d16..f48614ca94 100644 --- a/submodules/TelegramStringFormatting/Sources/ServiceMessageStrings.swift +++ b/submodules/TelegramStringFormatting/Sources/ServiceMessageStrings.swift @@ -1149,7 +1149,7 @@ public func universalServiceMessageString(presentationData: (PresentationTheme, attributedString = addAttributesToStringWithRanges(strings.Notification_StarsGift_Sent(authorName, starsPrice)._tuple, body: bodyAttributes, argumentAttributes: attributes) } } - case let .starGiftUnique(gift, isUpgrade, _, _, _, _, _, peerId, senderId, _): + case let .starGiftUnique(gift, isUpgrade, _, _, _, _, _, peerId, senderId, _, _): if case let .unique(gift) = gift { if !forAdditionalServiceMessage && !"".isEmpty { attributedString = NSAttributedString(string: "\(gift.title) #\(presentationStringsFormattedNumber(gift.number, dateTimeFormat.groupingSeparator))", font: titleFont, textColor: primaryTextColor) diff --git a/submodules/TelegramUI/BUILD b/submodules/TelegramUI/BUILD index e0c228ffef..202428afd9 100644 --- a/submodules/TelegramUI/BUILD +++ b/submodules/TelegramUI/BUILD @@ -463,6 +463,7 @@ swift_library( "//submodules/TelegramUI/Components/MiniAppListScreen", "//submodules/TelegramUI/Components/Stars/StarsIntroScreen", "//submodules/TelegramUI/Components/Gifts/GiftOptionsScreen", + "//submodules/TelegramUI/Components/Gifts/GiftStoreScreen", "//submodules/TelegramUI/Components/ContentReportScreen", "//submodules/TelegramUI/Components/PeerInfo/AffiliateProgramSetupScreen", "//submodules/TelegramUI/Components/Stars/StarsBalanceOverlayComponent", @@ -472,6 +473,7 @@ swift_library( "//submodules/TelegramUI/Components/ButtonComponent", "//submodules/Components/BlurredBackgroundComponent", "//submodules/TelegramUI/Components/CheckComponent", + "//submodules/TelegramUI/Components/MarqueeComponent", "//third-party/recaptcha:RecaptchaEnterprise", ] + select({ "@build_bazel_rules_apple//apple:ios_arm64": appcenter_targets, diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageGiftBubbleContentNode/Sources/ChatMessageGiftBubbleContentNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageGiftBubbleContentNode/Sources/ChatMessageGiftBubbleContentNode.swift index bf9eb2c51a..b0cc08742c 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageGiftBubbleContentNode/Sources/ChatMessageGiftBubbleContentNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageGiftBubbleContentNode/Sources/ChatMessageGiftBubbleContentNode.swift @@ -560,7 +560,7 @@ public class ChatMessageGiftBubbleContentNode: ChatMessageBubbleContentNode { buttonTitle = item.presentationData.strings.Notification_StarGift_View } } - case let .starGiftUnique(gift, isUpgrade, _, _, _, _, isRefunded, _, _, _): + case let .starGiftUnique(gift, isUpgrade, _, _, _, _, isRefunded, _, _, _, _): if case let .unique(uniqueGift) = gift { isStarGift = true @@ -594,7 +594,7 @@ public class ChatMessageGiftBubbleContentNode: ChatMessageBubbleContentNode { case let .model(name, file, _): modelValue = name animationFile = file - case let .backdrop(name, innerColor, outerColor, patternColor, _, _): + case let .backdrop(name, _, innerColor, outerColor, patternColor, _, _): uniqueBackgroundColor = UIColor(rgb: UInt32(bitPattern: outerColor)) uniqueSecondBackgroundColor = UIColor(rgb: UInt32(bitPattern: innerColor)) uniquePatternColor = UIColor(rgb: UInt32(bitPattern: patternColor)) diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageInteractiveMediaNode/Sources/ChatMessageInteractiveMediaNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageInteractiveMediaNode/Sources/ChatMessageInteractiveMediaNode.swift index 26241e9a6b..3938669792 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageInteractiveMediaNode/Sources/ChatMessageInteractiveMediaNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageInteractiveMediaNode/Sources/ChatMessageInteractiveMediaNode.swift @@ -2189,7 +2189,7 @@ public final class ChatMessageInteractiveMediaNode: ASDisplayNode, GalleryItemTr context: context, theme: presentationData.theme.theme, strings: presentationData.strings, - subject: .uniqueGift(gift: gift), + subject: .uniqueGift(gift: gift, price: nil), mode: .preview ) ), diff --git a/submodules/TelegramUI/Components/EntityKeyboard/Sources/EmojiPagerContentComponent.swift b/submodules/TelegramUI/Components/EntityKeyboard/Sources/EmojiPagerContentComponent.swift index 3dfb52532c..f6f46e22b6 100644 --- a/submodules/TelegramUI/Components/EntityKeyboard/Sources/EmojiPagerContentComponent.swift +++ b/submodules/TelegramUI/Components/EntityKeyboard/Sources/EmojiPagerContentComponent.swift @@ -128,7 +128,7 @@ public final class EntityKeyboardAnimationData: Equatable { for attribute in gift.attributes { if case let .model(_, fileValue, _) = attribute { file = fileValue - } else if case let .backdrop(_, innerColor, outerColor, _, _, _) = attribute { + } else if case let .backdrop(_, _, innerColor, outerColor, _, _, _) = attribute { color = UIColor(rgb: UInt32(bitPattern: innerColor)) let _ = outerColor } diff --git a/submodules/TelegramUI/Components/Gifts/GiftAnimationComponent/Sources/GiftCompositionComponent.swift b/submodules/TelegramUI/Components/Gifts/GiftAnimationComponent/Sources/GiftCompositionComponent.swift index 8a167de994..2f3eb20f16 100644 --- a/submodules/TelegramUI/Components/Gifts/GiftAnimationComponent/Sources/GiftCompositionComponent.swift +++ b/submodules/TelegramUI/Components/Gifts/GiftAnimationComponent/Sources/GiftCompositionComponent.swift @@ -149,6 +149,11 @@ public final class GiftCompositionComponent: Component { previewTimer.invalidate() self.previewTimer = nil } + + if !self.fetchedFiles.contains(file.fileId.id) { + self.disposables.add(freeMediaFileResourceInteractiveFetched(account: component.context.account, userLocation: .other, fileReference: .standalone(media: file), resource: file.resource).start()) + self.fetchedFiles.insert(file.fileId.id) + } case let .unique(gift): for attribute in gift.attributes { switch attribute { @@ -161,7 +166,7 @@ public final class GiftCompositionComponent: Component { case let .pattern(_, file, _): patternFile = file files[file.fileId.id] = file - case let .backdrop(_, innerColorValue, outerColorValue, patternColorValue, _, _): + case let .backdrop(_, _, innerColorValue, outerColorValue, patternColorValue, _, _): backgroundColor = UIColor(rgb: UInt32(bitPattern: outerColorValue)) secondBackgroundColor = UIColor(rgb: UInt32(bitPattern: innerColorValue)) patternColor = UIColor(rgb: UInt32(bitPattern: patternColorValue)) @@ -222,7 +227,7 @@ public final class GiftCompositionComponent: Component { files[file.fileId.id] = file } - if case let .backdrop(_, innerColorValue, outerColorValue, patternColorValue, _, _) = self.previewBackdrops[Int(self.previewBackdropIndex)] { + if case let .backdrop(_, _, innerColorValue, outerColorValue, patternColorValue, _, _) = self.previewBackdrops[Int(self.previewBackdropIndex)] { backgroundColor = UIColor(rgb: UInt32(bitPattern: outerColorValue)) secondBackgroundColor = UIColor(rgb: UInt32(bitPattern: innerColorValue)) patternColor = UIColor(rgb: UInt32(bitPattern: patternColorValue)) diff --git a/submodules/TelegramUI/Components/Gifts/GiftItemComponent/Sources/GiftItemComponent.swift b/submodules/TelegramUI/Components/Gifts/GiftItemComponent/Sources/GiftItemComponent.swift index c10f7ae3de..379755dd6b 100644 --- a/submodules/TelegramUI/Components/Gifts/GiftItemComponent/Sources/GiftItemComponent.swift +++ b/submodules/TelegramUI/Components/Gifts/GiftItemComponent/Sources/GiftItemComponent.swift @@ -20,7 +20,7 @@ public final class GiftItemComponent: Component { public enum Subject: Equatable { case premium(months: Int32, price: String) case starGift(gift: StarGift.Gift, price: String) - case uniqueGift(gift: StarGift.UniqueGift) + case uniqueGift(gift: StarGift.UniqueGift, price: String?) } public struct Ribbon: Equatable { @@ -28,6 +28,7 @@ public final class GiftItemComponent: Component { case red case blue case purple + case green case custom(Int32, Int32) func colors(theme: PresentationTheme) -> [UIColor] { @@ -61,6 +62,11 @@ public final class GiftItemComponent: Component { UIColor(rgb: 0x747bf6), UIColor(rgb: 0xe367d8) ] + case .green: + return [ + UIColor(rgb: 0x4bb121), + UIColor(rgb: 0x53d654) + ] case let .custom(topColor, _): return [ UIColor(rgb: UInt32(bitPattern: topColor)).withMultiplied(hue: 0.97, saturation: 1.45, brightness: 0.89), @@ -72,17 +78,25 @@ public final class GiftItemComponent: Component { public enum Font { case generic + case larger case monospaced } public let text: String public let font: Font public let color: Color + public let outline: UIColor? - public init(text: String, font: Font = .generic, color: Color) { + public init( + text: String, + font: Font = .generic, + color: Color, + outline: UIColor? = nil + ) { self.text = text self.font = font self.color = color + self.outline = outline } } @@ -108,6 +122,7 @@ public final class GiftItemComponent: Component { let subtitle: String? let label: String? let ribbon: Ribbon? + let resellPrice: Int64? let isLoading: Bool let isHidden: Bool let isSoldOut: Bool @@ -128,6 +143,7 @@ public final class GiftItemComponent: Component { subtitle: String? = nil, label: String? = nil, ribbon: Ribbon? = nil, + resellPrice: Int64? = nil, isLoading: Bool = false, isHidden: Bool = false, isSoldOut: Bool = false, @@ -147,6 +163,7 @@ public final class GiftItemComponent: Component { self.subtitle = subtitle self.label = label self.ribbon = ribbon + self.resellPrice = resellPrice self.isLoading = isLoading self.isHidden = isHidden self.isSoldOut = isSoldOut @@ -186,6 +203,9 @@ public final class GiftItemComponent: Component { if lhs.ribbon != rhs.ribbon { return false } + if lhs.resellPrice != rhs.resellPrice { + return false + } if lhs.isLoading != rhs.isLoading { return false } @@ -229,6 +249,8 @@ public final class GiftItemComponent: Component { private let subtitle = ComponentView() private let button = ComponentView() private let label = ComponentView() + + private let ribbonOutline = UIImageView() private let ribbon = UIImageView() private let ribbonText = ComponentView() @@ -244,6 +266,9 @@ public final class GiftItemComponent: Component { private var hiddenIcon: UIImageView? private var pinnedIcon: UIImageView? + private var resellBackground: BlurredBackgroundView? + private let reselLabel = ComponentView() + override init(frame: CGRect) { super.init(frame: frame) @@ -383,7 +408,7 @@ public final class GiftItemComponent: Component { file: gift.file ) animationOffset = 16.0 - case let .uniqueGift(gift): + case let .uniqueGift(gift, _): animationOffset = 16.0 for attribute in gift.attributes { switch attribute { @@ -396,7 +421,7 @@ public final class GiftItemComponent: Component { case let .pattern(_, file, _): patternFile = file files[file.fileId.id] = file - case let .backdrop(_, innerColorValue, outerColorValue, patternColorValue, _, _): + case let .backdrop(_, _, innerColorValue, outerColorValue, patternColorValue, _, _): backgroundColor = UIColor(rgb: UInt32(bitPattern: outerColorValue)) secondBackgroundColor = UIColor(rgb: UInt32(bitPattern: innerColorValue)) patternColor = UIColor(rgb: UInt32(bitPattern: patternColorValue)) @@ -530,6 +555,7 @@ public final class GiftItemComponent: Component { let buttonColor: UIColor var starsColor: UIColor? + var tinted = false let price: String switch component.subject { case let .premium(_, priceValue), let .starGift(_, priceValue): @@ -542,9 +568,10 @@ public final class GiftItemComponent: Component { buttonColor = component.theme.list.itemAccentColor } price = priceValue - case .uniqueGift: + case let .uniqueGift(_, priceValue): buttonColor = UIColor.white - price = component.strings.Gift_Options_Gift_Transfer + price = priceValue ?? component.strings.Gift_Options_Gift_Transfer + tinted = true } let buttonSize = self.button.update( @@ -554,6 +581,7 @@ public final class GiftItemComponent: Component { context: component.context, text: price, color: buttonColor, + tinted: tinted, starsColor: starsColor ) ), @@ -623,6 +651,8 @@ public final class GiftItemComponent: Component { switch ribbon.font { case .generic: ribbonFont = Font.semibold(ribbonFontSize) + case .larger: + ribbonFont = Font.semibold(10.0) case .monospaced: ribbonFont = Font.with(size: 10.0, design: .monospace, weight: .semibold) } @@ -645,6 +675,18 @@ public final class GiftItemComponent: Component { } ribbonTextView.bounds = CGRect(origin: .zero, size: ribbonTextSize) + if let _ = component.ribbon?.outline { + if self.ribbonOutline.image == nil || themeUpdated || previousComponent?.ribbon?.outline != component.ribbon?.outline { + self.ribbonOutline.image = ribbonOutlineImage + self.ribbonOutline.tintColor = component.ribbon?.outline + if self.ribbonOutline.superview == nil { + self.insertSubview(self.ribbonOutline, belowSubview: self.ribbon) + } + } + } else if self.ribbonOutline.superview != nil { + self.ribbonOutline.removeFromSuperview() + } + if self.ribbon.image == nil || themeUpdated || previousComponent?.ribbon?.color != component.ribbon?.color { var direction: GradientImageDirection = .mirroredDiagonal if case .custom = ribbon.color { @@ -661,11 +703,16 @@ public final class GiftItemComponent: Component { if let ribbonImage = self.ribbon.image { self.ribbon.frame = CGRect(origin: CGPoint(x: size.width - ribbonImage.size.width + ribbonOffset.x, y: ribbonOffset.y), size: ribbonImage.size) } + if let ribbonOutlineImage = self.ribbonOutline.image { + self.ribbonOutline.frame = ribbonOutlineImage.size.centered(around: self.ribbon.center.offsetBy(dx: 0.0, dy: 2.0)) + } + ribbonTextView.transform = CGAffineTransform(rotationAngle: .pi / 4.0) ribbonTextView.center = CGPoint(x: size.width - 22.0 + ribbonOffset.x, y: 22.0 + ribbonOffset.y) } } else { if self.ribbonText.view?.superview != nil { + self.ribbonOutline.removeFromSuperview() self.ribbon.removeFromSuperview() self.ribbonText.view?.removeFromSuperview() } @@ -808,6 +855,72 @@ public final class GiftItemComponent: Component { hiddenIcon.layer.animateScale(from: 1.0, to: 0.01, duration: 0.2, removeOnCompletion: false) } + + if let resellPrice = component.resellPrice { + let labelColor = UIColor.white + let attributes = MarkdownAttributes( + body: MarkdownAttributeSet(font: Font.semibold(11.0), textColor: labelColor), + bold: MarkdownAttributeSet(font: Font.semibold(11.0), textColor: labelColor), + link: MarkdownAttributeSet(font: Font.regular(11.0), textColor: labelColor), + linkAttribute: { contents in + return (TelegramTextAttributes.URL, contents) + } + ) + let labelText = NSMutableAttributedString(attributedString: parseMarkdownIntoAttributedString("#\(resellPrice)", attributes: attributes)) + if let range = labelText.string.range(of: "#") { + labelText.addAttribute(NSAttributedString.Key.font, value: Font.semibold(10.0), range: NSRange(range, in: labelText.string)) + labelText.addAttribute(ChatTextInputAttributes.customEmoji, value: ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: nil, fileId: 0, file: nil, custom: .stars(tinted: true)), range: NSRange(range, in: labelText.string)) + } + + let resellSize = self.reselLabel.update( + transition: transition, + component: AnyComponent( + MultilineTextWithEntitiesComponent( + context: component.context, + animationCache: component.context.animationCache, + animationRenderer: component.context.animationRenderer, + placeholderColor: .white, + text: .plain(labelText), + horizontalAlignment: .center + ) + ), + environment: {}, + containerSize: availableSize + ) + + let resellFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((availableSize.width - resellSize.width) / 2.0), y: size.height - 20.0), size: resellSize) + + let resellBackground: BlurredBackgroundView + var resellBackgroundTransition = transition + if let currentBackground = self.resellBackground { + resellBackground = currentBackground + } else { + resellBackgroundTransition = .immediate + + resellBackground = BlurredBackgroundView(color: UIColor(rgb: 0x000000, alpha: 0.3), enableBlur: true) //UIVisualEffectView(effect: blurEffect) + resellBackground.clipsToBounds = true + self.resellBackground = resellBackground + + self.addSubview(resellBackground) + } + let resellBackgroundFrame = resellFrame.insetBy(dx: -6.0, dy: -4.0) + resellBackgroundTransition.setFrame(view: resellBackground, frame: resellBackgroundFrame) + resellBackground.update(size: resellBackgroundFrame.size, cornerRadius: resellBackgroundFrame.size.height / 2.0, transition: resellBackgroundTransition.containedViewLayoutTransition) + + if let resellLabelView = self.reselLabel.view { + if resellLabelView.superview == nil { + self.addSubview(resellLabelView) + } + transition.setFrame(view: resellLabelView, frame: resellFrame) + } + } else { + self.reselLabel.view?.removeFromSuperview() + if let resellBackground = self.resellBackground { + self.resellBackground = nil + resellBackground.removeFromSuperview() + } + } + if case .grid = component.mode { let lineWidth: CGFloat = 2.0 let selectionFrame = backgroundFrame.insetBy(dx: 3.0, dy: 3.0) @@ -873,17 +986,20 @@ private final class ButtonContentComponent: Component { let context: AccountContext let text: String let color: UIColor + let tinted: Bool let starsColor: UIColor? public init( context: AccountContext, text: String, color: UIColor, + tinted: Bool = false, starsColor: UIColor? = nil ) { self.context = context self.text = text self.color = color + self.tinted = tinted self.starsColor = starsColor } @@ -897,6 +1013,9 @@ private final class ButtonContentComponent: Component { if lhs.color != rhs.color { return false } + if lhs.tinted != rhs.tinted { + return false + } if lhs.starsColor != rhs.starsColor { return false } @@ -930,7 +1049,7 @@ private final class ButtonContentComponent: Component { let attributedText = NSMutableAttributedString(string: component.text, font: Font.semibold(11.0), textColor: component.color) let range = (attributedText.string as NSString).range(of: "⭐️") if range.location != NSNotFound { - attributedText.addAttribute(ChatTextInputAttributes.customEmoji, value: ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: nil, fileId: 0, file: nil, custom: .stars(tinted: false)), range: range) + attributedText.addAttribute(ChatTextInputAttributes.customEmoji, value: ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: nil, fileId: 0, file: nil, custom: .stars(tinted: component.tinted)), range: range) attributedText.addAttribute(.font, value: Font.semibold(15.0), range: range) attributedText.addAttribute(.baselineOffset, value: 2.0, range: NSRange(location: range.upperBound, length: attributedText.length - range.upperBound)) } @@ -1057,3 +1176,11 @@ private final class StarsButtonEffectLayer: SimpleLayer { self.emitterLayer.emitterPosition = CGPoint(x: size.width / 2.0, y: size.height / 2.0) } } + +private var ribbonOutlineImage: UIImage? = { + if let image = UIImage(bundleImageName: "Premium/GiftRibbon") { + return generateScaledImage(image: image, size: CGSize(width: image.size.width + 8.0, height: image.size.height + 8.0), opaque: false)?.withRenderingMode(.alwaysTemplate) + } else { + return UIImage() + } +}() diff --git a/submodules/TelegramUI/Components/Gifts/GiftOptionsScreen/Sources/GiftOptionsScreen.swift b/submodules/TelegramUI/Components/Gifts/GiftOptionsScreen/Sources/GiftOptionsScreen.swift index a2edd0c5ed..7117e7fbd7 100644 --- a/submodules/TelegramUI/Components/Gifts/GiftOptionsScreen/Sources/GiftOptionsScreen.swift +++ b/submodules/TelegramUI/Components/Gifts/GiftOptionsScreen/Sources/GiftOptionsScreen.swift @@ -79,6 +79,7 @@ final class GiftOptionsScreenComponent: Component { case all case limited case inStock + case resale case stars(Int64) case transfer @@ -92,6 +93,8 @@ final class GiftOptionsScreenComponent: Component { self = .inStock case -3: self = .transfer + case -4: + self = .resale default: self = .stars(rawValue) } @@ -107,6 +110,8 @@ final class GiftOptionsScreenComponent: Component { return -2 case .transfer: return -3 + case .resale: + return -4 case let .stars(stars): return stars } @@ -136,6 +141,7 @@ final class GiftOptionsScreenComponent: Component { private var starsItems: [AnyHashable: ComponentView] = [:] private let tabSelector = ComponentView() private var starsFilter: StarsFilter = .all + private var switchingFilter = false private var _effectiveStarGifts: ([StarGift], StarsFilter)? private var effectiveStarGifts: [StarGift]? { @@ -191,6 +197,12 @@ final class GiftOptionsScreenComponent: Component { return true } } + case .resale: + if case let .generic(gift) = $0 { + if let availability = gift.availability, availability.resale > 0 { + return true + } + } case .transfer: break } @@ -216,6 +228,7 @@ final class GiftOptionsScreenComponent: Component { private(set) weak var state: State? private var environment: EnvironmentType? + private var tabSelectorOrigin: CGFloat = 0.0 private var starsItemsOrigin: CGFloat = 0.0 private var dismissed = false @@ -355,12 +368,28 @@ final class GiftOptionsScreenComponent: Component { var ribbon: GiftItemComponent.Ribbon? var isSoldOut = false - if case let .generic(gift) = gift { - if let _ = gift.soldOut { + switch gift { + case let .generic(gift): + if let availability = gift.availability, availability.resale > 0 { + //TODO:localize + //TODO:unmock ribbon = GiftItemComponent.Ribbon( - text: environment.strings.Gift_Options_Gift_SoldOut, - color: .red + text: "resale", + color: .green ) + } else if let _ = gift.soldOut { + if let availability = gift.availability, availability.resale > 0 { + //TODO:localize + ribbon = GiftItemComponent.Ribbon( + text: "resale", + color: .green + ) + } else { + ribbon = GiftItemComponent.Ribbon( + text: environment.strings.Gift_Options_Gift_SoldOut, + color: .red + ) + } isSoldOut = true } else if let _ = gift.availability { ribbon = GiftItemComponent.Ribbon( @@ -368,14 +397,31 @@ final class GiftOptionsScreenComponent: Component { color: .blue ) } + case let .unique(gift): + var ribbonColor: GiftItemComponent.Ribbon.Color = .blue + for attribute in gift.attributes { + if case let .backdrop(_, _, innerColor, outerColor, _, _, _) = attribute { + ribbonColor = .custom(outerColor, innerColor) + break + } + } + ribbon = GiftItemComponent.Ribbon( + text: "#\(gift.number)", + font: .monospaced, + color: ribbonColor + ) } let subject: GiftItemComponent.Subject switch gift { case let .generic(gift): - subject = .starGift(gift: gift, price: "⭐️ \(gift.price)") + if let availability = gift.availability, let minResaleStars = availability.minResaleStars { + subject = .starGift(gift: gift, price: "⭐️ \(minResaleStars)+") + } else { + subject = .starGift(gift: gift, price: "⭐️ \(gift.price)") + } case let .unique(gift): - subject = .uniqueGift(gift: gift) + subject = .uniqueGift(gift: gift, price: nil) } let _ = visibleItem.update( @@ -404,13 +450,27 @@ final class GiftOptionsScreenComponent: Component { mainController = controller } if case let .generic(gift) = gift { - if gift.availability?.remains == 0 { - let giftController = GiftViewScreen( - context: component.context, - subject: .soldOutGift(gift) - ) - mainController.push(giftController) - } else { + var forceStore = !"".isEmpty + #if DEBUG + forceStore = true + #endif + + if let availability = gift.availability, availability.remains == 0 || (availability.resale > 0 && forceStore) { + if availability.resale > 0 { + let storeController = component.context.sharedContext.makeGiftStoreController( + context: component.context, + peerId: component.peerId, + gift: gift + ) + mainController.push(storeController) + } else { + let giftController = GiftViewScreen( + context: component.context, + subject: .soldOutGift(gift) + ) + mainController.push(giftController) + } + } else { var forceUnique = false if let disallowedGifts = self.state?.disallowedGifts, disallowedGifts.contains(.limited) && !disallowedGifts.contains(.unique) { forceUnique = true @@ -475,6 +535,53 @@ final class GiftOptionsScreenComponent: Component { } } + + + var topPanelHeight = environment.navigationHeight + let tabSelectorThreshold = self.tabSelectorOrigin - 8.0 + if contentOffset > tabSelectorThreshold - environment.navigationHeight { + topPanelHeight += 39.0 + } + + if let tabSelectorView = self.tabSelector.view { + let tabSelectorSize = tabSelectorView.bounds.size + transition.setFrame(view: tabSelectorView, frame: CGRect(origin: CGPoint(x: floor((availableWidth - tabSelectorSize.width) / 2.0), y: max(56.0, self.tabSelectorOrigin - contentOffset)), size: tabSelectorSize)) + } + + var panelTransition = transition + if self.topPanel.view?.superview != nil && !self.switchingFilter { + panelTransition = .spring(duration: 0.3) + } + let topPanelSize = self.topPanel.update( + transition: panelTransition, + component: AnyComponent(BlurredBackgroundComponent( + color: environment.theme.rootController.navigationBar.blurredBackgroundColor + )), + environment: {}, + containerSize: CGSize(width: availableWidth, height: topPanelHeight) + ) + + let topSeparatorSize = self.topSeparator.update( + transition: panelTransition, + component: AnyComponent(Rectangle( + color: environment.theme.rootController.navigationBar.separatorColor + )), + environment: {}, + containerSize: CGSize(width: availableWidth, height: UIScreenPixel) + ) + let topPanelFrame = CGRect(origin: .zero, size: CGSize(width: availableWidth, height: topPanelSize.height)) + let topSeparatorFrame = CGRect(origin: CGPoint(x: 0.0, y: topPanelSize.height), size: CGSize(width: topSeparatorSize.width, height: topSeparatorSize.height)) + if let topPanelView = self.topPanel.view, let topSeparatorView = self.topSeparator.view { + if topPanelView.superview == nil { + if let headerView = self.header.view { + self.insertSubview(topSeparatorView, aboveSubview: headerView) + self.insertSubview(topPanelView, aboveSubview: headerView) + } + } + panelTransition.setFrame(view: topPanelView, frame: topPanelFrame) + panelTransition.setFrame(view: topSeparatorView, frame: topSeparatorFrame) + } + let bottomContentOffset = max(0.0, self.scrollView.contentSize.height - self.scrollView.contentOffset.y - self.scrollView.frame.height) if interactive, bottomContentOffset < 320.0, case .transfer = self.starsFilter { self.state?.starGiftsContext.loadMore() @@ -755,34 +862,34 @@ final class GiftOptionsScreenComponent: Component { } transition.setBounds(view: headerView, bounds: CGRect(origin: .zero, size: headerSize)) } - - let topPanelSize = self.topPanel.update( - transition: transition, - component: AnyComponent(BlurredBackgroundComponent( - color: theme.rootController.navigationBar.blurredBackgroundColor - )), - environment: {}, - containerSize: CGSize(width: availableSize.width, height: environment.navigationHeight) - ) - - let topSeparatorSize = self.topSeparator.update( - transition: transition, - component: AnyComponent(Rectangle( - color: theme.rootController.navigationBar.separatorColor - )), - environment: {}, - containerSize: CGSize(width: availableSize.width, height: UIScreenPixel) - ) - let topPanelFrame = CGRect(origin: .zero, size: CGSize(width: availableSize.width, height: topPanelSize.height)) - let topSeparatorFrame = CGRect(origin: CGPoint(x: 0.0, y: topPanelSize.height), size: CGSize(width: topSeparatorSize.width, height: topSeparatorSize.height)) - if let topPanelView = self.topPanel.view, let topSeparatorView = self.topSeparator.view { - if topPanelView.superview == nil { - self.addSubview(topPanelView) - self.addSubview(topSeparatorView) - } - transition.setFrame(view: topPanelView, frame: topPanelFrame) - transition.setFrame(view: topSeparatorView, frame: topSeparatorFrame) - } + +// let topPanelSize = self.topPanel.update( +// transition: transition, +// component: AnyComponent(BlurredBackgroundComponent( +// color: theme.rootController.navigationBar.blurredBackgroundColor +// )), +// environment: {}, +// containerSize: CGSize(width: availableSize.width, height: environment.navigationHeight) +// ) +// +// let topSeparatorSize = self.topSeparator.update( +// transition: transition, +// component: AnyComponent(Rectangle( +// color: theme.rootController.navigationBar.separatorColor +// )), +// environment: {}, +// containerSize: CGSize(width: availableSize.width, height: UIScreenPixel) +// ) +// let topPanelFrame = CGRect(origin: .zero, size: CGSize(width: availableSize.width, height: topPanelSize.height)) +// let topSeparatorFrame = CGRect(origin: CGPoint(x: 0.0, y: topPanelSize.height), size: CGSize(width: topSeparatorSize.width, height: topSeparatorSize.height)) +// if let topPanelView = self.topPanel.view, let topSeparatorView = self.topSeparator.view { +// if topPanelView.superview == nil { +// self.addSubview(topPanelView) +// self.addSubview(topSeparatorView) +// } +// transition.setFrame(view: topPanelView, frame: topPanelFrame) +// transition.setFrame(view: topSeparatorView, frame: topSeparatorFrame) +// } let cancelButtonSize = self.cancelButton.update( transition: transition, @@ -1186,13 +1293,17 @@ final class GiftOptionsScreenComponent: Component { } var hasLimited = false + var hasResale = false var starsAmountsSet = Set() if let starGifts = self.state?.starGifts { for gift in starGifts { if case let .generic(gift) = gift { starsAmountsSet.insert(gift.price) - if gift.availability != nil { + if let availability = gift.availability { hasLimited = true + if availability.resale > 0 { + hasResale = true + } } } } @@ -1210,6 +1321,14 @@ final class GiftOptionsScreenComponent: Component { title: strings.Gift_Options_Gift_Filter_InStock )) + if hasResale { + //TODO:localize + tabSelectorItems.append(TabSelectorComponent.Item( + id: AnyHashable(StarsFilter.resale.rawValue), + title: "Resale" + )) + } + let starsAmounts = Array(starsAmountsSet).sorted() for amount in starsAmounts { tabSelectorItems.append(TabSelectorComponent.Item( @@ -1235,17 +1354,26 @@ final class GiftOptionsScreenComponent: Component { } let starsFilter = StarsFilter(rawValue: idValue) if self.starsFilter != starsFilter { + if self.scrollView.contentOffset.y > self.tabSelectorOrigin - 56.0 { + self.scrollView.setContentOffset(CGPoint(x: 0.0, y: self.tabSelectorOrigin - 56.0), animated: true) + } + + self.switchingFilter = true self.starsFilter = starsFilter self.state?.updated(transition: .easeInOut(duration: 0.25)) + Queue.mainQueue().after(0.1, { + self.switchingFilter = false + }) } } )), environment: {}, containerSize: CGSize(width: availableSize.width - 10.0 * 2.0, height: 50.0) ) + self.tabSelectorOrigin = contentHeight if let tabSelectorView = self.tabSelector.view { if tabSelectorView.superview == nil { - self.scrollView.addSubview(tabSelectorView) + self.addSubview(tabSelectorView) } transition.setFrame(view: tabSelectorView, frame: CGRect(origin: CGPoint(x: floor((availableSize.width - tabSelectorSize.width) / 2.0), y: contentHeight), size: tabSelectorSize)) } diff --git a/submodules/TelegramUI/Components/Gifts/GiftStoreScreen/BUILD b/submodules/TelegramUI/Components/Gifts/GiftStoreScreen/BUILD new file mode 100644 index 0000000000..213684cbca --- /dev/null +++ b/submodules/TelegramUI/Components/Gifts/GiftStoreScreen/BUILD @@ -0,0 +1,51 @@ +load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library") + +swift_library( + name = "GiftStoreScreen", + module_name = "GiftStoreScreen", + srcs = glob([ + "Sources/**/*.swift", + ]), + copts = [ + "-warnings-as-errors", + ], + deps = [ + "//submodules/AsyncDisplayKit", + "//submodules/Display", + "//submodules/Postbox", + "//submodules/TelegramCore", + "//submodules/SSignalKit/SwiftSignalKit", + "//submodules/ComponentFlow", + "//submodules/Components/ViewControllerComponent", + "//submodules/Components/ComponentDisplayAdapters", + "//submodules/Components/MultilineTextComponent", + "//submodules/Components/BalancedTextComponent", + "//submodules/TelegramPresentationData", + "//submodules/AccountContext", + "//submodules/AppBundle", + "//submodules/ItemListUI", + "//submodules/TelegramStringFormatting", + "//submodules/PresentationDataUtils", + "//submodules/Components/SheetComponent", + "//submodules/UndoUI", + "//submodules/TextFormat", + "//submodules/TelegramUI/Components/ListSectionComponent", + "//submodules/TelegramUI/Components/ListActionItemComponent", + "//submodules/TelegramUI/Components/ScrollComponent", + "//submodules/TelegramUI/Components/PlainButtonComponent", + "//submodules/TelegramUI/Components/Premium/PremiumStarComponent", + "//submodules/Components/BlurredBackgroundComponent", + "//submodules/TelegramUI/Components/ButtonComponent", + "//submodules/Components/BundleIconComponent", + "//submodules/TelegramUI/Components/Gifts/GiftItemComponent", + "//submodules/ConfettiEffect", + "//submodules/InAppPurchaseManager", + "//submodules/TelegramUI/Components/TabSelectorComponent", + "//submodules/TelegramUI/Components/Gifts/GiftSetupScreen", + "//submodules/TelegramUI/Components/Gifts/GiftViewScreen", + "//submodules/TelegramUI/Components/LottieComponent", + ], + visibility = [ + "//visibility:public", + ], +) diff --git a/submodules/TelegramUI/Components/Gifts/GiftStoreScreen/Sources/FiltersComponent.swift b/submodules/TelegramUI/Components/Gifts/GiftStoreScreen/Sources/FiltersComponent.swift new file mode 100644 index 0000000000..eb8bff0a73 --- /dev/null +++ b/submodules/TelegramUI/Components/Gifts/GiftStoreScreen/Sources/FiltersComponent.swift @@ -0,0 +1,331 @@ +import Foundation +import UIKit +import Display +import ComponentFlow +import PlainButtonComponent +import MultilineTextWithEntitiesComponent +import BundleIconComponent +import TextFormat +import AccountContext + +public final class FilterSelectorComponent: Component { + public struct Colors: Equatable { + public var foreground: UIColor + public var background: UIColor + + public init( + foreground: UIColor, + background: UIColor + ) { + self.foreground = foreground + self.background = background + } + } + + public struct Item: Equatable { + public var id: AnyHashable + public var title: String + public var action: (UIView) -> Void + + public init( + id: AnyHashable, + title: String, + action: @escaping (UIView) -> Void + ) { + self.id = id + self.title = title + self.action = action + } + + public static func ==(lhs: Item, rhs: Item) -> Bool { + return lhs.id == rhs.id && lhs.title == rhs.title + } + } + + public let context: AccountContext? + public let colors: Colors + public let items: [Item] + + public init( + context: AccountContext? = nil, + colors: Colors, + items: [Item] + ) { + self.context = context + self.colors = colors + self.items = items + } + + public static func ==(lhs: FilterSelectorComponent, rhs: FilterSelectorComponent) -> Bool { + if lhs.context !== rhs.context { + return false + } + if lhs.colors != rhs.colors { + return false + } + if lhs.items != rhs.items { + return false + } + return true + } + + private final class VisibleItem { + let title = ComponentView() + + init() { + } + } + + public final class View: UIScrollView { + private var component: FilterSelectorComponent? + private weak var state: EmptyComponentState? + + private var visibleItems: [AnyHashable: VisibleItem] = [:] + + override init(frame: CGRect) { + super.init(frame: frame) + + self.showsVerticalScrollIndicator = false + self.showsHorizontalScrollIndicator = false + self.scrollsToTop = false + self.delaysContentTouches = false + self.canCancelContentTouches = true + self.contentInsetAdjustmentBehavior = .never + self.alwaysBounceVertical = false + self.clipsToBounds = false + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + } + + override public func touchesShouldCancel(in view: UIView) -> Bool { + return true + } + + func update(component: FilterSelectorComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + self.component = component + self.state = state + + let baseHeight: CGFloat = 28.0 + + var spacing: CGFloat = 6.0 + + let itemFont = Font.semibold(14.0) + let allowScroll = true + + var innerContentWidth: CGFloat = 0.0 + + var validIds: [AnyHashable] = [] + var index = 0 + var itemViews: [AnyHashable: (VisibleItem, CGSize, ComponentTransition)] = [:] + + for item in component.items { + var itemTransition = transition + let itemView: VisibleItem + if let current = self.visibleItems[item.id] { + itemView = current + } else { + itemView = VisibleItem() + self.visibleItems[item.id] = itemView + itemTransition = itemTransition.withAnimation(.none) + } + + let itemId = item.id + validIds.append(itemId) + + let itemSize = itemView.title.update( + transition: .immediate, + component: AnyComponent(PlainButtonComponent( + content: AnyComponent(ItemComponent( + context: component.context, + text: item.title, + font: itemFont, + color: component.colors.foreground, + backgroundColor: component.colors.background + )), + effectAlignment: .center, + minSize: nil, + action: { [weak itemView] in + if let view = itemView?.title.view { + item.action(view) + } + }, + animateScale: false + )), + environment: {}, + containerSize: CGSize(width: 200.0, height: 100.0) + ) + innerContentWidth += itemSize.width + itemViews[item.id] = (itemView, itemSize, itemTransition) + index += 1 + } + + let estimatedContentWidth = 2.0 * spacing + innerContentWidth + (CGFloat(component.items.count - 1) * spacing) + if estimatedContentWidth > availableSize.width && !allowScroll { + spacing = (availableSize.width - innerContentWidth) / CGFloat(component.items.count + 1) + } + + var contentWidth: CGFloat = spacing + for item in component.items { + guard let (itemView, itemSize, itemTransition) = itemViews[item.id] else { + continue + } + if contentWidth > spacing { + contentWidth += spacing + } + let itemFrame = CGRect(origin: CGPoint(x: contentWidth, y: floor((baseHeight - itemSize.height) * 0.5)), size: itemSize) + contentWidth = itemFrame.maxX + + if let itemTitleView = itemView.title.view { + if itemTitleView.superview == nil { + itemTitleView.layer.anchorPoint = CGPoint() + self.addSubview(itemTitleView) + } + itemTransition.setPosition(view: itemTitleView, position: itemFrame.origin) + itemTransition.setBounds(view: itemTitleView, bounds: CGRect(origin: CGPoint(), size: itemFrame.size)) + } + } + contentWidth += spacing + + var removeIds: [AnyHashable] = [] + for (id, itemView) in self.visibleItems { + if !validIds.contains(id) { + removeIds.append(id) + itemView.title.view?.removeFromSuperview() + } + } + for id in removeIds { + self.visibleItems.removeValue(forKey: id) + } + + self.contentSize = CGSize(width: contentWidth, height: baseHeight) + self.disablesInteractiveTransitionGestureRecognizer = contentWidth > availableSize.width + + return CGSize(width: min(contentWidth, availableSize.width), height: baseHeight) + } + } + + public func makeView() -> View { + return View(frame: CGRect()) + } + + public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } +} + +extension CGRect { + func interpolate(with other: CGRect, fraction: CGFloat) -> CGRect { + return CGRect( + x: self.origin.x * (1.0 - fraction) + (other.origin.x) * fraction, + y: self.origin.y * (1.0 - fraction) + (other.origin.y) * fraction, + width: self.size.width * (1.0 - fraction) + (other.size.width) * fraction, + height: self.size.height * (1.0 - fraction) + (other.size.height) * fraction + ) + } +} + +private final class ItemComponent: CombinedComponent { + let context: AccountContext? + let text: String + let font: UIFont + let color: UIColor + let backgroundColor: UIColor + + init( + context: AccountContext?, + text: String, + font: UIFont, + color: UIColor, + backgroundColor: UIColor + ) { + self.context = context + self.text = text + self.font = font + self.color = color + self.backgroundColor = backgroundColor + } + + static func ==(lhs: ItemComponent, rhs: ItemComponent) -> Bool { + if lhs.context !== rhs.context { + return false + } + if lhs.text != rhs.text { + return false + } + if lhs.font != rhs.font { + return false + } + if lhs.color != rhs.color { + return false + } + if lhs.backgroundColor != rhs.backgroundColor { + return false + } + return true + } + + static var body: Body { + let background = Child(RoundedRectangle.self) + let title = Child(MultilineTextWithEntitiesComponent.self) + let icon = Child(BundleIconComponent.self) + + return { context in + let component = context.component + + let attributedTitle = NSMutableAttributedString(string: component.text, font: component.font, textColor: component.color) + let range = (attributedTitle.string as NSString).range(of: "⭐️") + if range.location != NSNotFound { + attributedTitle.addAttribute(ChatTextInputAttributes.customEmoji, value: ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: nil, fileId: 0, file: nil, custom: .stars(tinted: false)), range: range) + } + + let title = title.update( + component: MultilineTextWithEntitiesComponent( + context: component.context, + animationCache: component.context?.animationCache, + animationRenderer: component.context?.animationRenderer, + placeholderColor: .white, + text: .plain(attributedTitle) + ), + availableSize: context.availableSize, + transition: .immediate + ) + + let icon = icon.update( + component: BundleIconComponent( + name: "Item List/ExpandableSelectorArrows", + tintColor: component.color + ), + availableSize: CGSize(width: 100, height: 100), + transition: .immediate + ) + + let padding: CGFloat = 12.0 + let spacing: CGFloat = 4.0 + let totalWidth = title.size.width + icon.size.width + spacing + let size = CGSize(width: totalWidth + padding * 2.0, height: 28.0) + let background = background.update( + component: RoundedRectangle( + color: component.backgroundColor, + cornerRadius: 14.0 + ), + availableSize: size, + transition: .immediate + ) + context.add(background + .position(CGPoint(x: size.width / 2.0, y: size.height / 2.0)) + ) + context.add(title + .position(CGPoint(x: padding + title.size.width / 2.0, y: size.height / 2.0)) + ) + context.add(icon + .position(CGPoint(x: size.width - padding - icon.size.width / 2.0, y: size.height / 2.0)) + ) + return size + } + } +} diff --git a/submodules/TelegramUI/Components/Gifts/GiftStoreScreen/Sources/GiftStoreScreen.swift b/submodules/TelegramUI/Components/Gifts/GiftStoreScreen/Sources/GiftStoreScreen.swift new file mode 100644 index 0000000000..75d49243ff --- /dev/null +++ b/submodules/TelegramUI/Components/Gifts/GiftStoreScreen/Sources/GiftStoreScreen.swift @@ -0,0 +1,1343 @@ +import Foundation +import UIKit +import Display +import AsyncDisplayKit +import SwiftSignalKit +import Postbox +import TelegramCore +import TelegramPresentationData +import TelegramUIPreferences +import PresentationDataUtils +import AccountContext +import ComponentFlow +import ViewControllerComponent +import MultilineTextComponent +import BalancedTextComponent +import BundleIconComponent +import Markdown +import TelegramStringFormatting +import PlainButtonComponent +import BlurredBackgroundComponent +import PremiumStarComponent +import TextFormat +import GiftItemComponent +import InAppPurchaseManager +import GiftViewScreen +import UndoUI +import ContextUI +import LottieComponent + +final class GiftStoreScreenComponent: Component { + typealias EnvironmentType = ViewControllerComponentContainer.Environment + + let context: AccountContext + let starsContext: StarsContext + let peerId: EnginePeer.Id + let gift: StarGift.Gift + + init( + context: AccountContext, + starsContext: StarsContext, + peerId: EnginePeer.Id, + gift: StarGift.Gift + ) { + self.context = context + self.starsContext = starsContext + self.peerId = peerId + self.gift = gift + } + + static func ==(lhs: GiftStoreScreenComponent, rhs: GiftStoreScreenComponent) -> Bool { + if lhs.context !== rhs.context { + return false + } + if lhs.peerId != rhs.peerId { + return false + } + if lhs.gift != rhs.gift { + return false + } + return true + } + + private final class ScrollView: UIScrollView { + override func touchesShouldCancel(in view: UIView) -> Bool { + return true + } + } + + final class View: UIView, UIScrollViewDelegate { + private let topOverscrollLayer = SimpleLayer() + private let scrollView: ScrollView + private let loadingNode: LoadingShimmerNode + private let emptyResultsAnimation = ComponentView() + private let emptyResultsTitle = ComponentView() + private let emptyResultsAction = ComponentView() + + private let topPanel = ComponentView() + private let topSeparator = ComponentView() + private let cancelButton = ComponentView() + private let sortButton = ComponentView() + + private let balanceTitle = ComponentView() + private let balanceValue = ComponentView() + private let balanceIcon = ComponentView() + + private let title = ComponentView() + private let subtitle = ComponentView() + + private var starsItems: [AnyHashable: ComponentView] = [:] + private let filterSelector = ComponentView() + private var isLoading = false + + private var isUpdating: Bool = false + + private var starsStateDisposable: Disposable? + private var starsState: StarsContext.State? + + private var component: GiftStoreScreenComponent? + private(set) weak var state: State? + private var environment: EnvironmentType? + + override init(frame: CGRect) { + self.scrollView = ScrollView() + self.scrollView.showsVerticalScrollIndicator = true + self.scrollView.showsHorizontalScrollIndicator = false + self.scrollView.scrollsToTop = false + self.scrollView.delaysContentTouches = false + self.scrollView.canCancelContentTouches = true + self.scrollView.contentInsetAdjustmentBehavior = .never + if #available(iOS 13.0, *) { + self.scrollView.automaticallyAdjustsScrollIndicatorInsets = false + } + self.scrollView.alwaysBounceVertical = true + + self.loadingNode = LoadingShimmerNode() + + super.init(frame: frame) + + self.scrollView.delegate = self + self.addSubview(self.scrollView) + self.addSubview(self.loadingNode.view) + + self.scrollView.layer.addSublayer(self.topOverscrollLayer) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + self.starsStateDisposable?.dispose() + } + + func scrollToTop() { + self.scrollView.setContentOffset(CGPoint(), animated: true) + } + + var nextScrollTransition: ComponentTransition? + func scrollViewDidScroll(_ scrollView: UIScrollView) { + self.updateScrolling(interactive: true, transition: self.nextScrollTransition ?? .immediate) + } + + private var currentGifts: ([StarGift], Set, Set, Set)? + private var effectiveGifts: [StarGift]? { + if let gifts = self.state?.starGiftsState?.gifts { + return gifts +// if self.selectedModels.isEmpty && self.selectedBackdrops.isEmpty && self.selectedSymbols.isEmpty { +// return gifts +// } else if let (currentGifts, currentModels, currentBackdrops, currentSymbols) = self.currentGifts, currentModels == self.selectedModels && currentBackdrops == self.selectedBackdrops && currentSymbols == self.selectedSymbols { +// return currentGifts +// } else { +// var filteredGifts: [StarGift] = [] +// for gift in gifts { +// guard case let .unique(uniqueGift) = gift else { +// continue +// } +// var match = true +// for attribute in uniqueGift.attributes { +// if case let .model(name, _, _) = attribute { +// if !self.selectedModels.isEmpty && !self.selectedModels.contains(name) { +// match = false +// } +// } +// if case let .backdrop(name, _, _, _, _, _, _) = attribute { +// if !self.selectedBackdrops.isEmpty && !self.selectedBackdrops.contains(name) { +// match = false +// } +// } +// if case let .pattern(name, _, _) = attribute { +// if !self.selectedSymbols.isEmpty && !self.selectedSymbols.contains(name) { +// match = false +// } +// } +// } +// if match { +// filteredGifts.append(gift) +// } +// } +// self.currentGifts = (filteredGifts, self.selectedModels, self.selectedBackdrops, self.selectedSymbols) +// return filteredGifts +// } + } else { + return nil + } + } + + private func updateScrolling(interactive: Bool = false, transition: ComponentTransition) { + guard let environment = self.environment, let component = self.component, !self.isLoading else { + return + } + + let availableWidth = self.scrollView.bounds.width + let contentOffset = self.scrollView.contentOffset.y + + let topPanelAlpha = min(20.0, max(0.0, contentOffset)) / 20.0 + if let topPanelView = self.topPanel.view, let topSeparator = self.topSeparator.view { + transition.setAlpha(view: topPanelView, alpha: topPanelAlpha) + transition.setAlpha(view: topSeparator, alpha: topPanelAlpha) + } + + let visibleBounds = self.scrollView.bounds.insetBy(dx: 0.0, dy: -10.0) + if let starGifts = self.effectiveGifts { + let sideInset: CGFloat = 16.0 + environment.safeInsets.left + + let optionSpacing: CGFloat = 10.0 + let optionWidth = (availableWidth - sideInset * 2.0 - optionSpacing * 2.0) / 3.0 + let starsOptionSize = CGSize(width: optionWidth, height: 154.0) + + var validIds: [AnyHashable] = [] + var itemFrame = CGRect(origin: CGPoint(x: sideInset, y: environment.navigationHeight + 39.0 + 9.0), size: starsOptionSize) + + let controller = environment.controller + + for gift in starGifts { + guard case let .unique(uniqueGift) = gift else { + continue + } + var isVisible = false + if visibleBounds.intersects(itemFrame) { + isVisible = true + } + + if isVisible { + let itemId = AnyHashable(gift.id) + validIds.append(itemId) + + var itemTransition = transition + let visibleItem: ComponentView + if let current = self.starsItems[itemId] { + visibleItem = current + } else { + visibleItem = ComponentView() + if !transition.animation.isImmediate { + itemTransition = .immediate + } + self.starsItems[itemId] = visibleItem + } + + var ribbon: GiftItemComponent.Ribbon? + var ribbonColor: GiftItemComponent.Ribbon.Color = .blue + for attribute in uniqueGift.attributes { + if case let .backdrop(_, _, innerColor, outerColor, _, _, _) = attribute { + ribbonColor = .custom(outerColor, innerColor) + break + } + } + ribbon = GiftItemComponent.Ribbon( + text: "#\(uniqueGift.number)", + font: .monospaced, + color: ribbonColor + ) + + let subject: GiftItemComponent.Subject = .uniqueGift(gift: uniqueGift, price: "⭐️\(uniqueGift.resellStars ?? 0)") + let _ = visibleItem.update( + transition: itemTransition, + component: AnyComponent( + PlainButtonComponent( + content: AnyComponent( + GiftItemComponent( + context: component.context, + theme: environment.theme, + strings: environment.strings, + peer: nil, + subject: subject, + ribbon: ribbon + ) + ), + effectAlignment: .center, + action: { [weak self] in + if let self, let component = self.component { + if let controller = controller() as? GiftStoreScreen { + let mainController: ViewController + if let parentController = controller.parentController() { + mainController = parentController + } else { + mainController = controller + } + let giftController = GiftViewScreen( + context: component.context, + subject: .uniqueGift(uniqueGift) + ) + mainController.push(giftController) + } + } + }, + animateAlpha: false + ) + ), + environment: {}, + containerSize: starsOptionSize + ) + if let itemView = visibleItem.view { + if itemView.superview == nil { + self.scrollView.addSubview(itemView) + } + itemTransition.setFrame(view: itemView, frame: itemFrame) + } + } + itemFrame.origin.x += itemFrame.width + optionSpacing + if itemFrame.maxX > availableWidth { + itemFrame.origin.x = sideInset + itemFrame.origin.y += starsOptionSize.height + optionSpacing + } + } + + var removeIds: [AnyHashable] = [] + for (id, item) in self.starsItems { + if !validIds.contains(id) { + removeIds.append(id) + if let itemView = item.view { + if !transition.animation.isImmediate { + itemView.layer.animateScale(from: 1.0, to: 0.01, duration: 0.25, removeOnCompletion: false) + itemView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, removeOnCompletion: false, completion: { _ in + itemView.removeFromSuperview() + }) + } else { + itemView.removeFromSuperview() + } + } + } + } + for id in removeIds { + self.starsItems.removeValue(forKey: id) + } + } + + let bottomContentOffset = max(0.0, self.scrollView.contentSize.height - self.scrollView.contentOffset.y - self.scrollView.frame.height) + if interactive, bottomContentOffset < 320.0 { + self.state?.starGiftsContext.loadMore() + } + } + + var selectedModels = Set() + var selectedBackdrops = Set() + var selectedSymbols = Set() + + private func simulateLoading() { + self.isLoading = true + self.state?.updated(transition: .immediate) + + Queue.mainQueue().after(1.0, { + self.isLoading = false + self.state?.updated(transition: .immediate) + }) + } + + func openContextMenu(sourceView: UIView) { + guard let component = self.component, let controller = self.environment?.controller() else { + return + } + + let presentationData = component.context.sharedContext.currentPresentationData.with { $0 } + + var items: [ContextMenuItem] = [] + items.append(.action(ContextMenuActionItem(text: "Sort by Price", icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Peer Info/SortValue"), color: theme.contextMenu.primaryColor) + }, action: { [weak self] _, f in + f(.default) + + self?.state?.starGiftsContext.updateSorting(.value) + }))) + items.append(.action(ContextMenuActionItem(text: "Sort by Date", icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Peer Info/SortDate"), color: theme.contextMenu.primaryColor) + }, action: { [weak self] _, f in + f(.default) + + self?.state?.starGiftsContext.updateSorting(.date) + }))) + items.append(.action(ContextMenuActionItem(text: "Sort by Number", icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Peer Info/SortNumber"), color: theme.contextMenu.primaryColor) + }, action: { [weak self] _, f in + f(.default) + + self?.state?.starGiftsContext.updateSorting(.number) + }))) + + items.append(.separator) + + items.append(.action(ContextMenuActionItem(text: "Model", textLayout: .secondLineWithValue("all models"), icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Peer Info/SortNumber"), color: theme.contextMenu.primaryColor) + }, action: { _, f in + f(.default) + + }))) + + items.append(.action(ContextMenuActionItem(text: "Backdrop", textLayout: .secondLineWithValue("all backdrops"), icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Peer Info/SortNumber"), color: theme.contextMenu.primaryColor) + }, action: { _, f in + f(.default) + + }))) + + items.append(.action(ContextMenuActionItem(text: "Symbol", textLayout: .secondLineWithValue("all symbols"), icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Peer Info/SortNumber"), color: theme.contextMenu.primaryColor) + }, action: { _, f in + f(.default) + + }))) + + let contextController = ContextController(context: component.context, presentationData: presentationData, source: .reference(GiftStoreReferenceContentSource(controller: controller, sourceView: sourceView)), items: .single(ContextController.Items(content: .list(items))), gesture: nil) + controller.presentInGlobalOverlay(contextController) + } + + func openSortContextMenu(sourceView: UIView) { + guard let component = self.component, let controller = self.environment?.controller() else { + return + } + + let presentationData = component.context.sharedContext.currentPresentationData.with { $0 } + + var items: [ContextMenuItem] = [] + items.append(.action(ContextMenuActionItem(text: "Sort by Price", icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Peer Info/SortValue"), color: theme.contextMenu.primaryColor) + }, action: { [weak self] _, f in + f(.default) + + self?.state?.starGiftsContext.updateSorting(.value) + }))) + items.append(.action(ContextMenuActionItem(text: "Sort by Date", icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Peer Info/SortDate"), color: theme.contextMenu.primaryColor) + }, action: { [weak self] _, f in + f(.default) + + self?.state?.starGiftsContext.updateSorting(.date) + }))) + items.append(.action(ContextMenuActionItem(text: "Sort by Number", icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Peer Info/SortNumber"), color: theme.contextMenu.primaryColor) + }, action: { [weak self] _, f in + f(.default) + + self?.state?.starGiftsContext.updateSorting(.number) + }))) + + let contextController = ContextController(presentationData: presentationData, source: .reference(GiftStoreReferenceContentSource(controller: controller, sourceView: sourceView)), items: .single(ContextController.Items(content: .list(items))), gesture: nil) + controller.presentInGlobalOverlay(contextController) + } + + func openModelContextMenu(sourceView: UIView) { + guard let component = self.component, let controller = self.environment?.controller() else { + return + } + + let presentationData = component.context.sharedContext.currentPresentationData.with { $0 } + + var items: [ContextMenuItem] = [] + var allSelected = true + + var currentFilterAttributes: [ResaleGiftsContext.Attribute] = [] + var selectedIds = Set() + + if let filterAttributes = self.state?.starGiftsState?.filterAttributes { + currentFilterAttributes = filterAttributes + for attribute in filterAttributes { + if case let .model(id) = attribute { + allSelected = false + selectedIds.insert(id) + } + } + } + items.append(.action(ContextMenuActionItem(text: "Select All", icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Select"), color: theme.contextMenu.primaryColor) + }, iconPosition: .left, action: { [weak self] _, f in + f(.default) + + if let self { + let updatedFilterAttributes = currentFilterAttributes.filter { attribute in + if case .model = attribute { + return false + } + return true + } + self.state?.starGiftsContext.updateFilterAttributes(updatedFilterAttributes) + } + }))) + + if let attributes = self.state?.starGiftsState?.attributes { + for attribute in attributes { + if case let .model(name, file, _) = attribute { + let isSelected = allSelected || selectedIds.contains(file.fileId.id) + + var entities: [MessageTextEntity] = [] + var entityFiles: [Int64: TelegramMediaFile] = [:] + entities = [ + MessageTextEntity( + range: 0..<1, + type: .CustomEmoji(stickerPack: nil, fileId: file.fileId.id) + ) + ] + entityFiles[file.fileId.id] = file + + var title = "# \(name)" + var count = "" + if let counter = self.state?.starGiftsState?.attributeCount[.model(file.fileId.id)] { + count = " \(presentationStringsFormattedNumber(counter, presentationData.dateTimeFormat.groupingSeparator))" + entities.append( + MessageTextEntity(range: title.count ..< title.count + count.count, type: .Bold) + ) + title += count + } + items.append(.action(ContextMenuActionItem(text: title, entities: entities, entityFiles: entityFiles, enableEntityAnimations: false, parseMarkdown: true, icon: { theme in + return isSelected ? generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Check"), color: theme.contextMenu.primaryColor) : nil + }, action: { [weak self] _, f in + f(.default) + + if let self { + var updatedFilterAttributes = currentFilterAttributes + if selectedIds.contains(file.fileId.id) { + updatedFilterAttributes.removeAll(where: { $0 == .model(file.fileId.id) }) + } else { + updatedFilterAttributes.append(.model(file.fileId.id)) + } + self.state?.starGiftsContext.updateFilterAttributes(updatedFilterAttributes) + } + }, longPressAction: { [weak self] _, f in + f(.default) + + if let self { + var updatedFilterAttributes = currentFilterAttributes.filter { attribute in + if case .model = attribute { + return false + } + return true + } + updatedFilterAttributes.append(.model(file.fileId.id)) + self.state?.starGiftsContext.updateFilterAttributes(updatedFilterAttributes) + } + }))) + } + } + } + + let contextController = ContextController(context: component.context, presentationData: presentationData, source: .reference(GiftStoreReferenceContentSource(controller: controller, sourceView: sourceView)), items: .single(ContextController.Items(content: .list(items))), gesture: nil) + controller.presentInGlobalOverlay(contextController) + } + + func openBackdropContextMenu(sourceView: UIView) { + guard let component = self.component, let controller = self.environment?.controller() else { + return + } + + let presentationData = component.context.sharedContext.currentPresentationData.with { $0 } + + var items: [ContextMenuItem] = [] + var allSelected = true + + var currentFilterAttributes: [ResaleGiftsContext.Attribute] = [] + var selectedIds = Set() + + if let filterAttributes = self.state?.starGiftsState?.filterAttributes { + currentFilterAttributes = filterAttributes + for attribute in filterAttributes { + if case let .backdrop(id) = attribute { + allSelected = false + selectedIds.insert(id) + } + } + } + items.append(.action(ContextMenuActionItem(text: "Select All", icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Select"), color: theme.contextMenu.primaryColor) + }, iconPosition: .left, action: { [weak self] _, f in + f(.default) + + if let self { + let updatedFilterAttributes = currentFilterAttributes.filter { attribute in + if case .backdrop = attribute { + return false + } + return true + } + self.state?.starGiftsContext.updateFilterAttributes(updatedFilterAttributes) + } + }))) + + if let attributes = self.state?.starGiftsState?.attributes { + for attribute in attributes { + if case let .backdrop(name, id, innerColor, outerColor, _, _, _) = attribute { + let isSelected = allSelected || selectedIds.contains(id) + + var entities: [MessageTextEntity] = [] + var title = "\(name)" + var count = "" + if let counter = self.state?.starGiftsState?.attributeCount[.backdrop(id)] { + count = " \(presentationStringsFormattedNumber(counter, presentationData.dateTimeFormat.groupingSeparator))" + entities.append( + MessageTextEntity(range: title.count ..< title.count + count.count, type: .Bold) + ) + title += count + } + items.append(.action(ContextMenuActionItem(text: "\(name)\(count)", entities: entities, icon: { theme in + return isSelected ? generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Check"), color: theme.contextMenu.primaryColor) : nil + }, additionalLeftIcon: { _ in + return generateGradientFilledCircleImage(diameter: 24.0, colors: [UIColor(rgb: UInt32(bitPattern: innerColor)).cgColor, UIColor(rgb: UInt32(bitPattern: outerColor)).cgColor]) + }, action: { [weak self] _, f in + f(.default) + + if let self { + var updatedFilterAttributes = currentFilterAttributes + if selectedIds.contains(id) { + updatedFilterAttributes.removeAll(where: { $0 == .backdrop(id) }) + } else { + updatedFilterAttributes.append(.backdrop(id)) + } + self.state?.starGiftsContext.updateFilterAttributes(updatedFilterAttributes) + } + }, longPressAction: { [weak self] _, f in + f(.default) + + if let self { + var updatedFilterAttributes = currentFilterAttributes.filter { attribute in + if case .backdrop = attribute { + return false + } + return true + } + updatedFilterAttributes.append(.backdrop(id)) + self.state?.starGiftsContext.updateFilterAttributes(updatedFilterAttributes) + } + }))) + } + } + } + + let contextController = ContextController(context: component.context, presentationData: presentationData, source: .reference(GiftStoreReferenceContentSource(controller: controller, sourceView: sourceView)), items: .single(ContextController.Items(content: .list(items))), gesture: nil) + controller.presentInGlobalOverlay(contextController) + } + + func openSymbolContextMenu(sourceView: UIView) { + guard let component = self.component, let controller = self.environment?.controller() else { + return + } + + let presentationData = component.context.sharedContext.currentPresentationData.with { $0 } + + var items: [ContextMenuItem] = [] + var allSelected = true + + var currentFilterAttributes: [ResaleGiftsContext.Attribute] = [] + var selectedIds = Set() + + if let filterAttributes = self.state?.starGiftsState?.filterAttributes { + currentFilterAttributes = filterAttributes + for attribute in filterAttributes { + if case let .pattern(id) = attribute { + allSelected = false + selectedIds.insert(id) + } + } + } + items.append(.action(ContextMenuActionItem(text: "Select All", icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Select"), color: theme.contextMenu.primaryColor) + }, iconPosition: .left, action: { [weak self] _, f in + f(.default) + + if let self { + let updatedFilterAttributes = currentFilterAttributes.filter { attribute in + if case .pattern = attribute { + return false + } + return true + } + self.state?.starGiftsContext.updateFilterAttributes(updatedFilterAttributes) + } + }))) + + if let attributes = self.state?.starGiftsState?.attributes { + for attribute in attributes { + if case let .pattern(name, file, _) = attribute { + let isSelected = allSelected || selectedIds.contains(file.fileId.id) + + var entities: [MessageTextEntity] = [] + var entityFiles: [Int64: TelegramMediaFile] = [:] + entities = [ + MessageTextEntity( + range: 0..<1, + type: .CustomEmoji(stickerPack: nil, fileId: file.fileId.id) + ) + ] + entityFiles[file.fileId.id] = file + + var title = "# \(name)" + var count = "" + if let counter = self.state?.starGiftsState?.attributeCount[.pattern(file.fileId.id)] { + count = " \(presentationStringsFormattedNumber(counter, presentationData.dateTimeFormat.groupingSeparator))" + entities.append( + MessageTextEntity(range: title.count ..< title.count + count.count, type: .Bold) + ) + title += count + } + items.append(.action(ContextMenuActionItem(text: title, entities: entities, entityFiles: entityFiles, enableEntityAnimations: false, icon: { theme in + return isSelected ? generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Check"), color: theme.contextMenu.primaryColor) : nil + }, action: { [weak self] _, f in + f(.default) + + if let self { + var updatedFilterAttributes = currentFilterAttributes + if selectedIds.contains(file.fileId.id) { + updatedFilterAttributes.removeAll(where: { $0 == .pattern(file.fileId.id) }) + } else { + updatedFilterAttributes.append(.pattern(file.fileId.id)) + } + self.state?.starGiftsContext.updateFilterAttributes(updatedFilterAttributes) + } + }, longPressAction: { [weak self] _, f in + f(.default) + + if let self { + var updatedFilterAttributes = currentFilterAttributes.filter { attribute in + if case .pattern = attribute { + return false + } + return true + } + updatedFilterAttributes.append(.pattern(file.fileId.id)) + self.state?.starGiftsContext.updateFilterAttributes(updatedFilterAttributes) + } + }))) + } + } + } + + let contextController = ContextController(context: component.context, presentationData: presentationData, source: .reference(GiftStoreReferenceContentSource(controller: controller, sourceView: sourceView)), items: .single(ContextController.Items(content: .list(items))), gesture: nil) + controller.presentInGlobalOverlay(contextController) + } + + func update(component: GiftStoreScreenComponent, availableSize: CGSize, state: State, environment: Environment, transition: ComponentTransition) -> CGSize { + self.isUpdating = true + defer { + self.isUpdating = false + } + + let environment = environment[EnvironmentType.self].value + let controller = environment.controller + let themeUpdated = self.environment?.theme !== environment.theme + self.environment = environment + self.state = state + + if self.component == nil { + self.starsStateDisposable = (component.starsContext.state + |> deliverOnMainQueue).start(next: { [weak self] state in + guard let self else { + return + } + self.starsState = state + if !self.isUpdating { + self.state?.updated() + } + }) + } + self.component = component + + let theme = environment.theme + let strings = environment.strings + + if themeUpdated { + self.backgroundColor = environment.theme.list.blocksBackgroundColor + } + + let bottomContentInset: CGFloat = 24.0 + let sideInset: CGFloat = 16.0 + environment.safeInsets.left + let headerSideInset: CGFloat = 24.0 + environment.safeInsets.left + + var contentHeight: CGFloat = 0.0 + contentHeight += environment.navigationHeight + + let topPanelHeight = environment.navigationHeight + 39.0 + + let topPanelSize = self.topPanel.update( + transition: transition, + component: AnyComponent(BlurredBackgroundComponent( + color: theme.rootController.navigationBar.blurredBackgroundColor + )), + environment: {}, + containerSize: CGSize(width: availableSize.width, height: topPanelHeight) + ) + + let topSeparatorSize = self.topSeparator.update( + transition: transition, + component: AnyComponent(Rectangle( + color: theme.rootController.navigationBar.separatorColor + )), + environment: {}, + containerSize: CGSize(width: availableSize.width, height: UIScreenPixel) + ) + let topPanelFrame = CGRect(origin: .zero, size: CGSize(width: availableSize.width, height: topPanelSize.height)) + let topSeparatorFrame = CGRect(origin: CGPoint(x: 0.0, y: topPanelSize.height), size: CGSize(width: topSeparatorSize.width, height: topSeparatorSize.height)) + if let topPanelView = self.topPanel.view, let topSeparatorView = self.topSeparator.view { + if topPanelView.superview == nil { + self.addSubview(topPanelView) + self.addSubview(topSeparatorView) + } + transition.setFrame(view: topPanelView, frame: topPanelFrame) + transition.setFrame(view: topSeparatorView, frame: topSeparatorFrame) + } + + let cancelButtonSize = self.cancelButton.update( + transition: transition, + component: AnyComponent( + PlainButtonComponent( + content: AnyComponent( + MultilineTextComponent( + text: .plain(NSAttributedString(string: strings.Common_Cancel, font: Font.regular(17.0), textColor: theme.rootController.navigationBar.accentTextColor)), + horizontalAlignment: .center + ) + ), + effectAlignment: .center, + action: { + controller()?.dismiss() + }, + animateScale: false + ) + ), + environment: {}, + containerSize: CGSize(width: availableSize.width, height: 100.0) + ) + let cancelButtonFrame = CGRect(origin: CGPoint(x: environment.safeInsets.left + 16.0, y: environment.statusBarHeight + (environment.navigationHeight - environment.statusBarHeight) / 2.0 - cancelButtonSize.height / 2.0), size: cancelButtonSize) + if let cancelButtonView = self.cancelButton.view { + if cancelButtonView.superview == nil { + self.addSubview(cancelButtonView) + } + transition.setFrame(view: cancelButtonView, frame: cancelButtonFrame) + } + +// let showFilters = !"".isEmpty + +// let sortButtonSize = self.sortButton.update( +// transition: transition, +// component: AnyComponent( +// PlainButtonComponent( +// content: AnyComponent( +// BundleIconComponent( +// name: "Peer Info/SortIcon", +// tintColor: theme.rootController.navigationBar.accentTextColor +// ) +// ), +// effectAlignment: .center, +// action: { [weak self] in +// if let sourceView = self?.sortButton.view { +// self?.openContextMenu(sourceView: sourceView) +// } +// }, +// animateScale: false +// ) +// ), +// environment: {}, +// containerSize: CGSize(width: availableSize.width, height: 100.0) +// ) +// let sortButtonFrame = CGRect(origin: CGPoint(x: availableSize.width - environment.safeInsets.right - sortButtonSize.width - 10.0, y: environment.statusBarHeight + (environment.navigationHeight - environment.statusBarHeight) / 2.0 - sortButtonSize.height / 2.0), size: sortButtonSize) +// if let sortButtonView = self.sortButton.view { +// if sortButtonView.superview == nil { +// self.addSubview(sortButtonView) +// } +// transition.setFrame(view: sortButtonView, frame: sortButtonFrame) +// transition.setAlpha(view: sortButtonView, alpha: showFilters ? 0.0 : 1.0) +// } + + let balanceTitleSize = self.balanceTitle.update( + transition: .immediate, + component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString( + string: strings.Stars_Purchase_Balance, + font: Font.regular(14.0), + textColor: environment.theme.actionSheet.primaryTextColor + )), + maximumNumberOfLines: 1 + )), + environment: {}, + containerSize: availableSize + ) + + let formattedBalance = formatStarsAmountText(self.starsState?.balance ?? StarsAmount.zero, dateTimeFormat: environment.dateTimeFormat) + let smallLabelFont = Font.regular(11.0) + let labelFont = Font.semibold(14.0) + let balanceText = tonAmountAttributedString(formattedBalance, integralFont: labelFont, fractionalFont: smallLabelFont, color: environment.theme.actionSheet.primaryTextColor, decimalSeparator: environment.dateTimeFormat.decimalSeparator) + + let balanceValueSize = self.balanceValue.update( + transition: .immediate, + component: AnyComponent(MultilineTextComponent( + text: .plain(balanceText), + maximumNumberOfLines: 1 + )), + environment: {}, + containerSize: availableSize + ) + let balanceIconSize = self.balanceIcon.update( + transition: .immediate, + component: AnyComponent(BundleIconComponent(name: "Premium/Stars/StarSmall", tintColor: nil)), + environment: {}, + containerSize: availableSize + ) + + if let balanceTitleView = self.balanceTitle.view, let balanceValueView = self.balanceValue.view, let balanceIconView = self.balanceIcon.view { + if balanceTitleView.superview == nil { + self.addSubview(balanceTitleView) + self.addSubview(balanceValueView) + self.addSubview(balanceIconView) + } + let navigationHeight = environment.navigationHeight - environment.statusBarHeight + let topBalanceOriginY = environment.statusBarHeight + (navigationHeight - balanceTitleSize.height - balanceValueSize.height) / 2.0 + balanceTitleView.center = CGPoint(x: availableSize.width - 16.0 - environment.safeInsets.right - balanceTitleSize.width / 2.0, y: topBalanceOriginY + balanceTitleSize.height / 2.0) + balanceTitleView.bounds = CGRect(origin: .zero, size: balanceTitleSize) + balanceValueView.center = CGPoint(x: availableSize.width - 16.0 - environment.safeInsets.right - balanceValueSize.width / 2.0, y: topBalanceOriginY + balanceTitleSize.height + balanceValueSize.height / 2.0) + balanceValueView.bounds = CGRect(origin: .zero, size: balanceValueSize) + balanceIconView.center = CGPoint(x: availableSize.width - 16.0 - environment.safeInsets.right - balanceValueSize.width - balanceIconSize.width / 2.0 - 2.0, y: topBalanceOriginY + balanceTitleSize.height + balanceValueSize.height / 2.0 - UIScreenPixel) + balanceIconView.bounds = CGRect(origin: .zero, size: balanceIconSize) + +// transition.setAlpha(view: balanceTitleView, alpha: showFilters ? 1.0 : 0.0) +// transition.setAlpha(view: balanceValueView, alpha: showFilters ? 1.0 : 0.0) +// transition.setAlpha(view: balanceIconView, alpha: showFilters ? 1.0 : 0.0) + } + + let titleSize = self.title.update( + transition: transition, + component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString(string: "Gift Name", font: Font.semibold(17.0), textColor: theme.rootController.navigationBar.primaryTextColor)), + horizontalAlignment: .center + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - headerSideInset * 2.0, height: 100.0) + ) + if let titleView = self.title.view { + if titleView.superview == nil { + self.addSubview(titleView) + } + transition.setFrame(view: titleView, frame: CGRect(origin: CGPoint(x: floor((availableSize.width - titleSize.width) / 2.0), y: 10.0), size: titleSize)) + } + + let subtitleSize = self.subtitle.update( + transition: transition, + component: AnyComponent(BalancedTextComponent( + text: .plain(NSAttributedString(string: "\(self.effectiveGifts?.count ?? 0) for resale", font: Font.regular(13.0), textColor: theme.rootController.navigationBar.secondaryTextColor)), + horizontalAlignment: .center, + maximumNumberOfLines: 1 + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - headerSideInset * 2.0, height: 100.0) + ) + let subtitleFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - subtitleSize.width) / 2.0), y: 31.0), size: subtitleSize) + if let subtitleView = self.subtitle.view { + if subtitleView.superview == nil { + self.addSubview(subtitleView) + } + transition.setFrame(view: subtitleView, frame: subtitleFrame) + } + + let optionSpacing: CGFloat = 10.0 + let optionWidth = (availableSize.width - sideInset * 2.0 - optionSpacing * 2.0) / 3.0 + + var sortingTitle = "Date" + if let sorting = self.state?.starGiftsState?.sorting { + switch sorting { + case .date: + sortingTitle = "Date" + case .value: + sortingTitle = "Price" + case .number: + sortingTitle = "Number" + } + } + + var filterItems: [FilterSelectorComponent.Item] = [] + filterItems.append(FilterSelectorComponent.Item( + id: AnyHashable(0), + title: sortingTitle, + action: { [weak self] view in + if let self { + self.openSortContextMenu(sourceView: view) + } + } + )) + + var modelTitle = "Model" + var backdropTitle = "Backdrop" + var symbolTitle = "Symbol" + if let filterAttributes = self.state?.starGiftsState?.filterAttributes { + var modelCount = 0 + var backdropCount = 0 + var symbolCount = 0 + + for attribute in filterAttributes { + switch attribute { + case .model: + modelCount += 1 + case .pattern: + symbolCount += 1 + case .backdrop: + backdropCount += 1 + } + } + + if modelCount > 0 { + if modelCount > 1 { + modelTitle = "\(modelCount) Models" + } else { + modelTitle = "1 Model" + } + } + if backdropCount > 0 { + if backdropCount > 1 { + backdropTitle = "\(backdropCount) Backdrops" + } else { + backdropTitle = "1 Backdrop" + } + } + if symbolCount > 0 { + if symbolCount > 1 { + symbolTitle = "\(symbolCount) Symbols" + } else { + symbolTitle = "1 Symbol" + } + } + } + + filterItems.append(FilterSelectorComponent.Item( + id: AnyHashable(1), + title: modelTitle, + action: { [weak self] view in + if let self { + self.openModelContextMenu(sourceView: view) + } + } + )) + filterItems.append(FilterSelectorComponent.Item( + id: AnyHashable(2), + title: backdropTitle, + action: { [weak self] view in + if let self { + self.openBackdropContextMenu(sourceView: view) + } + } + )) + filterItems.append(FilterSelectorComponent.Item( + id: AnyHashable(3), + title: symbolTitle, + action: { [weak self] view in + if let self { + self.openSymbolContextMenu(sourceView: view) + } + } + )) + + let filterSize = self.filterSelector.update( + transition: transition, + component: AnyComponent(FilterSelectorComponent( + context: component.context, + colors: FilterSelectorComponent.Colors( + foreground: theme.list.itemSecondaryTextColor, + background: theme.list.itemSecondaryTextColor.withMultipliedAlpha(0.15) + ), + items: filterItems + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - 10.0 * 2.0, height: 50.0) + ) + if let filterSelectorView = self.filterSelector.view { + if filterSelectorView.superview == nil { + self.addSubview(filterSelectorView) + } + transition.setFrame(view: filterSelectorView, frame: CGRect(origin: CGPoint(x: floor((availableSize.width - filterSize.width) / 2.0), y: 56.0), size: filterSize)) + } + + if let starGifts = self.state?.starGiftsState?.gifts { + let starsOptionSize = CGSize(width: optionWidth, height: 154.0) + let optionSpacing: CGFloat = 10.0 + contentHeight += ceil(CGFloat(starGifts.count) / 3.0) * (starsOptionSize.height + optionSpacing) + contentHeight += -optionSpacing + 66.0 + } + + contentHeight += bottomContentInset + contentHeight += environment.safeInsets.bottom + + let previousBounds = self.scrollView.bounds + + let contentSize = CGSize(width: availableSize.width, height: contentHeight) + if self.scrollView.frame != CGRect(origin: CGPoint(), size: availableSize) { + self.scrollView.frame = CGRect(origin: CGPoint(), size: availableSize) + } + if self.scrollView.contentSize != contentSize { + if contentSize.height < self.scrollView.contentSize.height, !transition.animation.isImmediate { + self.nextScrollTransition = transition + } + self.scrollView.contentSize = contentSize + self.nextScrollTransition = nil + } + let scrollInsets = UIEdgeInsets(top: environment.navigationHeight, left: 0.0, bottom: 0.0, right: 0.0) + if self.scrollView.scrollIndicatorInsets != scrollInsets { + self.scrollView.scrollIndicatorInsets = scrollInsets + } + + if !previousBounds.isEmpty, !transition.animation.isImmediate { + let bounds = self.scrollView.bounds + if bounds.maxY != previousBounds.maxY { + let offsetY = previousBounds.maxY - bounds.maxY + transition.animateBoundsOrigin(view: self.scrollView, from: CGPoint(x: 0.0, y: offsetY), to: CGPoint(), additive: true) + } + } + + self.topOverscrollLayer.frame = CGRect(origin: CGPoint(x: 0.0, y: -3000.0), size: CGSize(width: availableSize.width, height: 3000.0)) + + self.updateScrolling(transition: transition) + + var isLoading = false + if self.state?.starGiftsState?.gifts == nil || self.state?.starGiftsState?.dataState == .loading { + isLoading = true + } + + let loadingTransition: ComponentTransition = .easeInOut(duration: 0.25) + if isLoading { + self.loadingNode.update(size: availableSize, theme: environment.theme, transition: .immediate) + loadingTransition.setAlpha(view: self.loadingNode.view, alpha: 1.0) + } else { + loadingTransition.setAlpha(view: self.loadingNode.view, alpha: 0.0) + } + transition.setFrame(view: self.loadingNode.view, frame: CGRect(origin: CGPoint(x: 0.0, y: environment.navigationHeight + 39.0 + 7.0), size: availableSize)) + + let fadeTransition = ComponentTransition.easeInOut(duration: 0.25) + if let effectiveGifts = self.effectiveGifts, effectiveGifts.isEmpty && !self.isLoading { + let sideInset: CGFloat = 44.0 + let emptyAnimationHeight = 148.0 + let topInset: CGFloat = environment.navigationHeight + 39.0 + let bottomInset: CGFloat = environment.safeInsets.bottom + let visibleHeight = availableSize.height + let emptyAnimationSpacing: CGFloat = 20.0 + let emptyTextSpacing: CGFloat = 18.0 + + let emptyResultsTitleSize = self.emptyResultsTitle.update( + transition: .immediate, + component: AnyComponent( + MultilineTextComponent( + text: .plain(NSAttributedString(string: "No Matching Gifts", font: Font.semibold(17.0), textColor: theme.list.itemPrimaryTextColor)), + horizontalAlignment: .center + ) + ), + environment: {}, + containerSize: availableSize + ) + let emptyResultsActionSize = self.emptyResultsAction.update( + transition: .immediate, + component: AnyComponent( + PlainButtonComponent( + content: AnyComponent( + MultilineTextComponent( + text: .plain(NSAttributedString(string: "Clear Filters", font: Font.regular(17.0), textColor: theme.list.itemAccentColor)), + horizontalAlignment: .center, + maximumNumberOfLines: 0 + ) + ), + effectAlignment: .center, + action: { [weak self] in + guard let self else { + return + } + self.selectedModels.removeAll() + self.selectedBackdrops.removeAll() + self.selectedSymbols.removeAll() + self.simulateLoading() + }, + animateScale: false + ) + ), + environment: {}, + containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: visibleHeight) + ) + let emptyResultsAnimationSize = self.emptyResultsAnimation.update( + transition: .immediate, + component: AnyComponent(LottieComponent( + content: LottieComponent.AppBundleContent(name: "ChatListNoResults") + )), + environment: {}, + containerSize: CGSize(width: emptyAnimationHeight, height: emptyAnimationHeight) + ) + + let emptyTotalHeight = emptyAnimationHeight + emptyAnimationSpacing + emptyResultsTitleSize.height + emptyResultsActionSize.height + emptyTextSpacing + let emptyAnimationY = topInset + floorToScreenPixels((visibleHeight - topInset - bottomInset - emptyTotalHeight) / 2.0) + + let emptyResultsAnimationFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((availableSize.width - emptyResultsAnimationSize.width) / 2.0), y: emptyAnimationY), size: emptyResultsAnimationSize) + + let emptyResultsTitleFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((availableSize.width - emptyResultsTitleSize.width) / 2.0), y: emptyResultsAnimationFrame.maxY + emptyAnimationSpacing), size: emptyResultsTitleSize) + + let emptyResultsActionFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((availableSize.width - emptyResultsActionSize.width) / 2.0), y: emptyResultsTitleFrame.maxY + emptyTextSpacing), size: emptyResultsActionSize) + + if let view = self.emptyResultsAnimation.view as? LottieComponent.View { + if view.superview == nil { + view.alpha = 0.0 + fadeTransition.setAlpha(view: view, alpha: 1.0) + self.insertSubview(view, belowSubview: self.loadingNode.view) + view.playOnce() + } + view.bounds = CGRect(origin: .zero, size: emptyResultsAnimationFrame.size) + ComponentTransition.immediate.setPosition(view: view, position: emptyResultsAnimationFrame.center) + } + if let view = self.emptyResultsTitle.view { + if view.superview == nil { + view.alpha = 0.0 + fadeTransition.setAlpha(view: view, alpha: 1.0) + self.insertSubview(view, belowSubview: self.loadingNode.view) + } + view.bounds = CGRect(origin: .zero, size: emptyResultsTitleFrame.size) + ComponentTransition.immediate.setPosition(view: view, position: emptyResultsTitleFrame.center) + } + if let view = self.emptyResultsAction.view { + if view.superview == nil { + view.alpha = 0.0 + fadeTransition.setAlpha(view: view, alpha: 1.0) + self.insertSubview(view, belowSubview: self.loadingNode.view) + } + view.bounds = CGRect(origin: .zero, size: emptyResultsActionFrame.size) + ComponentTransition.immediate.setPosition(view: view, position: emptyResultsActionFrame.center) + } + } else { + if let view = self.emptyResultsAnimation.view { + fadeTransition.setAlpha(view: view, alpha: 0.0, completion: { _ in + view.removeFromSuperview() + }) + } + if let view = self.emptyResultsTitle.view { + fadeTransition.setAlpha(view: view, alpha: 0.0, completion: { _ in + view.removeFromSuperview() + }) + } + if let view = self.emptyResultsAction.view { + fadeTransition.setAlpha(view: view, alpha: 0.0, completion: { _ in + view.removeFromSuperview() + }) + } + } + + return availableSize + } + } + + func makeView() -> View { + return View() + } + + final class State: ComponentState { + private let context: AccountContext + private var disposable: Disposable? + + fileprivate let starGiftsContext: ResaleGiftsContext + fileprivate var starGiftsState: ResaleGiftsContext.State? + + init( + context: AccountContext, + giftId: Int64 + ) { + self.context = context + self.starGiftsContext = ResaleGiftsContext(account: context.account, giftId: giftId) + + super.init() + + self.disposable = (self.starGiftsContext.state + |> deliverOnMainQueue).start(next: { [weak self] state in + guard let self else { + return + } + self.starGiftsState = state + self.updated() + }) + } + + deinit { + self.disposable?.dispose() + } + } + + func makeState() -> State { + return State(context: self.context, giftId: self.gift.id) + } + + func update(view: View, availableSize: CGSize, state: State, environment: Environment, transition: ComponentTransition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } +} + +public class GiftStoreScreen: ViewControllerComponentContainer { + private let context: AccountContext + + public var parentController: () -> ViewController? = { + return nil + } + + public init( + context: AccountContext, + starsContext: StarsContext, + peerId: EnginePeer.Id, + gift: StarGift.Gift + ) { + self.context = context + + super.init(context: context, component: GiftStoreScreenComponent( + context: context, + starsContext: starsContext, + peerId: peerId, + gift: gift + ), navigationBarAppearance: .none, theme: .default, updatedPresentationData: nil) + + self.navigationItem.backBarButtonItem = UIBarButtonItem(title: self.context.sharedContext.currentPresentationData.with { $0 }.strings.Common_Back, style: .plain, target: nil, action: nil) + + + self.scrollToTop = { [weak self] in + guard let self, let componentView = self.node.hostView.componentView as? GiftStoreScreenComponent.View else { + return + } + componentView.scrollToTop() + } + } + + required public init(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + } + + override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { + super.containerLayoutUpdated(layout, transition: transition) + } +} + +private extension StarGift { + var id: String { + switch self { + case let .generic(gift): + return "\(gift.id)" + case let .unique(gift): + return gift.slug + } + } +} + +private final class GiftStoreReferenceContentSource: ContextReferenceContentSource { + private let controller: ViewController + private let sourceView: UIView + + init(controller: ViewController, sourceView: UIView) { + self.controller = controller + self.sourceView = sourceView + } + + func transitionInfo() -> ContextControllerReferenceViewInfo? { + return ContextControllerReferenceViewInfo(referenceView: self.sourceView, contentAreaInScreenSpace: UIScreen.main.bounds) + } +} diff --git a/submodules/TelegramUI/Components/Gifts/GiftStoreScreen/Sources/LoadingShimmerComponent.swift b/submodules/TelegramUI/Components/Gifts/GiftStoreScreen/Sources/LoadingShimmerComponent.swift new file mode 100644 index 0000000000..ed76cec440 --- /dev/null +++ b/submodules/TelegramUI/Components/Gifts/GiftStoreScreen/Sources/LoadingShimmerComponent.swift @@ -0,0 +1,185 @@ +import UIKit +import AsyncDisplayKit +import Display +import ComponentFlow +import TelegramPresentationData + +private final class SearchShimmerEffectNode: ASDisplayNode { + private var currentBackgroundColor: UIColor? + private var currentForegroundColor: UIColor? + private let imageNodeContainer: ASDisplayNode + private let imageNode: ASImageNode + + private var absoluteLocation: (CGRect, CGSize)? + private var isCurrentlyInHierarchy = false + private var shouldBeAnimating = false + + override init() { + self.imageNodeContainer = ASDisplayNode() + self.imageNodeContainer.isLayerBacked = true + + self.imageNode = ASImageNode() + self.imageNode.isLayerBacked = true + self.imageNode.displaysAsynchronously = false + self.imageNode.displayWithoutProcessing = true + self.imageNode.contentMode = .scaleToFill + + super.init() + + self.isLayerBacked = true + self.clipsToBounds = true + + self.imageNodeContainer.addSubnode(self.imageNode) + self.addSubnode(self.imageNodeContainer) + } + + override func didEnterHierarchy() { + super.didEnterHierarchy() + + self.isCurrentlyInHierarchy = true + self.updateAnimation() + } + + override func didExitHierarchy() { + super.didExitHierarchy() + + self.isCurrentlyInHierarchy = false + self.updateAnimation() + } + + func update(backgroundColor: UIColor, foregroundColor: UIColor) { + if let currentBackgroundColor = self.currentBackgroundColor, currentBackgroundColor.argb == backgroundColor.argb, let currentForegroundColor = self.currentForegroundColor, currentForegroundColor.argb == foregroundColor.argb { + return + } + self.currentBackgroundColor = backgroundColor + self.currentForegroundColor = foregroundColor + + self.imageNode.image = generateImage(CGSize(width: 4.0, height: 320.0), opaque: true, scale: 1.0, rotatedContext: { size, context in + context.setFillColor(backgroundColor.cgColor) + context.fill(CGRect(origin: CGPoint(), size: size)) + + context.clip(to: CGRect(origin: CGPoint(), size: size)) + + let transparentColor = foregroundColor.withAlphaComponent(0.0).cgColor + let peakColor = foregroundColor.cgColor + + var locations: [CGFloat] = [0.0, 0.5, 1.0] + let colors: [CGColor] = [transparentColor, peakColor, transparentColor] + + let colorSpace = CGColorSpaceCreateDeviceRGB() + let gradient = CGGradient(colorsSpace: colorSpace, colors: colors as CFArray, locations: &locations)! + + context.drawLinearGradient(gradient, start: CGPoint(x: 0.0, y: 0.0), end: CGPoint(x: 0.0, y: size.height), options: CGGradientDrawingOptions()) + }) + } + + func updateAbsoluteRect(_ rect: CGRect, within containerSize: CGSize) { + if let absoluteLocation = self.absoluteLocation, absoluteLocation.0 == rect && absoluteLocation.1 == containerSize { + return + } + let sizeUpdated = self.absoluteLocation?.1 != containerSize + let frameUpdated = self.absoluteLocation?.0 != rect + self.absoluteLocation = (rect, containerSize) + + if sizeUpdated { + if self.shouldBeAnimating { + self.imageNode.layer.removeAnimation(forKey: "shimmer") + self.addImageAnimation() + } + } + + if frameUpdated { + self.imageNodeContainer.frame = CGRect(origin: CGPoint(x: -rect.minX, y: -rect.minY), size: containerSize) + } + + self.updateAnimation() + } + + private func updateAnimation() { + let shouldBeAnimating = self.isCurrentlyInHierarchy && self.absoluteLocation != nil + if shouldBeAnimating != self.shouldBeAnimating { + self.shouldBeAnimating = shouldBeAnimating + if shouldBeAnimating { + self.addImageAnimation() + } else { + self.imageNode.layer.removeAnimation(forKey: "shimmer") + } + } + } + + private func addImageAnimation() { + guard let containerSize = self.absoluteLocation?.1 else { + return + } + let gradientHeight: CGFloat = 250.0 + self.imageNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -gradientHeight), size: CGSize(width: containerSize.width, height: gradientHeight)) + let animation = self.imageNode.layer.makeAnimation(from: 0.0 as NSNumber, to: (containerSize.height + gradientHeight) as NSNumber, keyPath: "position.y", timingFunction: CAMediaTimingFunctionName.easeOut.rawValue, duration: 1.3 * 1.0, delay: 0.0, mediaTimingFunction: nil, removeOnCompletion: true, additive: true) + animation.repeatCount = Float.infinity + animation.beginTime = 1.0 + self.imageNode.layer.add(animation, forKey: "shimmer") + } +} + + +final class LoadingShimmerNode: ASDisplayNode { + private let backgroundColorNode: ASDisplayNode + private let effectNode: SearchShimmerEffectNode + private let maskNode: ASImageNode + private var currentParams: (size: CGSize, theme: PresentationTheme)? + + override init() { + self.backgroundColorNode = ASDisplayNode() + self.effectNode = SearchShimmerEffectNode() + self.maskNode = ASImageNode() + + super.init() + + self.isUserInteractionEnabled = false + + self.addSubnode(self.backgroundColorNode) + self.addSubnode(self.effectNode) + self.addSubnode(self.maskNode) + } + + func update(size: CGSize, theme: PresentationTheme, transition: ContainedViewLayoutTransition) { + let color = theme.list.itemSecondaryTextColor.mixedWith(theme.list.blocksBackgroundColor, alpha: 0.85) + + if self.currentParams?.size != size || self.currentParams?.theme !== theme { + self.currentParams = (size, theme) + + self.backgroundColorNode.backgroundColor = color + + self.maskNode.image = generateImage(size, rotatedContext: { size, context in + context.setFillColor(theme.list.blocksBackgroundColor.cgColor) + context.fill(CGRect(origin: CGPoint(), size: size)) + + var currentY: CGFloat = 0.0 + var rowIndex: Int = 0 + + let sideInset: CGFloat = 16.0// + environment.safeInsets.left + let optionSpacing: CGFloat = 10.0 + let optionWidth = (size.width - sideInset * 2.0 - optionSpacing * 2.0) / 3.0 + let itemSize = CGSize(width: optionWidth, height: 154.0) + + context.setBlendMode(.copy) + context.setFillColor(UIColor.clear.cgColor) + + while currentY < size.height { + for i in 0 ..< 3 { + let itemOrigin = CGPoint(x: sideInset + CGFloat(i) * (itemSize.width + optionSpacing), y: 2.0 + CGFloat(rowIndex) * (itemSize.height + optionSpacing)) + context.addPath(CGPath(roundedRect: CGRect(origin: itemOrigin, size: itemSize), cornerWidth: 10.0, cornerHeight: 10.0, transform: nil)) + } + currentY += itemSize.height + rowIndex += 1 + } + context.fillPath() + }) + + self.effectNode.update(backgroundColor: color, foregroundColor: theme.list.itemBlocksBackgroundColor.withAlphaComponent(0.4)) + self.effectNode.updateAbsoluteRect(CGRect(origin: CGPoint(), size: size), within: size) + } + transition.updateFrame(node: self.backgroundColorNode, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: size)) + transition.updateFrame(node: self.maskNode, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: size)) + transition.updateFrame(node: self.effectNode, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: size)) + } +} diff --git a/submodules/TelegramUI/Components/Gifts/GiftViewScreen/Sources/GiftTransferAlertController.swift b/submodules/TelegramUI/Components/Gifts/GiftViewScreen/Sources/GiftTransferAlertController.swift index a5a063827f..ccfb5f2d93 100644 --- a/submodules/TelegramUI/Components/Gifts/GiftViewScreen/Sources/GiftTransferAlertController.swift +++ b/submodules/TelegramUI/Components/Gifts/GiftViewScreen/Sources/GiftTransferAlertController.swift @@ -164,7 +164,7 @@ private final class GiftTransferAlertContentNode: AlertContentNode { theme: self.presentationTheme, strings: self.strings, peer: nil, - subject: .uniqueGift(gift: self.gift), + subject: .uniqueGift(gift: self.gift, price: nil), mode: .thumbnail ) ), diff --git a/submodules/TelegramUI/Components/Gifts/GiftViewScreen/Sources/GiftUnpinScreen.swift b/submodules/TelegramUI/Components/Gifts/GiftViewScreen/Sources/GiftUnpinScreen.swift index 0070e7f7b3..e02517851e 100644 --- a/submodules/TelegramUI/Components/Gifts/GiftViewScreen/Sources/GiftUnpinScreen.swift +++ b/submodules/TelegramUI/Components/Gifts/GiftViewScreen/Sources/GiftUnpinScreen.swift @@ -159,7 +159,7 @@ private final class SheetContent: CombinedComponent { var ribbonColor: GiftItemComponent.Ribbon.Color = .blue for attribute in displayGift.attributes { - if case let .backdrop(_, innerColor, outerColor, _, _, _) = attribute { + if case let .backdrop(_, _, innerColor, outerColor, _, _, _) = attribute { ribbonColor = .custom(outerColor, innerColor) break } @@ -175,7 +175,7 @@ private final class SheetContent: CombinedComponent { context: component.context, theme: theme, strings: strings, - subject: .uniqueGift(gift: displayGift), + subject: .uniqueGift(gift: displayGift, price: nil), ribbon: GiftItemComponent.Ribbon(text: "#\(displayGift.number)", font: .monospaced, color: ribbonColor), mode: .grid ) diff --git a/submodules/TelegramUI/Components/Gifts/GiftViewScreen/Sources/GiftViewScreen.swift b/submodules/TelegramUI/Components/Gifts/GiftViewScreen/Sources/GiftViewScreen.swift index 8119ee549e..bb22cbe632 100644 --- a/submodules/TelegramUI/Components/Gifts/GiftViewScreen/Sources/GiftViewScreen.swift +++ b/submodules/TelegramUI/Components/Gifts/GiftViewScreen/Sources/GiftViewScreen.swift @@ -57,6 +57,7 @@ private final class GiftViewSheetContent: CombinedComponent { let transferGift: () -> Void let upgradeGift: ((Int64?, Bool) -> Signal) let shareGift: () -> Void + let resellGift: () -> Void let showAttributeInfo: (Any, String) -> Void let viewUpgraded: (EngineMessage.Id) -> Void let openMore: (ASDisplayNode, ContextGesture?) -> Void @@ -77,6 +78,7 @@ private final class GiftViewSheetContent: CombinedComponent { transferGift: @escaping () -> Void, upgradeGift: @escaping ((Int64?, Bool) -> Signal), shareGift: @escaping () -> Void, + resellGift: @escaping () -> Void, showAttributeInfo: @escaping (Any, String) -> Void, viewUpgraded: @escaping (EngineMessage.Id) -> Void, openMore: @escaping (ASDisplayNode, ContextGesture?) -> Void, @@ -96,6 +98,7 @@ private final class GiftViewSheetContent: CombinedComponent { self.transferGift = transferGift self.upgradeGift = upgradeGift self.shareGift = shareGift + self.resellGift = resellGift self.showAttributeInfo = showAttributeInfo self.viewUpgraded = viewUpgraded self.openMore = openMore @@ -126,6 +129,7 @@ private final class GiftViewSheetContent: CombinedComponent { var cachedCircleImage: UIImage? var cachedStarImage: (UIImage, PresentationTheme)? + var cachedSmallStarImage: (UIImage, PresentationTheme)? var cachedChevronImage: (UIImage, PresentationTheme)? var cachedSmallChevronImage: (UIImage, PresentationTheme)? @@ -138,6 +142,10 @@ private final class GiftViewSheetContent: CombinedComponent { var upgradeDisposable: Disposable? let levelsDisposable = MetaDisposable() + var buyForm: BotPaymentForm? + var buyFormDisposable: Disposable? + var buyDisposable: Disposable? + var inWearPreview = false var pendingWear = false var pendingTakeOff = false @@ -192,6 +200,17 @@ private final class GiftViewSheetContent: CombinedComponent { break } } + + if let _ = arguments.resellStars { + self.buyFormDisposable = (context.engine.payments.fetchBotPaymentForm(source: .starGiftResale(slug: gift.slug, toPeerId: context.account.peerId), themeParams: nil) + |> deliverOnMainQueue).start(next: { [weak self] paymentForm in + guard let self else { + return + } + self.buyForm = paymentForm + self.updated() + }) + } } else if case let .generic(gift) = arguments.gift { if arguments.canUpgrade || arguments.upgradeStars != nil { self.sampleDisposable.add((context.engine.payments.starGiftUpgradePreview(giftId: gift.id) @@ -266,6 +285,13 @@ private final class GiftViewSheetContent: CombinedComponent { self.optionsPromise.set(context.engine.payments.starsTopUpOptions() |> map(Optional.init)) } + + if let controller = getController() as? GiftViewScreen { + controller.updateSubject.connect { [weak self] subject in + self?.subject = subject + self?.updated() + } + } } deinit { @@ -273,6 +299,8 @@ private final class GiftViewSheetContent: CombinedComponent { self.sampleDisposable.dispose() self.upgradeFormDisposable?.dispose() self.upgradeDisposable?.dispose() + self.buyFormDisposable?.dispose() + self.buyDisposable?.dispose() self.levelsDisposable.dispose() } @@ -331,6 +359,99 @@ private final class GiftViewSheetContent: CombinedComponent { } } + func commitBuy() { + guard let arguments = self.subject.arguments, let _ = arguments.peerId, let starsContext = self.context.starsContext, let starsState = starsContext.currentState, case let .unique(uniqueGift) = arguments.gift else { + return + } + + let action = { + let proceed: (Int64) -> Void = { formId in + self.inProgress = true + self.updated() + + let signal = self.context.engine.payments.sendStarsPaymentForm(formId: formId, source: .starGiftResale(slug: uniqueGift.slug, toPeerId: self.context.account.peerId)) + |> mapError { _ -> SendBotPaymentFormError in + return .generic + } + |> mapToSignal { result in + if case let .done(_, _, gift) = result, let gift { + return .single(gift) + } else { + return .complete() + } + } + + self.buyDisposable = (signal + |> deliverOnMainQueue).start(next: { [weak self, weak starsContext] result in + guard let self, let controller = self.getController() as? GiftViewScreen else { + return + } + self.inProgress = false + + controller.animateSuccess() + self.updated(transition: .spring(duration: 0.4)) + + Queue.mainQueue().after(0.5) { + starsContext?.load(force: true) + } + }) + } + + if let buyForm = self.buyForm, let price = buyForm.invoice.prices.first?.amount { + if starsState.balance < StarsAmount(value: price, nanos: 0) { + let _ = (self.optionsPromise.get() + |> filter { $0 != nil } + |> take(1) + |> deliverOnMainQueue).startStandalone(next: { [weak self] options in + guard let self, let controller = self.getController() else { + return + } + let purchaseController = self.context.sharedContext.makeStarsPurchaseScreen( + context: self.context, + starsContext: starsContext, + options: options ?? [], + purpose: .upgradeStarGift(requiredStars: price), + completion: { [weak self, weak starsContext] stars in + guard let self, let starsContext else { + return + } + self.inProgress = true + self.updated() + + starsContext.add(balance: StarsAmount(value: stars, nanos: 0)) + let _ = (starsContext.onUpdate + |> deliverOnMainQueue).start(next: { + proceed(buyForm.id) + }) + } + ) + controller.push(purchaseController) + }) + } else { + proceed(buyForm.id) + } + } + } + let giftTitle = "\(uniqueGift.title) #\(uniqueGift.number)" + let alertController = textAlertController( + context: self.context, + title: "Confirm Payment", + text: "Do you really want to buy **\(giftTitle)** for **\(arguments.resellStars ?? 0)** Stars?", + actions: [ + TextAlertAction(type: .defaultAction, title: "Buy for \(arguments.resellStars ?? 0) Stars", action: { + action() + }), + TextAlertAction(type: .genericAction, title: "Cancel", action: { + }) + ], + actionLayout: .vertical, + parseMarkdown: true + ) + if let controller = self.getController() as? GiftViewScreen { + controller.present(alertController, in: .window(.root)) + } + } + func commitUpgrade() { guard let arguments = self.subject.arguments, let peerId = arguments.peerId, let starsContext = self.context.starsContext, let starsState = starsContext.currentState else { return @@ -352,8 +473,7 @@ private final class GiftViewSheetContent: CombinedComponent { self.inProgress = false self.inUpgradePreview = false - self.subject = .profileGift(peerId, result) - controller.subject = self.subject + controller.subject = .profileGift(peerId, result) controller.animateSuccess() self.updated(transition: .spring(duration: 0.4)) @@ -418,7 +538,8 @@ private final class GiftViewSheetContent: CombinedComponent { let transferButton = Child(PlainButtonComponent.self) let wearButton = Child(PlainButtonComponent.self) - let shareButton = Child(PlainButtonComponent.self) +// let shareButton = Child(PlainButtonComponent.self) + let resellButton = Child(PlainButtonComponent.self) let wearAvatar = Child(AvatarComponent.self) let wearPeerName = Child(MultilineTextComponent.self) @@ -1027,7 +1148,12 @@ private final class GiftViewSheetContent: CombinedComponent { var descriptionText: String if let uniqueGift { titleString = uniqueGift.title - descriptionText = "\(strings.Gift_Unique_Collectible) #\(presentationStringsFormattedNumber(uniqueGift.number, environment.dateTimeFormat.groupingSeparator))" + + if let resellPrice = uniqueGift.resellStars, incoming { + descriptionText = "\(strings.Gift_Unique_Collectible) #\(presentationStringsFormattedNumber(uniqueGift.number, environment.dateTimeFormat.groupingSeparator)) • Listed for * \(resellPrice)" + } else { + descriptionText = "\(strings.Gift_Unique_Collectible) #\(presentationStringsFormattedNumber(uniqueGift.number, environment.dateTimeFormat.groupingSeparator))" + } } else if soldOut { descriptionText = strings.Gift_View_UnavailableDescription } else if upgraded { @@ -1095,6 +1221,9 @@ private final class GiftViewSheetContent: CombinedComponent { if !descriptionText.isEmpty { let linkColor = theme.actionSheet.controlAccentColor + if state.cachedSmallStarImage == nil || state.cachedSmallStarImage?.1 !== environment.theme { + state.cachedSmallStarImage = (generateTintedImage(image: UIImage(bundleImageName: "Premium/Stars/ButtonStar"), color: .white)!, theme) + } if state.cachedChevronImage == nil || state.cachedChevronImage?.1 !== environment.theme { state.cachedChevronImage = (generateTintedImage(image: UIImage(bundleImageName: "Settings/TextArrowRight"), color: linkColor)!, theme) } @@ -1114,6 +1243,11 @@ private final class GiftViewSheetContent: CombinedComponent { descriptionText = descriptionText.replacingOccurrences(of: " >]", with: "\u{00A0}>]") let attributedString = parseMarkdownIntoAttributedString(descriptionText, attributes: markdownAttributes, textAlignment: .center).mutableCopy() as! NSMutableAttributedString + if let range = attributedString.string.range(of: "*"), let starImage = state.cachedSmallStarImage?.0 { + attributedString.addAttribute(.font, value: Font.regular(13.0), range: NSRange(range, in: attributedString.string)) + attributedString.addAttribute(.attachment, value: starImage, range: NSRange(range, in: attributedString.string)) + attributedString.addAttribute(.baselineOffset, value: 1.0, range: NSRange(range, in: attributedString.string)) + } if let range = attributedString.string.range(of: ">"), let chevronImage = state.cachedChevronImage?.0 { attributedString.addAttribute(.attachment, value: chevronImage, range: NSRange(range, in: attributedString.string)) } @@ -1221,7 +1355,7 @@ private final class GiftViewSheetContent: CombinedComponent { if case let .model(_, file, _) = attribute { fileId = file.fileId.id } - if case let .backdrop(_, innerColor, _, _, _, _) = attribute { + if case let .backdrop(_, _, innerColor, _, _, _, _) = attribute { color = UIColor(rgb: UInt32(bitPattern: innerColor)) } } @@ -1534,28 +1668,52 @@ private final class GiftViewSheetContent: CombinedComponent { ) buttonOriginX += buttonWidth + buttonSpacing - let shareButton = shareButton.update( + //TODO:localize + let resellButton = resellButton.update( component: PlainButtonComponent( content: AnyComponent( HeaderButtonComponent( - title: strings.Gift_View_Header_Share, - iconName: "Premium/Collectible/Share" + title: uniqueGift.resellStars == nil ? "sell" : "unlist", + iconName: uniqueGift.resellStars == nil ? "Premium/Collectible/Sell" : "Premium/Collectible/Unlist" ) ), effectAlignment: .center, action: { - component.shareGift() + component.resellGift() } ), environment: {}, availableSize: CGSize(width: buttonWidth, height: buttonHeight), transition: context.transition ) - context.add(shareButton + context.add(resellButton .position(CGPoint(x: buttonOriginX + buttonWidth / 2.0, y: headerHeight - buttonHeight / 2.0 - 16.0)) .appear(.default(scale: true, alpha: true)) .disappear(.default(scale: true, alpha: true)) ) + +// let shareButton = shareButton.update( +// component: PlainButtonComponent( +// content: AnyComponent( +// HeaderButtonComponent( +// title: strings.Gift_View_Header_Share, +// iconName: "Premium/Collectible/Share" +// ) +// ), +// effectAlignment: .center, +// action: { +// component.shareGift() +// } +// ), +// environment: {}, +// availableSize: CGSize(width: buttonWidth, height: buttonHeight), +// transition: context.transition +// ) +// context.add(shareButton +// .position(CGPoint(x: buttonOriginX + buttonWidth / 2.0, y: headerHeight - buttonHeight / 2.0 - 16.0)) +// .appear(.default(scale: true, alpha: true)) +// .disappear(.default(scale: true, alpha: true)) +// ) } let showAttributeInfo = component.showAttributeInfo @@ -1586,7 +1744,7 @@ private final class GiftViewSheetContent: CombinedComponent { value = NSAttributedString(string: name, font: tableFont, textColor: tableTextColor) percentage = Float(rarity) * 0.1 tag = modelButtonTag - case let .backdrop(name, _, _, _, _, rarity): + case let .backdrop(name, _, _, _, _, _, rarity): id = "backdrop" title = strings.Gift_Unique_Backdrop value = NSAttributedString(string: name, font: tableFont, textColor: tableTextColor) @@ -1907,14 +2065,27 @@ private final class GiftViewSheetContent: CombinedComponent { originY += table.size.height + 23.0 } - if ((incoming && !converted && !upgraded) || exported) && (!showUpgradePreview && !showWearPreview) { + var resellStars: Int64? + if let uniqueGift { + resellStars = uniqueGift.resellStars + } + if ((incoming && !converted && !upgraded) || exported || (!incoming && resellStars != nil)) && (!showUpgradePreview && !showWearPreview) { let linkColor = theme.actionSheet.controlAccentColor if state.cachedSmallChevronImage == nil || state.cachedSmallChevronImage?.1 !== environment.theme { state.cachedSmallChevronImage = (generateTintedImage(image: UIImage(bundleImageName: "Item List/InlineTextRightArrow"), color: linkColor)!, theme) } var addressToOpen: String? var descriptionText: String - if let uniqueGift, let address = uniqueGift.giftAddress, case .address = uniqueGift.owner { + if let uniqueGift, !incoming { + //TODO:localize + let ownerName: String + if case let .peerId(peerId) = uniqueGift.owner { + ownerName = state.peerMap[peerId]?.compactDisplayTitle ?? "" + } else { + ownerName = "" + } + descriptionText = "\(ownerName) is selling this gift and you can buy it." + } else if let uniqueGift, let address = uniqueGift.giftAddress, case .address = uniqueGift.owner { addressToOpen = address descriptionText = strings.Gift_View_TonGiftAddressInfo } else if savedToProfile { @@ -2114,14 +2285,15 @@ private final class GiftViewSheetContent: CombinedComponent { } var upgradeString = strings.Gift_Upgrade_Upgrade if let upgradeForm = state.upgradeForm, let price = upgradeForm.invoice.prices.first?.amount { - upgradeString += " # \(presentationStringsFormattedNumber(Int32(price), environment.dateTimeFormat.groupingSeparator))" + upgradeString += " # \(presentationStringsFormattedNumber(Int32(price), environment.dateTimeFormat.groupingSeparator))" } let buttonTitle = subject.arguments?.upgradeStars != nil ? strings.Gift_Upgrade_Confirm : upgradeString let buttonAttributedString = NSMutableAttributedString(string: buttonTitle, font: Font.semibold(17.0), textColor: theme.list.itemCheckColors.foregroundColor, paragraphAlignment: .center) if let range = buttonAttributedString.string.range(of: "#"), let starImage = state.cachedStarImage?.0 { buttonAttributedString.addAttribute(.attachment, value: starImage, range: NSRange(range, in: buttonAttributedString.string)) - buttonAttributedString.addAttribute(.foregroundColor, value: environment.theme.list.itemCheckColors.foregroundColor, range: NSRange(range, in: buttonAttributedString.string)) - buttonAttributedString.addAttribute(.baselineOffset, value: 1.0, range: NSRange(range, in: buttonAttributedString.string)) + buttonAttributedString.addAttribute(.foregroundColor, value: theme.list.itemCheckColors.foregroundColor, range: NSRange(range, in: buttonAttributedString.string)) + buttonAttributedString.addAttribute(.baselineOffset, value: 1.5, range: NSRange(range, in: buttonAttributedString.string)) + buttonAttributedString.addAttribute(.kern, value: 2.0, range: NSRange(range, in: buttonAttributedString.string)) } buttonChild = button.update( component: ButtonComponent( @@ -2205,6 +2377,36 @@ private final class GiftViewSheetContent: CombinedComponent { availableSize: buttonSize, transition: context.transition ) + } else if !incoming, let resellStars { + if state.cachedStarImage == nil || state.cachedStarImage?.1 !== theme { + state.cachedStarImage = (generateTintedImage(image: UIImage(bundleImageName: "Item List/PremiumIcon"), color: theme.list.itemCheckColors.foregroundColor)!, theme) + } + var upgradeString = "Buy for" + upgradeString += " # \(presentationStringsFormattedNumber(Int32(resellStars), environment.dateTimeFormat.groupingSeparator))" + + let buttonTitle = subject.arguments?.upgradeStars != nil ? strings.Gift_Upgrade_Confirm : upgradeString + let buttonAttributedString = NSMutableAttributedString(string: buttonTitle, font: Font.semibold(17.0), textColor: theme.list.itemCheckColors.foregroundColor, paragraphAlignment: .center) + if let range = buttonAttributedString.string.range(of: "#"), let starImage = state.cachedStarImage?.0 { + buttonAttributedString.addAttribute(.attachment, value: starImage, range: NSRange(range, in: buttonAttributedString.string)) + buttonAttributedString.addAttribute(.foregroundColor, value: theme.list.itemCheckColors.foregroundColor, range: NSRange(range, in: buttonAttributedString.string)) + buttonAttributedString.addAttribute(.baselineOffset, value: 1.5, range: NSRange(range, in: buttonAttributedString.string)) + buttonAttributedString.addAttribute(.kern, value: 2.0, range: NSRange(range, in: buttonAttributedString.string)) + } + buttonChild = button.update( + component: ButtonComponent( + background: buttonBackground, + content: AnyComponentWithIdentity( + id: AnyHashable("buy"), + component: AnyComponent(MultilineTextComponent(text: .plain(buttonAttributedString))) + ), + isEnabled: true, + displaysProgress: state.inProgress, + action: { [weak state] in + state?.commitBuy() + }), + availableSize: buttonSize, + transition: context.transition + ) } else { buttonChild = button.update( component: ButtonComponent( @@ -2256,6 +2458,7 @@ private final class GiftViewSheetComponent: CombinedComponent { let transferGift: () -> Void let upgradeGift: ((Int64?, Bool) -> Signal) let shareGift: () -> Void + let resellGift: () -> Void let viewUpgraded: (EngineMessage.Id) -> Void let openMore: (ASDisplayNode, ContextGesture?) -> Void let showAttributeInfo: (Any, String) -> Void @@ -2274,6 +2477,7 @@ private final class GiftViewSheetComponent: CombinedComponent { transferGift: @escaping () -> Void, upgradeGift: @escaping ((Int64?, Bool) -> Signal), shareGift: @escaping () -> Void, + resellGift: @escaping () -> Void, viewUpgraded: @escaping (EngineMessage.Id) -> Void, openMore: @escaping (ASDisplayNode, ContextGesture?) -> Void, showAttributeInfo: @escaping (Any, String) -> Void @@ -2291,6 +2495,7 @@ private final class GiftViewSheetComponent: CombinedComponent { self.transferGift = transferGift self.upgradeGift = upgradeGift self.shareGift = shareGift + self.resellGift = resellGift self.viewUpgraded = viewUpgraded self.openMore = openMore self.showAttributeInfo = showAttributeInfo @@ -2324,10 +2529,7 @@ private final class GiftViewSheetComponent: CombinedComponent { cancel: { animate in if animate { if let controller = controller() as? GiftViewScreen { - controller.dismissAllTooltips() - animateOut.invoke(Action { [weak controller] _ in - controller?.dismiss(completion: nil) - }) + controller.dismissAnimated() } } else if let controller = controller() { controller.dismiss(animated: false, completion: nil) @@ -2344,6 +2546,7 @@ private final class GiftViewSheetComponent: CombinedComponent { transferGift: context.component.transferGift, upgradeGift: context.component.upgradeGift, shareGift: context.component.shareGift, + resellGift: context.component.resellGift, showAttributeInfo: context.component.showAttributeInfo, viewUpgraded: context.component.viewUpgraded, openMore: context.component.openMore, @@ -2422,20 +2625,20 @@ public class GiftViewScreen: ViewControllerComponentContainer { case upgradePreview([StarGift.UniqueGift.Attribute], String) case wearPreview(StarGift.UniqueGift) - var arguments: (peerId: EnginePeer.Id?, fromPeerId: EnginePeer.Id?, fromPeerName: String?, messageId: EngineMessage.Id?, reference: StarGiftReference?, incoming: Bool, gift: StarGift, date: Int32, convertStars: Int64?, text: String?, entities: [MessageTextEntity]?, nameHidden: Bool, savedToProfile: Bool, pinnedToTop: Bool?, converted: Bool, upgraded: Bool, canUpgrade: Bool, upgradeStars: Int64?, transferStars: Int64?, canExportDate: Int32?, upgradeMessageId: Int32?)? { + var arguments: (peerId: EnginePeer.Id?, fromPeerId: EnginePeer.Id?, fromPeerName: String?, messageId: EngineMessage.Id?, reference: StarGiftReference?, incoming: Bool, gift: StarGift, date: Int32, convertStars: Int64?, text: String?, entities: [MessageTextEntity]?, nameHidden: Bool, savedToProfile: Bool, pinnedToTop: Bool?, converted: Bool, upgraded: Bool, refunded: Bool, canUpgrade: Bool, upgradeStars: Int64?, transferStars: Int64?, resellStars: Int64?, canExportDate: Int32?, upgradeMessageId: Int32?)? { switch self { case let .message(message): if let action = message.media.first(where: { $0 is TelegramMediaAction }) as? TelegramMediaAction { switch action.action { - case let .starGift(gift, convertStars, text, entities, nameHidden, savedToProfile, converted, upgraded, canUpgrade, upgradeStars, _, upgradeMessageId, peerId, senderId, savedId): + case let .starGift(gift, convertStars, text, entities, nameHidden, savedToProfile, converted, upgraded, canUpgrade, upgradeStars, isRefunded, upgradeMessageId, peerId, senderId, savedId): var reference: StarGiftReference if let peerId, let savedId { reference = .peer(peerId: peerId, id: savedId) } else { reference = .message(messageId: message.id) } - return (message.id.peerId, senderId ?? message.author?.id, message.author?.compactDisplayTitle, message.id, reference, message.flags.contains(.Incoming), gift, message.timestamp, convertStars, text, entities, nameHidden, savedToProfile, nil, converted, upgraded, canUpgrade, upgradeStars, nil, nil, upgradeMessageId) - case let .starGiftUnique(gift, isUpgrade, isTransferred, savedToProfile, canExportDate, transferStars, _, peerId, senderId, savedId): + return (message.id.peerId, senderId ?? message.author?.id, message.author?.compactDisplayTitle, message.id, reference, message.flags.contains(.Incoming), gift, message.timestamp, convertStars, text, entities, nameHidden, savedToProfile, nil, converted, upgraded, isRefunded, canUpgrade, upgradeStars, nil, nil, nil, upgradeMessageId) + case let .starGiftUnique(gift, isUpgrade, isTransferred, savedToProfile, canExportDate, transferStars, _, peerId, senderId, savedId, _): var reference: StarGiftReference if let peerId, let savedId { reference = .peer(peerId: peerId, id: savedId) @@ -2454,19 +2657,28 @@ public class GiftViewScreen: ViewControllerComponentContainer { } else { incoming = message.flags.contains(.Incoming) } - return (message.id.peerId, senderId ?? message.author?.id, message.author?.compactDisplayTitle, message.id, reference, incoming, gift, message.timestamp, nil, nil, nil, false, savedToProfile, nil, false, false, false, nil, transferStars, canExportDate, nil) + + var resellStars: Int64? + if case let .unique(uniqueGift) = gift { + resellStars = uniqueGift.resellStars + } + return (message.id.peerId, senderId ?? message.author?.id, message.author?.compactDisplayTitle, message.id, reference, incoming, gift, message.timestamp, nil, nil, nil, false, savedToProfile, nil, false, false, false, false, nil, transferStars, resellStars, canExportDate, nil) default: return nil } } case let .uniqueGift(gift), let .wearPreview(gift): - return (nil, nil, nil, nil, nil, false, .unique(gift), 0, nil, nil, nil, false, false, nil, false, false, false, nil, nil, nil, nil) + return (nil, nil, nil, nil, nil, false, .unique(gift), 0, nil, nil, nil, false, false, nil, false, false, false, false, nil, nil, nil, nil, nil) case let .profileGift(peerId, gift): var messageId: EngineMessage.Id? if case let .message(messageIdValue) = gift.reference { messageId = messageIdValue } - return (peerId, gift.fromPeer?.id, gift.fromPeer?.compactDisplayTitle, messageId, gift.reference, false, gift.gift, gift.date, gift.convertStars, gift.text, gift.entities, gift.nameHidden, gift.savedToProfile, gift.pinnedToTop, false, false, gift.canUpgrade, gift.upgradeStars, gift.transferStars, gift.canExportDate, nil) + var resellStars: Int64? + if case let .unique(uniqueGift) = gift.gift { + resellStars = uniqueGift.resellStars + } + return (peerId, gift.fromPeer?.id, gift.fromPeer?.compactDisplayTitle, messageId, gift.reference, false, gift.gift, gift.date, gift.convertStars, gift.text, gift.entities, gift.nameHidden, gift.savedToProfile, gift.pinnedToTop, false, false, false, gift.canUpgrade, gift.upgradeStars, gift.transferStars, resellStars, gift.canExportDate, nil) case .soldOutGift: return nil case .upgradePreview: @@ -2477,7 +2689,13 @@ public class GiftViewScreen: ViewControllerComponentContainer { } private let context: AccountContext - fileprivate var subject: GiftViewScreen.Subject + fileprivate var subject: GiftViewScreen.Subject { + didSet { + self.updateSubject.invoke(self.subject) + } + } + let updateSubject = ActionSlot() + public var disposed: () -> Void = {} fileprivate var showBalance = false { @@ -2497,6 +2715,7 @@ public class GiftViewScreen: ViewControllerComponentContainer { convertToStars: (() -> Void)? = nil, transferGift: ((Bool, EnginePeer.Id) -> Signal)? = nil, upgradeGift: ((Int64?, Bool) -> Signal)? = nil, + updateResellStars: ((Int64?) -> Void)? = nil, togglePinnedToTop: ((Bool) -> Bool)? = nil, shareStory: ((StarGift.UniqueGift) -> Void)? = nil ) { @@ -2514,10 +2733,11 @@ public class GiftViewScreen: ViewControllerComponentContainer { var transferGiftImpl: (() -> Void)? var upgradeGiftImpl: ((Int64?, Bool) -> Signal)? var shareGiftImpl: (() -> Void)? + var resellGiftImpl: (() -> Void)? var openMoreImpl: ((ASDisplayNode, ContextGesture?) -> Void)? var showAttributeInfoImpl: ((Any, String) -> Void)? var viewUpgradedImpl: ((EngineMessage.Id) -> Void)? - + super.init( context: context, component: GiftViewSheetComponent( @@ -2556,6 +2776,9 @@ public class GiftViewScreen: ViewControllerComponentContainer { shareGift: { shareGiftImpl?() }, + resellGift: { + resellGiftImpl?() + }, viewUpgraded: { messageId in viewUpgradedImpl?(messageId) }, @@ -2939,6 +3162,102 @@ public class GiftViewScreen: ViewControllerComponentContainer { self.present(shareController, in: .window(.root)) } + resellGiftImpl = { [weak self] in + guard let self, let arguments = self.subject.arguments, case let .profileGift(peerId, currentSubject) = self.subject, case let .unique(gift) = arguments.gift else { + return + } + + //TODO:localize + if let resellStars = gift.resellStars, resellStars > 0 { + let alertController = textAlertController( + context: context, + title: "Unlist This Item?", + text: "It will no longer be for sale.", + actions: [ + TextAlertAction(type: .defaultAction, title: "Unlist", action: { [weak self] in + guard let self else { + return + } + + self.subject = .profileGift(peerId, currentSubject.withGift(.unique(gift.withResellStars(nil)))) + + let giftTitle = "\(gift.title) #\(gift.number)" + let presentationData = context.sharedContext.currentPresentationData.with { $0 } + let text = "\(giftTitle) is removed from sale." + let tooltipController = UndoOverlayController( + presentationData: presentationData, + content: .universalImage( + image: generateTintedImage(image: UIImage(bundleImageName: "Premium/Collectible/Unlist"), color: .white)!, + size: nil, + title: nil, + text: text, + customUndoText: nil, + timeout: 3.0 + ), + position: .bottom, + animateInAsReplacement: false, + appearance: UndoOverlayController.Appearance(sideInset: 16.0, bottomInset: 62.0), + action: { action in + return false + } + ) + self.present(tooltipController, in: .window(.root)) + + if let updateResellStars { + updateResellStars(nil) + } else { + let _ = (context.engine.payments.updateStarGiftResalePrice(slug: gift.slug, price: nil) + |> deliverOnMainQueue).startStandalone() + } + }), + TextAlertAction(type: .genericAction, title: presentationData.strings.Common_Cancel, action: { + }) + ], + actionLayout: .vertical + ) + self.present(alertController, in: .window(.root)) + } else { + let resellController = context.sharedContext.makeStarGiftResellScreen(context: context, completion: { [weak self] price in + guard let self else { + return + } + + self.subject = .profileGift(peerId, currentSubject.withGift(.unique(gift.withResellStars(price)))) + + let giftTitle = "\(gift.title) #\(gift.number)" + let presentationData = context.sharedContext.currentPresentationData.with { $0 } + let text = "\(giftTitle) is now for sale!" + + let tooltipController = UndoOverlayController( + presentationData: presentationData, + content: .universalImage( + image: generateTintedImage(image: UIImage(bundleImageName: "Premium/Collectible/Sell"), color: .white)!, + size: nil, + title: nil, + text: text, + customUndoText: nil, + timeout: 3.0 + ), + position: .bottom, + animateInAsReplacement: false, + appearance: UndoOverlayController.Appearance(sideInset: 16.0, bottomInset: 62.0), + action: { action in + return false + } + ) + self.present(tooltipController, in: .window(.root)) + + if let updateResellStars { + updateResellStars(price) + } else { + let _ = (context.engine.payments.updateStarGiftResalePrice(slug: gift.slug, price: price) + |> deliverOnMainQueue).startStandalone() + } + }) + self.push(resellController) + } + } + viewUpgradedImpl = { [weak self] messageId in guard let self, let navigationController = self.navigationController as? NavigationController else { return @@ -3066,6 +3385,13 @@ public class GiftViewScreen: ViewControllerComponentContainer { super.viewDidLoad() self.view.disablesInteractiveModalDismiss = true + + if let arguments = self.subject.arguments, let _ = self.subject.arguments?.resellStars { + if case let .unique(uniqueGift) = arguments.gift, case .peerId(self.context.account.peerId) = uniqueGift.owner { + } else { + self.showBalance = true + } + } } public override func viewWillDisappear(_ animated: Bool) { diff --git a/submodules/TelegramUI/Components/Gifts/GiftViewScreen/Sources/GiftWithdrawAlertController.swift b/submodules/TelegramUI/Components/Gifts/GiftViewScreen/Sources/GiftWithdrawAlertController.swift index 6fb6a9d67f..517e0a777d 100644 --- a/submodules/TelegramUI/Components/Gifts/GiftViewScreen/Sources/GiftWithdrawAlertController.swift +++ b/submodules/TelegramUI/Components/Gifts/GiftViewScreen/Sources/GiftWithdrawAlertController.swift @@ -148,7 +148,7 @@ private final class GiftWithdrawAlertContentNode: AlertContentNode { theme: self.presentationTheme, strings: self.strings, peer: nil, - subject: .uniqueGift(gift: self.gift), + subject: .uniqueGift(gift: self.gift, price: nil), mode: .thumbnail ) ), diff --git a/submodules/TelegramUI/Components/MarqueeComponent/BUILD b/submodules/TelegramUI/Components/MarqueeComponent/BUILD new file mode 100644 index 0000000000..543348ff1a --- /dev/null +++ b/submodules/TelegramUI/Components/MarqueeComponent/BUILD @@ -0,0 +1,19 @@ +load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library") + +swift_library( + name = "MarqueeComponent", + module_name = "MarqueeComponent", + srcs = glob([ + "Sources/**/*.swift", + ]), + copts = [ + "-warnings-as-errors", + ], + deps = [ + "//submodules/Display", + "//submodules/ComponentFlow", + ], + visibility = [ + "//visibility:public", + ], +) diff --git a/submodules/TelegramUI/Components/MarqueeComponent/Sources/MarqueeComponent.swift b/submodules/TelegramUI/Components/MarqueeComponent/Sources/MarqueeComponent.swift new file mode 100644 index 0000000000..6b1dac5eeb --- /dev/null +++ b/submodules/TelegramUI/Components/MarqueeComponent/Sources/MarqueeComponent.swift @@ -0,0 +1,180 @@ +import Foundation +import UIKit +import Display +import ComponentFlow + +private let animationDuration: TimeInterval = 12.0 +private let animationDelay: TimeInterval = 2.0 +private let spacing: CGFloat = 20.0 + +public final class MarqueeComponent: Component { + public static let innerPadding: CGFloat = 16.0 + + private final class MeasureState: Equatable { + let attributedText: NSAttributedString + let availableSize: CGSize + let size: CGSize + + init(attributedText: NSAttributedString, availableSize: CGSize, size: CGSize) { + self.attributedText = attributedText + self.availableSize = availableSize + self.size = size + } + + static func ==(lhs: MeasureState, rhs: MeasureState) -> Bool { + if !lhs.attributedText.isEqual(rhs.attributedText) { + return false + } + if lhs.availableSize != rhs.availableSize { + return false + } + if lhs.size != rhs.size { + return false + } + return true + } + } + + public final class View: UIView { + private var measureState: MeasureState? + private let containerLayer = SimpleLayer() + private let textLayer = SimpleLayer() + private let duplicateTextLayer = SimpleLayer() + private let gradientMaskLayer = SimpleGradientLayer() + private var isAnimating = false + private var isOverflowing = false + + override init(frame: CGRect) { + super.init(frame: frame) + + self.clipsToBounds = true + self.containerLayer.masksToBounds = true + self.layer.addSublayer(self.containerLayer) + + self.containerLayer.addSublayer(self.textLayer) + self.containerLayer.addSublayer(self.duplicateTextLayer) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public func update(component: MarqueeComponent, availableSize: CGSize) -> CGSize { + let attributedText = component.attributedText + if let measureState = self.measureState { + if measureState.attributedText.isEqual(to: attributedText) && measureState.availableSize == availableSize { + return measureState.size + } + } + + var boundingRect = attributedText.boundingRect(with: CGSize(width: 10000, height: availableSize.height), options: .usesLineFragmentOrigin, context: nil) + boundingRect.size.width = ceil(boundingRect.size.width) + boundingRect.size.height = ceil(boundingRect.size.height) + + let measureState = MeasureState(attributedText: attributedText, availableSize: availableSize, size: boundingRect.size) + self.measureState = measureState + + self.containerLayer.frame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: measureState.size.width + innerPadding * 2.0, height: measureState.size.height)) + + let isOverflowing = boundingRect.width > availableSize.width + + let renderer = UIGraphicsImageRenderer(bounds: CGRect(origin: CGPoint(), size: measureState.size)) + let image = renderer.image { context in + UIGraphicsPushContext(context.cgContext) + measureState.attributedText.draw(at: CGPoint()) + UIGraphicsPopContext() + } + + if isOverflowing { + self.setupMarqueeTextLayers(textImage: image.cgImage!, textWidth: boundingRect.width, containerWidth: availableSize.width) + self.setupGradientMask(size: CGSize(width: availableSize.width, height: boundingRect.height)) + self.startAnimation() + } else { + self.stopAnimation() + self.textLayer.frame = CGRect(origin: CGPoint(x: innerPadding, y: 0.0), size: boundingRect.size) + self.textLayer.contents = image.cgImage + self.duplicateTextLayer.frame = .zero + self.duplicateTextLayer.contents = nil + self.layer.mask = nil + } + + return CGSize(width: min(measureState.size.width + innerPadding * 2.0, availableSize.width), height: measureState.size.height) + } + + private func setupMarqueeTextLayers(textImage: CGImage, textWidth: CGFloat, containerWidth: CGFloat) { + self.textLayer.frame = CGRect(x: innerPadding, y: 0, width: textWidth, height: self.containerLayer.bounds.height) + self.textLayer.contents = textImage + + self.duplicateTextLayer.frame = CGRect(x: innerPadding + textWidth + spacing, y: 0, width: textWidth, height: self.containerLayer.bounds.height) + self.duplicateTextLayer.contents = textImage + + self.containerLayer.frame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: textWidth * 2.0 + spacing, height: self.containerLayer.bounds.height)) + } + + private func setupGradientMask(size: CGSize) { + self.gradientMaskLayer.frame = CGRect(origin: .zero, size: size) + self.gradientMaskLayer.colors = [ + UIColor.clear.cgColor, + UIColor.clear.cgColor, + UIColor.black.cgColor, + UIColor.black.cgColor, + UIColor.clear.cgColor, + UIColor.clear.cgColor + ] + self.gradientMaskLayer.startPoint = CGPoint(x: 0.0, y: 0.5) + self.gradientMaskLayer.endPoint = CGPoint(x: 1.0, y: 0.5) + + let edgePercentage = innerPadding / size.width + self.gradientMaskLayer.locations = [ + 0.0, + NSNumber(value: edgePercentage * 0.4), + NSNumber(value: edgePercentage), + NSNumber(value: 1.0 - edgePercentage), + NSNumber(value: 1.0 - edgePercentage * 0.4), + 1.0 + ] + + self.layer.mask = self.gradientMaskLayer + } + + private func startAnimation() { + guard !self.isAnimating else { + return + } + self.isAnimating = true + + self.containerLayer.removeAllAnimations() + + self.containerLayer.animateBoundsOriginXAdditive(from: 0.0, to: self.textLayer.frame.width + spacing, duration: animationDuration, delay: animationDelay, timingFunction: CAMediaTimingFunctionName.linear.rawValue, completion: { _ in + self.isAnimating = false + self.startAnimation() + }) + } + + private func stopAnimation() { + self.containerLayer.removeAllAnimations() + self.isAnimating = false + } + } + + public let attributedText: NSAttributedString + + public init(attributedText: NSAttributedString) { + self.attributedText = attributedText + } + + public static func ==(lhs: MarqueeComponent, rhs: MarqueeComponent) -> Bool { + if lhs.attributedText != rhs.attributedText { + return false + } + return true + } + + public func makeView() -> View { + return View() + } + + public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + return view.update(component: self, availableSize: availableSize) + } +} diff --git a/submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreen.swift b/submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreen.swift index 3699cfe229..d77af5a4c4 100644 --- a/submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreen.swift +++ b/submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreen.swift @@ -326,6 +326,8 @@ final class MediaEditorScreenComponent: Component { private let switchCameraButton = ComponentView() + private let selectionButton = ComponentView() + private let textCancelButton = ComponentView() private let textDoneButton = ComponentView() private let textSize = ComponentView() @@ -335,6 +337,8 @@ final class MediaEditorScreenComponent: Component { private var isEditingCaption = false private var currentInputMode: MessageInputPanelComponent.InputMode = .text + private var isSelectionPanelOpen = false + private var didInitializeInputMediaNodeDataPromise = false private var inputMediaNodeData: ChatEntityKeyboardInputNode.InputData? private var inputMediaNodeDataPromise = Promise() @@ -1988,6 +1992,40 @@ final class MediaEditorScreenComponent: Component { transition.setScale(view: switchCameraButtonView, scale: isRecordingAdditionalVideo ? 1.0 : 0.01) transition.setAlpha(view: switchCameraButtonView, alpha: isRecordingAdditionalVideo ? 1.0 : 0.0) } + + + let selectionButtonSize = self.selectionButton.update( + transition: transition, + component: AnyComponent(PlainButtonComponent( + content: AnyComponent( + SelectionPanelButtonContentComponent( + count: 1, + isSelected: self.isSelectionPanelOpen, + tag: nil + ) + ), + effectAlignment: .center, + action: { [weak self] in + if let self { + self.isSelectionPanelOpen = !self.isSelectionPanelOpen + self.state?.updated() + } + } + )), + environment: {}, + containerSize: CGSize(width: 33.0, height: 33.0) + ) + let selectionButtonFrame = CGRect( + origin: CGPoint(x: availableSize.width - selectionButtonSize.width - 12.0, y: max(environment.statusBarHeight + 10.0, inputPanelFrame.minY - selectionButtonSize.height - 3.0)), + size: selectionButtonSize + ) + if let selectionButtonView = self.selectionButton.view { + if selectionButtonView.superview == nil { + self.addSubview(selectionButtonView) + } + transition.setPosition(view: selectionButtonView, position: selectionButtonFrame.center) + transition.setBounds(view: selectionButtonView, bounds: CGRect(origin: .zero, size: selectionButtonFrame.size)) + } } else { inputPanelSize = CGSize(width: 0.0, height: 12.0) } @@ -3407,7 +3445,7 @@ public final class MediaEditorScreenImpl: ViewController, MediaEditorScreen, UID } } else if case let .gift(gift) = effectiveSubject { isGift = true - let media: [Media] = [TelegramMediaAction(action: .starGiftUnique(gift: .unique(gift), isUpgrade: false, isTransferred: false, savedToProfile: false, canExportDate: nil, transferStars: nil, isRefunded: false, peerId: nil, senderId: nil, savedId: nil))] + let media: [Media] = [TelegramMediaAction(action: .starGiftUnique(gift: .unique(gift), isUpgrade: false, isTransferred: false, savedToProfile: false, canExportDate: nil, transferStars: nil, isRefunded: false, peerId: nil, senderId: nil, savedId: nil, resaleStars: nil))] let message = Message(stableId: 0, stableVersion: 0, id: MessageId(peerId: self.context.account.peerId, namespace: Namespaces.Message.Cloud, id: -1), globallyUniqueId: nil, groupingKey: nil, groupInfo: nil, threadId: nil, timestamp: 0, flags: [], tags: [], globalTags: [], localTags: [], customTags: [], forwardInfo: nil, author: nil, text: "", attributes: [], media: media, peers: SimpleDictionary(), associatedMessages: SimpleDictionary(), associatedMessageIds: [], associatedMedia: [:], associatedThreadInfo: nil, associatedStories: [:]) messages = .single([message]) } else { diff --git a/submodules/TelegramUI/Components/MediaEditorScreen/Sources/SelectionPanelButtonContentComponent.swift b/submodules/TelegramUI/Components/MediaEditorScreen/Sources/SelectionPanelButtonContentComponent.swift new file mode 100644 index 0000000000..cbe16e36f0 --- /dev/null +++ b/submodules/TelegramUI/Components/MediaEditorScreen/Sources/SelectionPanelButtonContentComponent.swift @@ -0,0 +1,140 @@ +import Foundation +import UIKit +import Display +import ComponentFlow + +final class SelectionPanelButtonContentComponent: Component { + let count: Int32 + let isSelected: Bool + let tag: AnyObject? + + init( + count: Int32, + isSelected: Bool, + tag: AnyObject? + ) { + self.count = count + self.isSelected = isSelected + self.tag = tag + } + + static func ==(lhs: SelectionPanelButtonContentComponent, rhs: SelectionPanelButtonContentComponent) -> Bool { + return lhs.count == rhs.count && lhs.isSelected == rhs.isSelected + } + + final class View: UIView, ComponentTaggedView { + private var component: SelectionPanelButtonContentComponent? + public func matches(tag: Any) -> Bool { + if let component = self.component, let componentTag = component.tag { + let tag = tag as AnyObject + if componentTag === tag { + return true + } + } + return false + } + + private let backgroundView: BlurredBackgroundView + private let outline = SimpleLayer() + private let icon = SimpleLayer() + private let label = ComponentView() + + init() { + self.backgroundView = BlurredBackgroundView(color: UIColor(white: 0.2, alpha: 0.45), enableBlur: true) + self.icon.opacity = 0.0 + + super.init(frame: CGRect()) + + self.addSubview(self.backgroundView) + self.layer.addSublayer(self.icon) + self.layer.addSublayer(self.outline) + + self.outline.contents = generateImage(CGSize(width: 33.0, height: 33.0), rotatedContext: { size, context in + let bounds = CGRect(origin: .zero, size: size) + context.clear(bounds) + let lineWidth: CGFloat = 2.0 - UIScreenPixel + context.setLineWidth(lineWidth) + context.setStrokeColor(UIColor.white.cgColor) + context.strokeEllipse(in: bounds.insetBy(dx: lineWidth / 2.0, dy: lineWidth / 2.0)) + })?.cgImage + + self.icon.contents = generateImage(CGSize(width: 33.0, height: 33.0), rotatedContext: { size, context in + let bounds = CGRect(origin: .zero, size: size) + context.clear(bounds) + let lineWidth: CGFloat = 2.0 - UIScreenPixel + context.setLineWidth(lineWidth) + context.setStrokeColor(UIColor.white.cgColor) + + context.move(to: CGPoint(x: 11.0, y: 11.0)) + context.addLine(to: CGPoint(x: size.width - 11.0, y: size.height - 11.0)) + context.strokePath() + + context.move(to: CGPoint(x: size.width - 11.0, y: 11.0)) + context.addLine(to: CGPoint(x: 11.0, y: size.height - 11.0)) + context.strokePath() + })?.cgImage + } + + required init?(coder aDecoder: NSCoder) { + preconditionFailure() + } + + func update(component: SelectionPanelButtonContentComponent, availableSize: CGSize, transition: ComponentTransition) -> CGSize { + let previousComponent = self.component + self.component = component + + let size = CGSize(width: 33.0, height: 33.0) + let backgroundFrame = CGRect(origin: .zero, size: size) + + self.backgroundView.frame = backgroundFrame + self.backgroundView.update(size: backgroundFrame.size, cornerRadius: backgroundFrame.width / 2.0, transition: .immediate) + + self.icon.position = CGPoint(x: size.width / 2.0, y: size.height / 2.0) + self.icon.bounds = CGRect(origin: .zero, size: size) + + self.outline.position = CGPoint(x: size.width / 2.0, y: size.height / 2.0) + self.outline.bounds = CGRect(origin: .zero, size: size) + + let labelSize = self.label.update( + transition: .immediate, + component: AnyComponent( + Text( + text: "\(component.count)", + font: Font.with(size: 18.0, design: .round, weight: .semibold), + color: .white + ) + ), + environment: {}, + containerSize: size + ) + let labelFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - labelSize.width) / 2.0), y: floorToScreenPixels((size.height - labelSize.height) / 2.0)), size: labelSize) + if let labelView = self.label.view { + if labelView.superview == nil { + self.addSubview(labelView) + } + labelView.center = labelFrame.center + labelView.bounds = CGRect(origin: .zero, size: labelFrame.size) + } + + if (previousComponent?.isSelected ?? false) != component.isSelected { + let changeTransition: ComponentTransition = .easeInOut(duration: 0.2) + changeTransition.setAlpha(layer: self.icon, alpha: component.isSelected ? 1.0 : 0.0) + changeTransition.setTransform(layer: self.icon, transform: !component.isSelected ? CATransform3DMakeRotation(.pi / 4.0, 0.0, 0.0, 1.0) : CATransform3DIdentity) + if let labelView = self.label.view { + changeTransition.setAlpha(view: labelView, alpha: component.isSelected ? 0.0 : 1.0) + changeTransition.setTransform(view: labelView, transform: component.isSelected ? CATransform3DMakeRotation(-.pi / 4.0, 0.0, 0.0, 1.0) : CATransform3DIdentity) + } + } + + return size + } + } + + func makeView() -> View { + return View() + } + + func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + return view.update(component: self, availableSize: availableSize, transition: transition) + } +} diff --git a/submodules/TelegramUI/Components/MediaEditorScreen/Sources/SelectionPanelComponent.swift b/submodules/TelegramUI/Components/MediaEditorScreen/Sources/SelectionPanelComponent.swift new file mode 100644 index 0000000000..e0edb237b4 --- /dev/null +++ b/submodules/TelegramUI/Components/MediaEditorScreen/Sources/SelectionPanelComponent.swift @@ -0,0 +1,7 @@ + +import Foundation +import UIKit +import Display +import ComponentFlow + + diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoCoverComponent/Sources/PeerInfoGiftsCoverComponent.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoCoverComponent/Sources/PeerInfoGiftsCoverComponent.swift index b45b22dcfe..81ba25209d 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoCoverComponent/Sources/PeerInfoGiftsCoverComponent.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoCoverComponent/Sources/PeerInfoGiftsCoverComponent.swift @@ -497,7 +497,7 @@ private class GiftIconLayer: SimpleLayer { for attribute in gift.attributes { if case let .model(_, fileValue, _) = attribute { file = fileValue - } else if case let .backdrop(_, innerColor, _, _, _, _) = attribute { + } else if case let .backdrop(_, _, innerColor, _, _, _, _) = attribute { color = UIColor(rgb: UInt32(bitPattern: innerColor)) } } @@ -563,7 +563,7 @@ private class GiftIconLayer: SimpleLayer { for attribute in gift.attributes { if case let .model(_, fileValue, _) = attribute { file = fileValue - } else if case let .backdrop(_, innerColor, _, _, _, _) = attribute { + } else if case let .backdrop(_, _, innerColor, _, _, _, _) = attribute { color = UIColor(rgb: UInt32(bitPattern: innerColor)) } } diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoGiftsPaneNode.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoGiftsPaneNode.swift index 5344a720b0..94d4c21e13 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoGiftsPaneNode.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoGiftsPaneNode.swift @@ -477,42 +477,52 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr itemTransition = .immediate } - let ribbonText: String? + var ribbonText: String? var ribbonColor: GiftItemComponent.Ribbon.Color = .blue var ribbonFont: GiftItemComponent.Ribbon.Font = .generic + var ribbonOutline: UIColor? + + let peer: GiftItemComponent.Peer? + let subject: GiftItemComponent.Subject + var resellPrice: Int64? + switch product.gift { case let .generic(gift): + subject = .starGift(gift: gift, price: "⭐️ \(gift.price)") + peer = product.fromPeer.flatMap { .peer($0) } ?? .anonymous + if let availability = gift.availability { ribbonText = params.presentationData.strings.PeerInfo_Gifts_OneOf(compactNumericCountString(Int(availability.total), decimalSeparator: params.presentationData.dateTimeFormat.decimalSeparator)).string } else { ribbonText = nil } case let .unique(gift): - if product.pinnedToTop { - ribbonFont = .monospaced - ribbonText = "#\(gift.number)" + subject = .uniqueGift(gift: gift, price: nil) + peer = nil + resellPrice = gift.resellStars + + if let _ = resellPrice { + //TODO:localize + ribbonText = "sale" + ribbonFont = .larger + ribbonColor = .green + ribbonOutline = params.presentationData.theme.list.blocksBackgroundColor } else { - ribbonText = params.presentationData.strings.PeerInfo_Gifts_OneOf(compactNumericCountString(Int(gift.availability.issued), decimalSeparator: params.presentationData.dateTimeFormat.decimalSeparator)).string - } - for attribute in gift.attributes { - if case let .backdrop(_, innerColor, outerColor, _, _, _) = attribute { - ribbonColor = .custom(outerColor, innerColor) - break + if product.pinnedToTop { + ribbonFont = .monospaced + ribbonText = "#\(gift.number)" + } else { + ribbonText = params.presentationData.strings.PeerInfo_Gifts_OneOf(compactNumericCountString(Int(gift.availability.issued), decimalSeparator: params.presentationData.dateTimeFormat.decimalSeparator)).string + } + for attribute in gift.attributes { + if case let .backdrop(_, _, innerColor, outerColor, _, _, _) = attribute { + ribbonColor = .custom(outerColor, innerColor) + break + } } } } - - let peer: GiftItemComponent.Peer? - let subject: GiftItemComponent.Subject - switch product.gift { - case let .generic(gift): - subject = .starGift(gift: gift, price: "⭐️ \(gift.price)") - peer = product.fromPeer.flatMap { .peer($0) } ?? .anonymous - case let .unique(gift): - subject = .uniqueGift(gift: gift) - peer = nil - } - + let _ = visibleItem.update( transition: itemTransition, component: AnyComponent( @@ -522,7 +532,8 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr strings: params.presentationData.strings, peer: peer, subject: subject, - ribbon: ribbonText.flatMap { GiftItemComponent.Ribbon(text: $0, font: ribbonFont, color: ribbonColor) }, + ribbon: ribbonText.flatMap { GiftItemComponent.Ribbon(text: $0, font: ribbonFont, color: ribbonColor, outline: ribbonOutline) }, + resellPrice: resellPrice, isHidden: !product.savedToProfile, isPinned: product.pinnedToTop, isEditing: self.isReordering, @@ -589,6 +600,12 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr } return self.profileGifts.upgradeStarGift(formId: formId, reference: reference, keepOriginalInfo: keepOriginalInfo) }, + updateResellStars: { [weak self] price in + guard let self, case let .unique(uniqueGift) = product.gift else { + return + } + self.profileGifts.updateStarGiftResellPrice(slug: uniqueGift.slug, price: price) + }, togglePinnedToTop: { [weak self] pinnedToTop in guard let self else { return false diff --git a/submodules/TelegramUI/Components/Settings/PeerNameColorScreen/Sources/GiftListItemComponent.swift b/submodules/TelegramUI/Components/Settings/PeerNameColorScreen/Sources/GiftListItemComponent.swift index 8db0756d2d..615e3ad7cf 100644 --- a/submodules/TelegramUI/Components/Settings/PeerNameColorScreen/Sources/GiftListItemComponent.swift +++ b/submodules/TelegramUI/Components/Settings/PeerNameColorScreen/Sources/GiftListItemComponent.swift @@ -125,7 +125,7 @@ final class GiftListItemComponent: Component { theme: component.theme, strings: component.context.sharedContext.currentPresentationData.with { $0 }.strings, peer: nil, - subject: .uniqueGift(gift: gift), + subject: .uniqueGift(gift: gift, price: nil), ribbon: nil, isHidden: false, isSelected: gift.id == component.selectedId, diff --git a/submodules/TelegramUI/Components/Settings/PeerNameColorScreen/Sources/UserApperanceScreen.swift b/submodules/TelegramUI/Components/Settings/PeerNameColorScreen/Sources/UserApperanceScreen.swift index 0bbf2fa9fe..303da9e6ba 100644 --- a/submodules/TelegramUI/Components/Settings/PeerNameColorScreen/Sources/UserApperanceScreen.swift +++ b/submodules/TelegramUI/Components/Settings/PeerNameColorScreen/Sources/UserApperanceScreen.swift @@ -456,10 +456,11 @@ final class UserAppearanceScreenComponent: Component { attributes: [ .model(name: "", file: file, rarity: 0), .pattern(name: "", file: patternFile, rarity: 0), - .backdrop(name: "", innerColor: innerColor, outerColor: outerColor, patternColor: patternColor, textColor: textColor, rarity: 0) + .backdrop(name: "", id: 0, innerColor: innerColor, outerColor: outerColor, patternColor: patternColor, textColor: textColor, rarity: 0) ], availability: StarGift.UniqueGift.Availability(issued: 0, total: 0), - giftAddress: nil + giftAddress: nil, + resellStars: nil ) signal = component.context.engine.accountData.setStarGiftStatus(starGift: gift, expirationDate: emojiStatus.expirationDate) } else { @@ -1090,7 +1091,7 @@ final class UserAppearanceScreenComponent: Component { case let .pattern(_, file, _): patternFileId = file.fileId.id self.cachedIconFiles[file.fileId.id] = file - case let .backdrop(_, innerColorValue, outerColorValue, patternColorValue, textColorValue, _): + case let .backdrop(_, _, innerColorValue, outerColorValue, patternColorValue, textColorValue, _): innerColor = innerColorValue outerColor = outerColorValue patternColor = patternColorValue diff --git a/submodules/TelegramUI/Components/Stars/StarsAvatarComponent/Sources/StarsAvatarComponent.swift b/submodules/TelegramUI/Components/Stars/StarsAvatarComponent/Sources/StarsAvatarComponent.swift index b7abee888a..b372222740 100644 --- a/submodules/TelegramUI/Components/Stars/StarsAvatarComponent/Sources/StarsAvatarComponent.swift +++ b/submodules/TelegramUI/Components/Stars/StarsAvatarComponent/Sources/StarsAvatarComponent.swift @@ -126,7 +126,7 @@ public final class StarsAvatarComponent: Component { theme: component.theme, strings: component.context.sharedContext.currentPresentationData.with { $0 }.strings, peer: nil, - subject: .uniqueGift(gift: gift), + subject: .uniqueGift(gift: gift, price: nil), mode: .thumbnail ) ), diff --git a/submodules/TelegramUI/Components/Stars/StarsWithdrawalScreen/Sources/StarsWithdrawalScreen.swift b/submodules/TelegramUI/Components/Stars/StarsWithdrawalScreen/Sources/StarsWithdrawalScreen.swift index f55b8c698c..9f39861c57 100644 --- a/submodules/TelegramUI/Components/Stars/StarsWithdrawalScreen/Sources/StarsWithdrawalScreen.swift +++ b/submodules/TelegramUI/Components/Stars/StarsWithdrawalScreen/Sources/StarsWithdrawalScreen.swift @@ -105,7 +105,8 @@ private final class SheetContent: CombinedComponent { let minAmount: StarsAmount? let maxAmount: StarsAmount? - let configuration = StarsWithdrawConfiguration.with(appConfiguration: component.context.currentAppConfiguration.with { $0 }) + let withdrawConfiguration = StarsWithdrawConfiguration.with(appConfiguration: component.context.currentAppConfiguration.with { $0 }) + let resaleConfiguration = StarsSubscriptionConfiguration.with(appConfiguration: component.context.currentAppConfiguration.with { $0 }) switch component.mode { case let .withdraw(status): @@ -113,7 +114,7 @@ private final class SheetContent: CombinedComponent { amountTitle = environment.strings.Stars_Withdraw_AmountTitle amountPlaceholder = environment.strings.Stars_Withdraw_AmountPlaceholder - minAmount = configuration.minWithdrawAmount.flatMap { StarsAmount(value: $0, nanos: 0) } + minAmount = withdrawConfiguration.minWithdrawAmount.flatMap { StarsAmount(value: $0, nanos: 0) } maxAmount = status.balances.availableBalance amountLabel = nil case .accountWithdraw: @@ -121,7 +122,7 @@ private final class SheetContent: CombinedComponent { amountTitle = environment.strings.Stars_Withdraw_AmountTitle amountPlaceholder = environment.strings.Stars_Withdraw_AmountPlaceholder - minAmount = configuration.minWithdrawAmount.flatMap { StarsAmount(value: $0, nanos: 0) } + minAmount = withdrawConfiguration.minWithdrawAmount.flatMap { StarsAmount(value: $0, nanos: 0) } maxAmount = state.balance amountLabel = nil case .paidMedia: @@ -130,9 +131,9 @@ private final class SheetContent: CombinedComponent { amountPlaceholder = environment.strings.Stars_PaidContent_AmountPlaceholder minAmount = StarsAmount(value: 1, nanos: 0) - maxAmount = configuration.maxPaidMediaAmount.flatMap { StarsAmount(value: $0, nanos: 0) } + maxAmount = withdrawConfiguration.maxPaidMediaAmount.flatMap { StarsAmount(value: $0, nanos: 0) } - if let usdWithdrawRate = configuration.usdWithdrawRate, let amount = state.amount, amount > StarsAmount.zero { + if let usdWithdrawRate = withdrawConfiguration.usdWithdrawRate, let amount = state.amount, amount > StarsAmount.zero { let usdRate = Double(usdWithdrawRate) / 1000.0 / 100.0 amountLabel = "≈\(formatTonUsdValue(amount.value, divide: false, rate: usdRate, dateTimeFormat: environment.dateTimeFormat))" } else { @@ -144,7 +145,16 @@ private final class SheetContent: CombinedComponent { amountPlaceholder = environment.strings.Stars_SendStars_AmountPlaceholder minAmount = StarsAmount(value: 1, nanos: 0) - maxAmount = configuration.maxPaidMediaAmount.flatMap { StarsAmount(value: $0, nanos: 0) } + maxAmount = withdrawConfiguration.maxPaidMediaAmount.flatMap { StarsAmount(value: $0, nanos: 0) } + amountLabel = nil + case .starGiftResell: + //TODO:localize + titleString = "Sell Gift" + amountTitle = "PRICE IN STARS" + amountPlaceholder = "Enter Price" + + minAmount = StarsAmount(value: resaleConfiguration.starGiftResaleMinAmount, nanos: 0) + maxAmount = StarsAmount(value: resaleConfiguration.starGiftResaleMaxAmount, nanos: 0) amountLabel = nil } @@ -214,10 +224,16 @@ private final class SheetContent: CombinedComponent { } let amountFont = Font.regular(13.0) + let boldAmountFont = Font.semibold(13.0) let amountTextColor = theme.list.freeTextColor - let amountMarkdownAttributes = MarkdownAttributes(body: MarkdownAttributeSet(font: amountFont, textColor: amountTextColor), bold: MarkdownAttributeSet(font: amountFont, textColor: amountTextColor), link: MarkdownAttributeSet(font: amountFont, textColor: theme.list.itemAccentColor), linkAttribute: { contents in + let amountMarkdownAttributes = MarkdownAttributes( + body: MarkdownAttributeSet(font: amountFont, textColor: amountTextColor), + bold: MarkdownAttributeSet(font: boldAmountFont, textColor: amountTextColor), + link: MarkdownAttributeSet(font: amountFont, textColor: theme.list.itemAccentColor), + linkAttribute: { contents in return (TelegramTextAttributes.URL, contents) - }) + } + ) if state.cachedChevronImage == nil || state.cachedChevronImage?.1 !== environment.theme { state.cachedChevronImage = (generateTintedImage(image: UIImage(bundleImageName: "Contact List/SubtitleArrow"), color: environment.theme.list.itemAccentColor)!, environment.theme) } @@ -252,6 +268,18 @@ private final class SheetContent: CombinedComponent { text: .plain(amountInfoString), maximumNumberOfLines: 0 )) + case .starGiftResell: + //TODO:localize + let amountInfoString: NSAttributedString + if let value = state.amount?.value, value > 0 { + amountInfoString = NSAttributedString(attributedString: parseMarkdownIntoAttributedString("You will receive **\(Int32(floor(Float(value) * 0.8))) Stars**.", attributes: amountMarkdownAttributes, textAlignment: .natural)) + } else { + amountInfoString = NSAttributedString(attributedString: parseMarkdownIntoAttributedString("You will receive **80%**.", attributes: amountMarkdownAttributes, textAlignment: .natural)) + } + amountFooter = AnyComponent(MultilineTextComponent( + text: .plain(amountInfoString), + maximumNumberOfLines: 0 + )) default: amountFooter = nil } @@ -305,8 +333,15 @@ private final class SheetContent: CombinedComponent { let buttonString: String if case .paidMedia = component.mode { buttonString = environment.strings.Stars_PaidContent_Create + } else if case .starGiftResell = component.mode { + //TODO:localize + if let amount = state.amount, amount.value > 0 { + buttonString = "Sell for # \(presentationStringsFormattedNumber(amount, environment.dateTimeFormat.groupingSeparator))" + } else { + buttonString = "Sell" + } } else if let amount = state.amount { - buttonString = "\(environment.strings.Stars_Withdraw_Withdraw) # \(presentationStringsFormattedNumber(amount, environment.dateTimeFormat.groupingSeparator))" + buttonString = "\(environment.strings.Stars_Withdraw_Withdraw) # \(presentationStringsFormattedNumber(amount, environment.dateTimeFormat.groupingSeparator))" } else { buttonString = environment.strings.Stars_Withdraw_Withdraw } @@ -318,10 +353,17 @@ private final class SheetContent: CombinedComponent { let buttonAttributedString = NSMutableAttributedString(string: buttonString, font: Font.semibold(17.0), textColor: theme.list.itemCheckColors.foregroundColor, paragraphAlignment: .center) if let range = buttonAttributedString.string.range(of: "#"), let starImage = state.cachedStarImage?.0 { buttonAttributedString.addAttribute(.attachment, value: starImage, range: NSRange(range, in: buttonAttributedString.string)) - buttonAttributedString.addAttribute(.foregroundColor, value: UIColor(rgb: 0xffffff), range: NSRange(range, in: buttonAttributedString.string)) - buttonAttributedString.addAttribute(.baselineOffset, value: 1.0, range: NSRange(range, in: buttonAttributedString.string)) + buttonAttributedString.addAttribute(.foregroundColor, value: theme.list.itemCheckColors.foregroundColor, range: NSRange(range, in: buttonAttributedString.string)) + buttonAttributedString.addAttribute(.baselineOffset, value: 1.5, range: NSRange(range, in: buttonAttributedString.string)) + buttonAttributedString.addAttribute(.kern, value: 2.0, range: NSRange(range, in: buttonAttributedString.string)) } +// if let range = buttonAttributedString.string.range(of: "#"), let starImage = state.cachedStarImage?.0 { +// buttonAttributedString.addAttribute(.attachment, value: starImage, range: NSRange(range, in: buttonAttributedString.string)) +// buttonAttributedString.addAttribute(.foregroundColor, value: UIColor(rgb: 0xffffff), range: NSRange(range, in: buttonAttributedString.string)) +// buttonAttributedString.addAttribute(.baselineOffset, value: 1.0, range: NSRange(range, in: buttonAttributedString.string)) +// } + let button = button.update( component: ButtonComponent( background: ButtonComponent.Background( @@ -394,6 +436,8 @@ private final class SheetContent: CombinedComponent { amount = initialValue.flatMap { StarsAmount(value: $0, nanos: 0) } case .reaction: amount = nil + case .starGiftResell: + amount = nil } self.amount = amount @@ -514,6 +558,7 @@ public final class StarsWithdrawScreen: ViewControllerComponentContainer { case accountWithdraw case paidMedia(Int64?) case reaction(Int64?) + case starGiftResell } private let context: AccountContext diff --git a/submodules/TelegramUI/Images.xcassets/Peer Info/SortNumber.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Peer Info/SortNumber.imageset/Contents.json new file mode 100644 index 0000000000..fb212fad6b --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Peer Info/SortNumber.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "hash.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/submodules/TelegramUI/Images.xcassets/Peer Info/SortNumber.imageset/hash.pdf b/submodules/TelegramUI/Images.xcassets/Peer Info/SortNumber.imageset/hash.pdf new file mode 100644 index 0000000000000000000000000000000000000000..4961e20e5583d72280afea023600f82947815d60 GIT binary patch literal 4324 zcmai2c{r3``!{LOL?pY^gA~FT%rGsoGuehDYu1q&%UA}JCHt1hl6@yxvP6h1l_^V< zJ-Y}Yk)1^IGi~3u-}k+)_x$lZ_w_mF{+#>1pX;3Kb3X!TB^6Pam^1_e1H=KmB@O}r z7cKx$d3yrJ#nFO*0ia44cdQjgS5;wuYJWggu#~y|xBdRK62=N|jrldBfpNS+umvRG z27a=mjq-6Pf`K*DQJqm5Fj&tdfFAN%M&>rRa2fSU!Y9rChu6x+ zpB2^l`g}?ona%i?O{R)=q*fAl$)kIfV{WGHsbtNujT%f((Phddv2k;CDAe#!T|j<6 z&*sxo&vJs1s_E(}-y6FoqA)f6R1wrspd-#1k{RdkglO6+M`104&SIRqy}x5QBfgFv z45~YN`o`sLH69&AB9Eb^p{$TX0!i~&n+&G~t>XdOHr{@~UiLjXjgGUG%AvT}flSIE z+qJ#5&RD;D0LVNnwFfZxPYeG^xMliejEhPIwIvKnFX#4jF^DbfE)us;`H~;>FW<(nczwC z2k2;Z0O9*ISVfv>PHSoy@3mHLqXS^Y7&JW#<00Ndm7rl7R=HPbq#n3m{*vNsRzw%P zfbl6~q};JCv#v87;5ZAe;zQg`3F5~#{ir9 zyk&JF7Z`o&9^^9ZD>;~sh#nveVGU)@Mr5z*o<0#5+(x=*iGyh- zwI_i(I6m^QMO=NsWx@04)}Wd_pZmpXCNH{?u*H{~7OLgFGRL(TZiKkL0Dq8?fr&AC zFnK^cKm&KVNj}fxmWPSl^^CF*=*!s&^_4R#JlnT+U3cl_{mtc^_zp4?gQ|k~6d1B2 zog&0ll6ZxgBg4dAqAmHH&z9dAl&jb9R`XWmiy@$*?$4Y8VGkGHA*2xEr4)A5^!ZX_ z4X14@g6dPf(?(H^+P4(5(ey$~_XKYzRix^w)+*i9_zI}TKaRF)UztX0Kgs3Co% z2GY1z;cI<`9yrgkph+L4FQkjil_*%n`hPrd7rYCuW0Mk@Gh4pymR(dQYz8;0(s7g9 zh~qMSV=RhtC^L!}=69=nTDaq~A+kaGl-PfUy@%}_bXu&#jA%1S&0s*%<2SHsXof|m za`mPdr8K5sQ)uB(tQM}&w$@Gn7jNrkt7&_s%CrhREHKPoY1S1#e`4L*ld~aJHZM1O zDxbf*uxGwMxf|YNnw^?EQt(afZb3_5MDN+|g`U)&fp=y_&*Yz7C;Nk5_+g{Xot|g4 zOf+Q2Skz1&A+eF1`LFULP8%G1fQoJ7jT?FJs4yKHfDN7( zl^pCC*D!Z)E$%#SwPY(MRfafe8O?d(j)uxbtCtF|EW?$flp|i9RrhHZY;Q=om|#}& zxj422R~%9jU%Xf1T0Bxru7Fid4(kn$nkpGiKek=Vd8b_F@W%dYb*RIaA)Up9;l)P_ z72hnsm_sY@k20OQJi?p{2ppC@N%9>EAFrz3$<3d#X>o74tF@_RnJ=m3drmSfE=~K~ zT`0n+!_4F{u}^s*cuIGM&n>U6x#omh{L}dw=h33EjHe5=Yom5!nO;f6Q=Vz7#2M_2 z(5n5acc+V`gSDGycD;AQYJ+D(eEl_IqSvL(?v3!R!mZ@hsx{LuBlDpvhnGhdq+h?ib?DBnoUTdM=Ch~HK5d#N<&Zu#jfZ*#vxlnl zi|`{(vM5!nOy)G@;Bp^a^DG;E<{~mIb`I+46yII+`RaiF!!oVwKDQ!wx!$Hc zh9xB}C&j@sk^X78w*-;%)e`Qv+drI`Xq>KHyG%Uh&3+3;zL}i4nO!L zs3!;oQb&ss)8`V2r&fG)&3jhv4^1ymH?w$O)_9G~jul4@rWeJhqz@>Biks&e8Ho}|Y*4=2<1UG~+%s9!%wPb$S)Go7Z&2VeV zbKP@kr#9V{$`=XCH(x)D) zL967JjQ*hh_1)Ip@>2bV>a5Ln=Sk=J@QGt%Zb#hQ&cLX*&<5&7W((FtQx+0vfN`_h8qi;HJZxp1MLrCA^~QOB%Vh z6fs-(1A|dh5`Z^FOiLYAT9BW?ic0v!T6reRl6hyxS-B7aUqf9DE|a zo!c4PfsU^{b)6CWwiM})q4o(pOY?<6nGyA6WhwH#b|ZO5{$pVBq$nedaqXng1!14! zm71OKwm6l-hV|XBfz~(qyxO6vwYgB=&h5Q5gC)t=k9%f^I~STAnn%ufpLTp^@YV7& zX1G1Mt^N`VxxgE<`zi6#7`YzF3WIMNt$S{kHA&;}QO~M;M&LYBd)ub3>yo#=p6ruN z+iu+*Toc;uT}v+Rtm(v)Fl_HQAu#Tn=NTKf_<-faMW5~ zw`+M~fP+(YV5!hv5(igYVfs#jdv!?hfUnNpN6o_Q;Tq37ndzot_72}XP!3#GW2c+< z@ryjnbwVWm$j=}0@i#2_2Q~(vC`CmD3s;OaK+&qYfGLIMhqKh5RTS;2h_}bPTye6n z!u*i3iZFn}qPs6cDN6LW5Bx8m|0!w_6pFvKEGRx6M%LNJ8kdogjE62ly$MqT4li1ioqS?4tSR)q18;uEgy zG-C5&Z1?2CYJgMVYxzaFw?)+o36`hgv(CjfQ4OFxZ+F_rWm=hbt6}qZIvhWktZI&$ zmnCfDyBf^2zZ~{5%t+jtnN1AMI-%%Cd%JvYAjHq8qn0l!A?g04pr5GKZH`CPYCREC zf^oXMiX_%O*1l*R=<_tbS(-toXiNN!)h=f_c1u>lPy@)zBDnl@q{w`wh${0Dl-~VP zq?8VB=CX{P9bv~|S<%VFoWBt>L+SxH`bQFP-SM2RkIwGctIpF3u8Y<&-CBE$Z{0Es z6j~ETHuU8l|Lm6BIZTV^@Z{pIe{mrz&)I_!3+DK2i9Gy7pWYpHvE{J{89pBL3b13! z&eUTfzwaGW2v;gB4p$I0teH42ik^;T;yM7<)V4c}RB5X?eFR)bddHlt&#Hf?4VY<2 zoC91XT05FJ-G#0(-xnXyODQqyAP#vRh&NdlG80s@tIF2ZZ)~-W3juK$`v&R@%cOTj zQ0Zk$&TtEdCoi{!o~0|j8W?L$|B$xVPs6J?rZ+sCKUhZA5YLaj9kjxxW@9T1s9tsxNy|%Meq{|+-W-?fJs$!kbDom z7|x|~PcC{$g*T)R*CdP&6W{<_^#kl$n1dCjN|oWw&I5Ii zl9|`m1HG|CvSI?iGmVJOe@$ zZYev6>%x}2c+fzzP*Mj)^liLpp54tkMh-FD4eX5Zp}C1`=6-;J?6eJXcntXPFW&t~ zKRMf0(^*ZZ1|BSAcSfP)<0d$5q5T z5-^VYJ5p|m-yqt;Wgi1%enJ0nmr$UeDNuq{$6D`)3Q#hjTvERo-S&N9|BByrANda^ zwg7#oN<027D&%%AcE5OBC89N5Qy4VXCa!vY+C zK@te$exQ``4c0|hTSO1qI!Nme= zk8y!eN{Le1FQlL=-T@;72x6^;ewGuei^mfHN~wN)8t4_QCx(*ukN*r7E`%SIkP?@Y Lga`;IYpeVpBW{vj literal 0 HcmV?d00001 diff --git a/submodules/TelegramUI/Images.xcassets/Premium/Collectible/Sell.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Premium/Collectible/Sell.imageset/Contents.json new file mode 100644 index 0000000000..56a854225f --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Premium/Collectible/Sell.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "sale.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/submodules/TelegramUI/Images.xcassets/Premium/Collectible/Sell.imageset/sale.pdf b/submodules/TelegramUI/Images.xcassets/Premium/Collectible/Sell.imageset/sale.pdf new file mode 100644 index 0000000000000000000000000000000000000000..f714dfefe056fa3d6de1e21a5166a580a70c5e04 GIT binary patch literal 4291 zcmZu#c|26z8z)Pe7A;bex~N3V%$4FL>G!_pkGbdbJs8VJY3U?6}5n2xS6 z0NA@1Kxoq0R4)dZO$87-)Dtuc)$D*4v;+-=9*w^SJ?YT04wb@mqRy`vQ5nbCE&vLP z!_Olo02-R9tHW4qFDls`#!0>#OCzmoldfURnhL2cl0wyow*?-bwo?Q3Et6CY)&TX*@$HzC=6>z> z*TNgI*^*_F5gO|n9U7H3ibaz*w~jzz8;4-)Aian^5^%;FCA*m3Z7;zzhyJFj;c8@eC|oBlN8{uJh0h6_O;#R1%X%pG zhCl$}l0K__U_QdHF3NHP9z=g_=g3w_s>2YE-uD;|D-Fv{o|Irt-zqk`wI!V9e$6-NI0{@Mzc*#?o<-)FY~0ACiJIADFgYC#89qkoCznmvde6H)hAD zTV-?1h7;eEtTm}4)!ClUx^k0kG!WIibG>d$Mp3riN1W@*Zz%5zXESA*TJPxC2!B?x zLAW2ZHfXaX8WC+vwZ2yrS%_siW>;GpSSp(lGf>$hwBUD3E{aWyl}lq(KRA3n>YbKb zuHt}oxNGXI@hy6j{Zm^t1NQ=(@ODM-J7qajUf)!|aX-ejC~at#-i_#o*E?{Wdqq}S zaV-?BD3wpZh@?%=6KxW!5^0G`u?U)pYmQ5)+jiF&7jGA17xTOJcg5Pbw{N)P&=@nY zWz5M}rZP!AGb61pOR*`Zd7ves3EOO+mXy(v{X_p^cFl|M=ewE)o0FPfy>!UEuX+FI zY_RadAljv49uKb7^i-x@CYSWC;!1Np6%Q*GH|@^w%?NuUMZx#6`&|3VcUY~vVGva( zAKh`|@0_c&)3h@KYUtMbZlhx-YV#U4Q-)mNm;&5Z$4fF>E*RwBg*LrvOz=e#gKIfVfH5X0B zO&qh(CV{)r$fR#VcZts>_Fls%~=R4**G<9p`ZrNhS#9R$cFfx$c^Py7Zb?!S??#E_IDOr6sgwY`S*3@Rns|@wIWDXRqhL`JQ!M-mAR5 zm9Qq#E!=NWQ|HZ_vKX`d-^|@aGSYHQGYfk#1U2H!oA>dlOB+O!2X^!Jc6<$gh=?(M zabwy!`=tA*g`rmX9cJ!wftE{w-@)P-l%bT zlJWhvnU^ozUU&HOdMyXtYdYKm!hh_Z>Y74y50|mt!hZ}!1XD!Ql5rq8{qeVQeIDC|IK1|qOwa%R+O z2>s9P=J)LlgHPO_bSU}nVBEL*?)Zt?&P%AP*e^Ak?N6Ql7`MM`wt~1G#E#pH`HmM< z<6W7N_wNRDVC66~llK3VCro_b`a(T7Y9QDNb z9!bZCQvyy z?K6&5`n8w69~q?Vk-MB%bHU=W6<`2nDVj8|3c66Di(?sTIz3XPq|`sF0!4+6EO`)@ z-qx0o+t!AtRV8tS*av@#MMyL$!l(?V`Hm2p@w-kKO3;4JwT}xhS^(Pu2m@_xEi#Mh z1n_%RGr*otGv|yESn+#TZ6=-RW$r=BaA+bmR1t(&NcqAU6xWfcy|DLcY3>b)q zn=QfTCkS3<^Xq zuIqoi7YfV;24V#9(F?55`@+HrI1s80018kLx5$!+!QxREU=fC0@P^d?w#30fEC!+i z7BS)%EEmNVV30Q&j>Dqx1R?-^OY>h4h!Vv9&G;Kz9Q!wlhQ8WgS^j4Ht>!;;^XuWP z0%O2TVgIqQ`CIDOy^;Xp1iKF}K<93o3z@z-|NjxuL@ab-&s(v*$TT|D3&wx%20Bn7ce0r7RApc%%}M!JIT2<| kCL7?FYVOq_%xS(OWZxQmGriUO_PyVAedmwg@44=Cp8K5VoadbDevTtGwWXla3Lp>^ECa?mx`V*r z)2G1@H4hxx*9(P1gCUye0E`pb_?$Yei`F387|#i%LcTgdV}-@*Pukft-I;5yU3&Paq$9WQ3$9Way_7`coM z0GtWQc--uK_2oh;^ ze+JwK2F9pz4KmbTJ&j~o3*WvgAOL{fJs=-0cNbV488-o{19(LOWLWPxur@K7ObDh& zfLZn$flu9I#%M4n@;Wm@g)K<@)_Z^&w~?%z?E8fGRRe~ZkElLJB20n(YG*Y*-Hq#k z6|*<6$E$Mm*!P^|0VbpP%J%WMrOI%ug)>ed@ka#Mi->a*ws-|wW(#7`gv*9V2%}JV z;esBX4~RT=2Xub#b5*F3q$&6m=t%V8Jb2!cQDL#WDPt*He`G^sQvL~}qZCW|BL?T> zdS1JoT>;5+XD5tDNo%GRT)3M>8Jkg_IkdvLC2{>KF!P;M>b*T`t;mSNXGVkfka^yQC>@mb?NYaLp5pX;%VNCoV&?n0 z!-k#6PMg@GI}dRBvq`;TN3{A2s)}tt!`<0dW&LknFXW{RR%==VzMiy(c%HY?9WqDg zBW&u`H=C+VfrXC6ZRWb>;>L&q+2SQk#K%3ifK*^3m%QYR{lZ26ywb)~_AvVzBY)M^ zWInrBHd4Bt71nXXqW;wlC0o9$lB>keY5ga;d%2V#)6!k`c$Y~=HcO(ZsHIc$3ut^6 zUtgwmW=kd}b1w{nF?26+tG{&IJ;lx6&A`p1#;yi9e0-R@+P)`cR%pdJh_^XQrLZ7x zsz{Vl(mUIqL4oz!1cU2}_Rqx=EeF$s_k4m1JYCVy?|f z$qjam>pKLH%DM%d=G~;_E8s^R6M2Ph>T92IYFB^m7^{__757|1FO)1sZcaUuYG3}P zEUDbR?0R`h*><^K*+|)L6|`n@*mQ8zPSa}ovDf9su+FhD>*E!1PSs^p_G0x8!&%RVR$k0N<5YCb>?Ge zdip|oGBg(vL2!SKlT@yi4Y)ymFEr6IUB7%D&k@3X1-g5gpl9ndif_`Rwrs>QJ&Ed# z(go-trSaJ_Y4~G{p~eoqi}!}67p7luhMd=biO5Tm(H+b#P07q2PIF03O*>%_c>Z0k z%5l)aeiv2V2eQ`B<)_d2nm9@hN%MB%UX_ukiAPfX;Wkj4^iaR@*r+81 zLGxP=*_8Ney?BOZDl6SNs>K6L23(q7)n|3oZg?RoQ|e%tIV{2)@f?ADat(Wl+}|~p zq}(tT7#x~onG3I~3-~S}ckgK1#nzR*bLMigvonAG$#8{A5 zYE)P`A+IQf*na;hJ$G+66Jb_~e0pRdt`(AE@a8_%rTDVvvZwP# zwgnkqZ1DRa{e3<@0?im2DZ%`WO^aRk+v0ruy9+J5TWTL8GbW|jq3p{?#ZR9KEnBSH zitS9+E@@t&#te{N6$xL6K387=3G3e2UbdWO-L78D)!Bq&giCkcqMIcYT^TkUY zPb}SG3|$xoWUwsLq94@UTWq@#A#-)pC*+WQj0a5rS@&U^pEy23bB2mNM z1K~I8wBUYvYR>JVPB~q#TcAt~7JHth^9BvcKi$5Bc~DmirCcfFZgX8tN!Nv@@8lRgJ-RbebQ|^1nyXwW|i!1M6$zjdDW& zkg^(3FrCGiCPV2;^p_9p51;=lYTKea3%-jsYp0Lt3*U1Cch#v%!AqwXo;PdnO6($8D6N3gj`RUV+igiQNkvo7L8bl`}KSg?&+@ z`DPCkJ>fDij+YLQgVVyBEMbfm3N`N`Erx|62_hEKWj)gZlj-+_fLdnatep`lk-6aNqbQ27%8ZyBFCbfcFsS#= ztno1!9LqbPGE^`=Hip9fHr=MpMDts*GVjN>k$rvYoy;cQgI(*9GhL+ey2?0(@%OiOcy<}d{`@chM=sNI<*!l-21Wc%IECNnj{?if3ybpu z+t~fAluR#s(`qdw%Jo+TYSX}a;m}^R8R>cd3q+!PX&6}XC-jfoL5KdX2K6w`w55XS5zw#QFGhcwFZ5sQ z_oE^IU_$)CL=VvK_k{F?{S~+d3WxH*y8h{k_eHyaWWZ1v&|mS>zhF2FCI CGFloat { return round(value * 10.0) / 10.0 @@ -147,6 +149,7 @@ final class OverlayPlayerControlsNode: ASDisplayNode { private let albumArtNode: TransformImageNode private var largeAlbumArtNode: TransformImageNode? private let titleNode: TextNode + private let title: ComponentView private let descriptionNode: TextNode private let shareNode: HighlightableButtonNode private let artistButton: HighlightTrackingButtonNode @@ -236,6 +239,8 @@ final class OverlayPlayerControlsNode: ASDisplayNode { self.titleNode.isUserInteractionEnabled = false self.titleNode.displaysAsynchronously = false + self.title = ComponentView() + self.descriptionNode = TextNode() self.descriptionNode.isUserInteractionEnabled = false self.descriptionNode.displaysAsynchronously = false @@ -295,7 +300,7 @@ final class OverlayPlayerControlsNode: ASDisplayNode { self.addSubnode(self.collapseNode) self.addSubnode(self.albumArtNode) - self.addSubnode(self.titleNode) + //self.addSubnode(self.titleNode) self.addSubnode(self.descriptionNode) self.addSubnode(self.artistButton) self.addSubnode(self.shareNode) @@ -725,8 +730,25 @@ final class OverlayPlayerControlsNode: ASDisplayNode { } self.artistButton.isUserInteractionEnabled = hasArtist + let makeTitleLayout = TextNode.asyncLayout(self.titleNode) let (titleLayout, titleApply) = makeTitleLayout(TextNodeLayoutArguments(attributedString: titleString, backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: width - sideInset * 2.0 - leftInset - rightInset - infoLabelsLeftInset - infoLabelsRightInset, height: CGFloat.greatestFiniteMagnitude), alignment: .left, lineSpacing: 0.0, cutout: nil, insets: UIEdgeInsets())) + + let titleSize = self.title.update( + transition: .immediate, + component: AnyComponent( + MarqueeComponent(attributedText: titleString ?? NSAttributedString()) + ), + environment: {}, + containerSize: CGSize(width: width - sideInset * 2.0 - leftInset - rightInset - infoLabelsLeftInset - infoLabelsRightInset + MarqueeComponent.innerPadding, height: CGFloat.greatestFiniteMagnitude) + ) + if let titleView = self.title.view { + if titleView.superview == nil { + self.view.addSubview(titleView) + } + transition.updateFrame(view: titleView, frame: CGRect(origin: CGPoint(x: self.isExpanded ? floor((width - titleSize.width) / 2.0) : (leftInset + sideInset + infoLabelsLeftInset) - MarqueeComponent.innerPadding, y: infoVerticalOrigin + 1.0), size: titleSize)) + } + let makeDescriptionLayout = TextNode.asyncLayout(self.descriptionNode) let (descriptionLayout, descriptionApply) = makeDescriptionLayout(TextNodeLayoutArguments(attributedString: descriptionString, backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: width - sideInset * 2.0 - leftInset - rightInset - infoLabelsLeftInset - infoLabelsRightInset, height: CGFloat.greatestFiniteMagnitude), alignment: .left, lineSpacing: 0.0, cutout: nil, insets: UIEdgeInsets())) diff --git a/submodules/TelegramUI/Sources/SharedAccountContext.swift b/submodules/TelegramUI/Sources/SharedAccountContext.swift index d3bd0f15bf..75717b4f5a 100644 --- a/submodules/TelegramUI/Sources/SharedAccountContext.swift +++ b/submodules/TelegramUI/Sources/SharedAccountContext.swift @@ -81,6 +81,7 @@ import AccountFreezeInfoScreen import JoinSubjectScreen import OldChannelsController import InviteLinksUI +import GiftStoreScreen private final class AccountUserInterfaceInUseContext { let subscribers = Bag<(Bool) -> Void>() @@ -3267,6 +3268,15 @@ public final class SharedAccountContextImpl: SharedAccountContext { return controller } + public func makeGiftStoreController(context: AccountContext, peerId: EnginePeer.Id, gift: StarGift.Gift) -> ViewController { + guard let starsContext = context.starsContext else { + fatalError() + } + let controller = GiftStoreScreen(context: context, starsContext: starsContext, peerId: peerId, gift: gift) + controller.navigationPresentation = .modal + return controller + } + public func makePremiumPrivacyControllerController(context: AccountContext, subject: PremiumPrivacySubject, peerId: EnginePeer.Id) -> ViewController { let mappedSubject: PremiumPrivacyScreen.Subject let introSource: PremiumIntroSource @@ -3656,6 +3666,10 @@ public final class SharedAccountContextImpl: SharedAccountContext { return StarsWithdrawScreen(context: context, mode: .accountWithdraw, completion: completion) } + public func makeStarGiftResellScreen(context: AccountContext, completion: @escaping (Int64) -> Void) -> ViewController { + return StarsWithdrawScreen(context: context, mode: .starGiftResell, completion: completion) + } + public func makeStarsGiftScreen(context: AccountContext, message: EngineMessage) -> ViewController { return StarsTransactionScreen(context: context, subject: .gift(message)) }