diff --git a/Telegram/Telegram-iOS/en.lproj/Localizable.strings b/Telegram/Telegram-iOS/en.lproj/Localizable.strings index 7da555ae72..dd4791e9b7 100644 --- a/Telegram/Telegram-iOS/en.lproj/Localizable.strings +++ b/Telegram/Telegram-iOS/en.lproj/Localizable.strings @@ -12290,12 +12290,14 @@ Sorry for the inconvenience."; "Stars.Transaction.Via" = "Via"; "Stars.Transaction.To" = "To"; "Stars.Transaction.From" = "From"; +"Stars.Transaction.Media" = "Media"; "Stars.Transaction.Id" = "Transaction ID"; "Stars.Transaction.Date" = "Date"; "Stars.Transaction.Terms" = "Review the [Terms of Service]() for Stars."; "Stars.Transaction.Terms_URL" = "https://telegram.org/tos"; "Stars.Transaction.CopiedId" = "Transaction ID copied to clipboard."; +"Stars.Transaction.MediaPurchase" = "Media Purchase"; "Stars.Transaction.AppleTopUp.Title" = "Stars Top-Up"; "Stars.Transaction.AppleTopUp.Subtitle" = "App Store"; "Stars.Transaction.GoogleTopUp.Title" = "Stars Top-Up"; @@ -12319,7 +12321,7 @@ Sorry for the inconvenience."; "Stars.Transfer.Purchased.Stars_1" = "%@ Star"; "Stars.Transfer.Purchased.Stars_any" = "%@ Stars"; "Stars.Transfer.UnlockedText" = "You unlocked media for **%1$@**."; -"Stars.Transfer.UnlockInfo" = "Do you want to unlock media for **%1$@**?"; +"Stars.Transfer.UnlockInfo" = "Do you want to unlock %1$@ in **%2$@** for **%3$@**?"; "Stars.Transfer.Balance" = "Balance"; @@ -12349,16 +12351,16 @@ Sorry for the inconvenience."; "HashtagSearch.StoriesFoundInfo" = "View stories with %@"; "Stars.BotRevenue.Title" = "Stars Balance"; -"Stars.BotRevenue.Revenue.Title" = "Revenue"; -"Stars.BotRevenue.Proceeds.Title" = "Proceeds Overview"; +"Stars.BotRevenue.Revenue.Title" = "Stars Received"; +"Stars.BotRevenue.Proceeds.Title" = "Rewards Overview"; "Stars.BotRevenue.Proceeds.Available" = "Available Balance"; "Stars.BotRevenue.Proceeds.Current" = "Total Balance"; -"Stars.BotRevenue.Proceeds.Total" = "Total Lifetime Proceeds"; -"Stars.BotRevenue.Proceeds.Info" = "Stars from your total balance become available for spending on ads and rewards 21 days after they are earned."; +"Stars.BotRevenue.Proceeds.Total" = "Lifetime Proceeds"; +"Stars.BotRevenue.Proceeds.Info" = "Stars from your total balance can be used for ads or withdrawn as rewards 21 days after they are earned."; "Stars.BotRevenue.Withdraw.Balance" = "Available Balance"; "Stars.BotRevenue.Withdraw.Withdraw" = "Withdraw via Fragment"; -"Stars.BotRevenue.Withdraw.Info" = "You can withdraw Stars using Fragment, or use Stars to advertise your bot. [Learn More >]()"; +"Stars.BotRevenue.Withdraw.Info" = "You can collect rewards for Stars using Fragment, or use Stars to advertise your bot. [Learn More >]()"; "Stars.BotRevenue.Withdraw.Info_URL" = "https://telegram.org/tos"; "Stars.BotRevenue.Transactions.Title" = "Transaction History"; @@ -12403,3 +12405,18 @@ Sorry for the inconvenience."; "Chat.MessagesDeletedToast.Text_1" = "Message Deleted"; "Chat.MessagesDeletedToast.Text_any" = "%d Messages Deleted"; + +"Monetization.StarsRevenueTitle" = "REVENUE"; + +"Monetization.TonBalanceTitle" = "TON BALANCE"; +"Monetization.StarsBalanceTitle" = "STARS BALANCE"; + +"Monetization.TonTransactions" = "TON Transactions"; +"Monetization.StarsTransactions" = "Stars Transactions"; + +"Monetization.BalanceStarsWithdraw" = "Withdraw via Fragment"; +"Monetization.Balance.StarsInfo" = "You can withdraw Stars using Fragment, or use Stars to advertise your channel. [Learn More >]()"; +"Monetization.Balance.StarsInfo_URL" = "https://telegram.org"; + +"Premium.MessageEffects" = "Message Effects"; +"Premium.MessageEffectsInfo" = "Add over 500 animated effects to private messages."; diff --git a/submodules/AccountContext/Sources/AccountContext.swift b/submodules/AccountContext/Sources/AccountContext.swift index 9d40edb8b4..d3c799cb2c 100644 --- a/submodules/AccountContext/Sources/AccountContext.swift +++ b/submodules/AccountContext/Sources/AccountContext.swift @@ -1065,11 +1065,12 @@ public protocol SharedAccountContext: AnyObject { func makeStarsTransactionsScreen(context: AccountContext, starsContext: StarsContext) -> ViewController func makeStarsPurchaseScreen(context: AccountContext, starsContext: StarsContext, options: [StarsTopUpOption], peerId: EnginePeer.Id?, requiredStars: Int64?, completion: @escaping (Int64) -> Void) -> ViewController - func makeStarsTransferScreen(context: AccountContext, starsContext: StarsContext, invoice: TelegramMediaInvoice, source: BotPaymentInvoiceSource, inputData: Signal<(StarsContext.State, BotPaymentForm, EnginePeer?)?, NoError>, completion: @escaping (Bool) -> Void) -> ViewController - func makeStarsTransactionScreen(context: AccountContext, transaction: StarsContext.State.Transaction, isAccount: Bool) -> ViewController + func makeStarsTransferScreen(context: AccountContext, starsContext: StarsContext, invoice: TelegramMediaInvoice, source: BotPaymentInvoiceSource, extendedMedia: [TelegramExtendedMedia], inputData: Signal<(StarsContext.State, BotPaymentForm, EnginePeer?)?, NoError>, completion: @escaping (Bool) -> Void) -> ViewController + func makeStarsTransactionScreen(context: AccountContext, transaction: StarsContext.State.Transaction, peer: EnginePeer) -> ViewController func makeStarsReceiptScreen(context: AccountContext, receipt: BotPaymentReceipt) -> ViewController func makeStarsStatisticsScreen(context: AccountContext, peerId: EnginePeer.Id, revenueContext: StarsRevenueStatsContext) -> ViewController - func makeStarsAmountScreen(context: AccountContext, completion: @escaping (Int64) -> Void) -> ViewController + func makeStarsAmountScreen(context: AccountContext, initialValue: Int64?, completion: @escaping (Int64) -> Void) -> ViewController + func makeStarsWithdrawalScreen(context: AccountContext, stats: StarsRevenueStats, completion: @escaping (Int64) -> Void) -> ViewController func makeDebugSettingsController(context: AccountContext?) -> ViewController? diff --git a/submodules/AccountContext/Sources/GalleryController.swift b/submodules/AccountContext/Sources/GalleryController.swift index cdaccb9ee0..33b8a49c8f 100644 --- a/submodules/AccountContext/Sources/GalleryController.swift +++ b/submodules/AccountContext/Sources/GalleryController.swift @@ -6,7 +6,7 @@ import TelegramCore public enum GalleryControllerItemSource { case peerMessagesAtId(messageId: MessageId, chatLocation: ChatLocation, customTag: MemoryBuffer?, chatLocationContextHolder: Atomic) - case standaloneMessage(Message) + case standaloneMessage(Message, Int?) case custom(messages: Signal<([Message], Int32, Bool), NoError>, messageId: MessageId, loadMore: (() -> Void)?) } diff --git a/submodules/AccountContext/Sources/OpenChatMessage.swift b/submodules/AccountContext/Sources/OpenChatMessage.swift index 61d922255a..ade6e84c96 100644 --- a/submodules/AccountContext/Sources/OpenChatMessage.swift +++ b/submodules/AccountContext/Sources/OpenChatMessage.swift @@ -25,6 +25,7 @@ public final class OpenChatMessageParams { public let chatFilterTag: MemoryBuffer? public let chatLocationContextHolder: Atomic? public let message: Message + public let mediaIndex: Int? public let standalone: Bool public let reverseMessageGalleryOrder: Bool public let mode: ChatControllerInteractionOpenMessageMode @@ -55,6 +56,7 @@ public final class OpenChatMessageParams { chatFilterTag: MemoryBuffer?, chatLocationContextHolder: Atomic?, message: Message, + mediaIndex: Int? = nil, standalone: Bool, reverseMessageGalleryOrder: Bool, mode: ChatControllerInteractionOpenMessageMode = .default, @@ -84,6 +86,7 @@ public final class OpenChatMessageParams { self.chatFilterTag = chatFilterTag self.chatLocationContextHolder = chatLocationContextHolder self.message = message + self.mediaIndex = mediaIndex self.standalone = standalone self.reverseMessageGalleryOrder = reverseMessageGalleryOrder self.mode = mode diff --git a/submodules/AccountContext/Sources/Premium.swift b/submodules/AccountContext/Sources/Premium.swift index fee54b6b28..46d6134111 100644 --- a/submodules/AccountContext/Sources/Premium.swift +++ b/submodules/AccountContext/Sources/Premium.swift @@ -41,6 +41,7 @@ public enum PremiumIntroSource { case messageTags case folderTags case animatedEmoji + case messageEffects } public enum PremiumGiftSource: Equatable { @@ -75,6 +76,7 @@ public enum PremiumDemoSubject { case messagePrivacy case folderTags case business + case messageEffects case businessLocation case businessHours diff --git a/submodules/ChatListUI/Sources/Node/ChatListItem.swift b/submodules/ChatListUI/Sources/Node/ChatListItem.swift index 7a663626b2..2b85777a12 100644 --- a/submodules/ChatListUI/Sources/Node/ChatListItem.swift +++ b/submodules/ChatListUI/Sources/Node/ChatListItem.swift @@ -859,6 +859,9 @@ private final class ChatListMediaPreviewNode: ASDisplayNode { let signal = mediaGridMessagePhoto(account: self.context.account, userLocation: .peer(self.message.id.peerId), photoReference: .message(message: MessageReference(self.message._asMessage()), media: image), fullRepresentationSize: CGSize(width: 36.0, height: 36.0), blurred: hasSpoiler, synchronousLoad: synchronousLoads) self.imageNode.setSignal(signal, attemptSynchronously: synchronousLoads) } + } else { + let signal = chatSecretPhoto(account: self.context.account, userLocation: .peer(self.message.id.peerId), photoReference: .standalone(media: image), ignoreFullSize: true, synchronousLoad: synchronousLoads) + self.imageNode.setSignal(signal, attemptSynchronously: synchronousLoads) } } else if case let .action(action) = self.media, case let .suggestedProfilePhoto(image) = action.action, let image = image { isRound = true @@ -2565,7 +2568,36 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { } inner: for media in message.media { - if let image = media as? TelegramMediaImage { + if let paidContent = media as? TelegramMediaPaidContent { + let fitSize = contentImageSize + var index: Int64 = 0 + for media in paidContent.extendedMedia.prefix(3) { + switch media { + case let .preview(dimensions, immediateThumbnailData, videoDuration): + if let immediateThumbnailData { + if let videoDuration { + let thumbnailMedia = TelegramMediaFile(fileId: MediaId(namespace: 0, id: index), partialReference: nil, resource: EmptyMediaResource(), previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: immediateThumbnailData, mimeType: "video/mp4", size: nil, attributes: [.Video(duration: Double(videoDuration), size: dimensions ?? PixelDimensions(width: 1, height: 1), flags: [], preloadSize: nil)]) + contentImageSpecs.append(ContentImageSpec(message: message, media: .file(thumbnailMedia), size: fitSize)) + } else { + let thumbnailMedia = TelegramMediaImage(imageId: MediaId(namespace: 0, id: index), representations: [], immediateThumbnailData: immediateThumbnailData, reference: nil, partialReference: nil, flags: []) + contentImageSpecs.append(ContentImageSpec(message: message, media: .image(thumbnailMedia), size: fitSize)) + } + index += 1 + } + case let .full(fullMedia): + if let image = fullMedia as? TelegramMediaImage { + if let _ = largestImageRepresentation(image.representations) { + contentImageSpecs.append(ContentImageSpec(message: message, media: .image(image), size: fitSize)) + } + } else if let file = fullMedia as? TelegramMediaFile { + if file.isVideo, !file.isVideoSticker, let _ = file.dimensions { + contentImageSpecs.append(ContentImageSpec(message: message, media: .file(file), size: fitSize)) + } + } + } + } + break inner + } else if let image = media as? TelegramMediaImage { if let _ = largestImageRepresentation(image.representations) { let fitSize = contentImageSize contentImageSpecs.append(ContentImageSpec(message: message, media: .image(image), size: fitSize)) diff --git a/submodules/ChatListUI/Sources/Node/ChatListItemStrings.swift b/submodules/ChatListUI/Sources/Node/ChatListItemStrings.swift index 619070c10f..5d0f029f29 100644 --- a/submodules/ChatListUI/Sources/Node/ChatListItemStrings.swift +++ b/submodules/ChatListUI/Sources/Node/ChatListItemStrings.swift @@ -31,6 +31,24 @@ private func singleMessageType(message: EngineMessage) -> MessageGroupType { return .generic } +private func singleExtendedMediaType(extendedMedia: TelegramExtendedMedia) -> MessageGroupType { + switch extendedMedia { + case let .preview(_, _, videoDuration): + if let _ = videoDuration { + return .videos + } else { + return .photos + } + case let .full(fullMedia): + if let _ = fullMedia as? TelegramMediaImage { + return .photos + } else if let file = fullMedia as? TelegramMediaFile, file.isVideo { + return .videos + } + } + return .generic +} + private func messageGroupType(messages: [EngineMessage]) -> MessageGroupType { if messages.isEmpty { return .generic @@ -45,6 +63,20 @@ private func messageGroupType(messages: [EngineMessage]) -> MessageGroupType { return currentType } +private func paidContentGroupType(paidContent: TelegramMediaPaidContent) -> MessageGroupType { + if paidContent.extendedMedia.isEmpty { + return .generic + } + let currentType = singleExtendedMediaType(extendedMedia: paidContent.extendedMedia[0]) + for i in 1 ..< paidContent.extendedMedia.count { + let nextType = singleExtendedMediaType(extendedMedia: paidContent.extendedMedia[i]) + if nextType != currentType { + return .generic + } + } + return currentType +} + public func chatListItemStrings(strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, dateTimeFormat: PresentationDateTimeFormat, contentSettings: ContentSettings, messages: [EngineMessage], chatPeer: EngineRenderedPeer, accountPeerId: EnginePeer.Id, enableMediaEmoji: Bool = true, isPeerGroup: Bool = false) -> (peer: EnginePeer?, hideAuthor: Bool, messageText: String, spoilers: [NSRange]?, customEmojiRanges: [(NSRange, ChatTextInputTextCustomEmojiAttribute)]?) { let peer: EnginePeer? @@ -76,42 +108,59 @@ public func chatListItemStrings(strings: PresentationStrings, nameDisplayOrder: } } + + let paidContent = message.media.first(where: { $0 is TelegramMediaPaidContent }) as? TelegramMediaPaidContent + var textIsReady = false - if messages.count > 1 { - let groupType = messageGroupType(messages: messages) + if messages.count > 1 || (paidContent != nil && (paidContent?.extendedMedia.count ?? 0) > 1) { + let groupType: MessageGroupType + let count: Int32 + if let paidContent { + groupType = paidContentGroupType(paidContent: paidContent) + count = Int32(paidContent.extendedMedia.count) + } else { + groupType = messageGroupType(messages: messages) + count = Int32(messages.count) + } switch groupType { case .photos: if !messageText.isEmpty { textIsReady = true } else { - messageText = strings.ChatList_MessagePhotos(Int32(messages.count)) + messageText = strings.ChatList_MessagePhotos(count) textIsReady = true } case .videos: if !messageText.isEmpty { textIsReady = true } else { - messageText = strings.ChatList_MessageVideos(Int32(messages.count)) + messageText = strings.ChatList_MessageVideos(count) textIsReady = true } case .music: if !messageText.isEmpty { textIsReady = true } else { - messageText = strings.ChatList_MessageMusic(Int32(messages.count)) + messageText = strings.ChatList_MessageMusic(count) textIsReady = true } case .files: if !messageText.isEmpty { textIsReady = true } else { - messageText = strings.ChatList_MessageFiles(Int32(messages.count)) + messageText = strings.ChatList_MessageFiles(count) textIsReady = true } case .generic: var messageTypes = Set() - for message in messages { - messageTypes.insert(singleMessageType(message: message)) + if let paidContent { + for extendedMedia in paidContent.extendedMedia { + messageTypes.insert(singleExtendedMediaType(extendedMedia: extendedMedia)) + } + } else { + for message in messages { + messageTypes.insert(singleMessageType(message: message)) + } } if messageTypes.count == 2 && messageTypes.contains(.photos) && messageTypes.contains(.videos) { if !messageText.isEmpty { @@ -124,6 +173,26 @@ public func chatListItemStrings(strings: PresentationStrings, nameDisplayOrder: if !textIsReady { for media in message.media { switch media { + case let paidContent as TelegramMediaPaidContent: + for extendedMedia in paidContent.extendedMedia { + let type = singleExtendedMediaType(extendedMedia: extendedMedia) + switch type { + case .photos: + if message.text.isEmpty { + messageText = strings.Message_Photo + } else if enableMediaEmoji { + messageText = "🖼 \(messageText)" + } + case .videos: + if message.text.isEmpty { + messageText = strings.Message_Video + } else if enableMediaEmoji { + messageText = "📹 \(messageText)" + } + default: + break + } + } case _ as TelegramMediaImage: if message.text.isEmpty { messageText = strings.Message_Photo @@ -337,12 +406,6 @@ public func chatListItemStrings(strings: PresentationStrings, nameDisplayOrder: if messageText.isEmpty, case let .Loaded(content) = webpage.content { messageText = content.displayUrl } - case _ as TelegramMediaPaidContent: - if message.text.isEmpty { - messageText = "Paid Media" - } else { - messageText = "🖼 \(messageText)" - } default: break } diff --git a/submodules/GalleryData/Sources/GalleryData.swift b/submodules/GalleryData/Sources/GalleryData.swift index dbb339d882..5e9bfedecc 100644 --- a/submodules/GalleryData/Sources/GalleryData.swift +++ b/submodules/GalleryData/Sources/GalleryData.swift @@ -91,7 +91,7 @@ public func instantPageGalleryMedia(webpageId: MediaId, page: InstantPage, galle return result } -public func chatMessageGalleryControllerData(context: AccountContext, chatLocation: ChatLocation?, chatFilterTag: MemoryBuffer?, chatLocationContextHolder: Atomic?, message: Message, navigationController: NavigationController?, standalone: Bool, reverseMessageGalleryOrder: Bool, mode: ChatControllerInteractionOpenMessageMode, source: GalleryControllerItemSource?, synchronousLoad: Bool, actionInteraction: GalleryControllerActionInteraction?) -> ChatMessageGalleryControllerData? { +public func chatMessageGalleryControllerData(context: AccountContext, chatLocation: ChatLocation?, chatFilterTag: MemoryBuffer?, chatLocationContextHolder: Atomic?, message: Message, mediaIndex: Int? = nil, navigationController: NavigationController?, standalone: Bool, reverseMessageGalleryOrder: Bool, mode: ChatControllerInteractionOpenMessageMode, source: GalleryControllerItemSource?, synchronousLoad: Bool, actionInteraction: GalleryControllerActionInteraction?) -> ChatMessageGalleryControllerData? { var standalone = standalone if message.id.peerId.namespace == Namespaces.Peer.CloudUser && message.id.namespace != Namespaces.Message.Cloud { standalone = true @@ -111,9 +111,9 @@ public func chatMessageGalleryControllerData(context: AccountContext, chatLocati } } for media in message.media { - if let paidContent = media as? TelegramMediaPaidContent, let extendedMedia = paidContent.extendedMedia.first, case let .full(fullMedia) = extendedMedia { + if let paidContent = media as? TelegramMediaPaidContent, let extendedMedia = paidContent.extendedMedia.first, case .full = extendedMedia { standalone = true - galleryMedia = fullMedia + galleryMedia = paidContent } else if let invoice = media as? TelegramMediaInvoice, let extendedMedia = invoice.extendedMedia, case let .full(fullMedia) = extendedMedia { standalone = true galleryMedia = fullMedia @@ -241,7 +241,7 @@ public func chatMessageGalleryControllerData(context: AccountContext, chatLocati var source = source if standalone { - source = .standaloneMessage(message) + source = .standaloneMessage(message, nil) } if internalDocumentItemSupportsMimeType(file.mimeType, fileName: file.fileName ?? "file") { @@ -280,7 +280,7 @@ public func chatMessageGalleryControllerData(context: AccountContext, chatLocati return .gallery(startState |> deliverOnMainQueue |> map { startState in - let gallery = GalleryController(context: context, source: source ?? (standalone ? .standaloneMessage(message) : .peerMessagesAtId(messageId: message.id, chatLocation: openChatLocation, customTag: chatFilterTag, chatLocationContextHolder: openChatLocationContextHolder)), invertItemOrder: reverseMessageGalleryOrder, streamSingleVideo: stream, fromPlayingVideo: autoplayingVideo, landscape: landscape, timecode: startState.timecode, playbackRate: startState.rate, synchronousLoad: synchronousLoad, replaceRootController: { [weak navigationController] controller, ready in + let gallery = GalleryController(context: context, source: source ?? (standalone ? .standaloneMessage(message, mediaIndex) : .peerMessagesAtId(messageId: message.id, chatLocation: openChatLocation, customTag: chatFilterTag, chatLocationContextHolder: openChatLocationContextHolder)), invertItemOrder: reverseMessageGalleryOrder, streamSingleVideo: stream, fromPlayingVideo: autoplayingVideo, landscape: landscape, timecode: startState.timecode, playbackRate: startState.rate, synchronousLoad: synchronousLoad, replaceRootController: { [weak navigationController] controller, ready in navigationController?.replaceTopController(controller, animated: false, ready: ready) }, baseNavigationController: navigationController, actionInteraction: actionInteraction) gallery.temporaryDoNotWaitForReady = autoplayingVideo diff --git a/submodules/GalleryUI/Sources/ChatItemGalleryFooterContentNode.swift b/submodules/GalleryUI/Sources/ChatItemGalleryFooterContentNode.swift index 7d2eaa2fcc..2d69388461 100644 --- a/submodules/GalleryUI/Sources/ChatItemGalleryFooterContentNode.swift +++ b/submodules/GalleryUI/Sources/ChatItemGalleryFooterContentNode.swift @@ -903,7 +903,7 @@ final class ChatItemGalleryFooterContentNode: GalleryFooterContentNode, ASScroll canEdit = false } - if message.isCopyProtected() || peerIsCopyProtected { + if message.isCopyProtected() || peerIsCopyProtected || message.paidContent != nil { canShare = false canEdit = false } @@ -931,7 +931,9 @@ final class ChatItemGalleryFooterContentNode: GalleryFooterContentNode, ASScroll var messageText = NSAttributedString(string: "") var hasCaption = false for media in message.media { - if media is TelegramMediaImage { + if media is TelegramMediaPaidContent { + hasCaption = true + } else if media is TelegramMediaImage { hasCaption = true } else if let file = media as? TelegramMediaFile { hasCaption = file.mimeType.hasPrefix("image/") diff --git a/submodules/GalleryUI/Sources/GalleryController.swift b/submodules/GalleryUI/Sources/GalleryController.swift index 37dc46d1ac..8c80f27d78 100644 --- a/submodules/GalleryUI/Sources/GalleryController.swift +++ b/submodules/GalleryUI/Sources/GalleryController.swift @@ -44,9 +44,7 @@ private func tagsForMessage(_ message: Message) -> MessageTags? { } private func galleryMediaForMedia(media: Media) -> Media? { - if let paidContent = media as? TelegramMediaPaidContent, let extendedMedia = paidContent.extendedMedia.first, case let .full(fullMedia) = extendedMedia { - return fullMedia - } else if let invoice = media as? TelegramMediaInvoice, let extendedMedia = invoice.extendedMedia, case let .full(fullMedia) = extendedMedia { + if let invoice = media as? TelegramMediaInvoice, let extendedMedia = invoice.extendedMedia, case let .full(fullMedia) = extendedMedia { return fullMedia } else if let media = media as? TelegramMediaImage { return media @@ -62,22 +60,30 @@ private func galleryMediaForMedia(media: Media) -> Media? { return nil } -private func mediaForMessage(message: Message) -> (Media, TelegramMediaImage?)? { +private func mediaForMessage(message: Message) -> [(Media, TelegramMediaImage?)] { for media in message.media { if let result = galleryMediaForMedia(media: media) { - return (result, nil) + return [(result, nil)] + } else if let paidContent = media as? TelegramMediaPaidContent { + var results: [(Media, TelegramMediaImage?)] = [] + for case let .full(fullMedia) in paidContent.extendedMedia { + if let result = galleryMediaForMedia(media: fullMedia) { + results.append((result, nil)) + } + } + return results } else if let webpage = media as? TelegramMediaWebpage { switch webpage.content { case let .Loaded(content): if let embedUrl = content.embedUrl, !embedUrl.isEmpty { - return (webpage, nil) + return [(webpage, nil)] } else if let file = content.file { if let result = galleryMediaForMedia(media: file) { - return (result, content.image) + return [(result, content.image)] } } else if let image = content.image { if let result = galleryMediaForMedia(media: image) { - return (result, nil) + return [(result, nil)] } } case .Pending: @@ -85,7 +91,7 @@ private func mediaForMessage(message: Message) -> (Media, TelegramMediaImage?)? } } } - return nil + return [] } private let internalExtensions = Set([ @@ -176,7 +182,7 @@ private func galleryMessageCaptionText(_ message: Message) -> String { public func galleryItemForEntry( context: AccountContext, presentationData: PresentationData, - entry: MessageHistoryEntry, + entry: GalleryEntry, isCentral: Bool = false, streamVideos: Bool, loopVideos: Bool = false, @@ -198,195 +204,209 @@ public func galleryItemForEntry( generateStoreAfterDownload: ((Message, TelegramMediaFile) -> (() -> Void)?)? = nil, present: @escaping (ViewController, Any?) -> Void) -> GalleryItem? { - let message = entry.message - let location = entry.location - if let (media, mediaImage) = mediaForMessage(message: message) { - if let _ = media as? TelegramMediaImage { - return ChatImageGalleryItem( - context: context, - presentationData: presentationData, - message: message, - location: location, - translateToLanguage: translateToLanguage, - peerIsCopyProtected: peerIsCopyProtected, - isSecret: isSecret, - displayInfoOnTop: displayInfoOnTop, - performAction: performAction, - openActionOptions: openActionOptions, - present: present - ) - } else if let file = media as? TelegramMediaFile { - if file.isVideo { - let content: UniversalVideoContent - let captureProtected = message.isCopyProtected() || message.containsSecretMedia || message.minAutoremoveOrClearTimeout == viewOnceTimeout - if file.isAnimated { - content = NativeVideoContent(id: .message(message.stableId, file.fileId), userLocation: .peer(message.id.peerId), fileReference: .message(message: MessageReference(message), media: file), imageReference: mediaImage.flatMap({ ImageMediaReference.message(message: MessageReference(message), media: $0) }), loopVideo: true, enableSound: false, tempFilePath: tempFilePath, captureProtected: captureProtected, storeAfterDownload: generateStoreAfterDownload?(message, file)) + let message = entry.entry.message + let location = entry.location ?? entry.entry.location + let messageMedia = mediaForMessage(message: message) + + let mediaAndMediaImage: (Media, TelegramMediaImage?)? + if let mediaIndex = entry.mediaIndex { + if mediaIndex < messageMedia.count { + mediaAndMediaImage = messageMedia[Int(mediaIndex)] + } else { + mediaAndMediaImage = nil + } + } else { + mediaAndMediaImage = messageMedia.first + } + guard let (media, mediaImage) = mediaAndMediaImage else { + return nil + } + + if let _ = media as? TelegramMediaImage { + return ChatImageGalleryItem( + context: context, + presentationData: presentationData, + message: message, + mediaIndex: entry.mediaIndex, + location: location, + translateToLanguage: translateToLanguage, + peerIsCopyProtected: peerIsCopyProtected, + isSecret: isSecret, + displayInfoOnTop: displayInfoOnTop, + performAction: performAction, + openActionOptions: openActionOptions, + present: present + ) + } else if let file = media as? TelegramMediaFile { + if file.isVideo { + let content: UniversalVideoContent + let captureProtected = message.isCopyProtected() || message.containsSecretMedia || message.minAutoremoveOrClearTimeout == viewOnceTimeout + if file.isAnimated { + content = NativeVideoContent(id: .message(message.stableId, file.fileId), userLocation: .peer(message.id.peerId), fileReference: .message(message: MessageReference(message), media: file), imageReference: mediaImage.flatMap({ ImageMediaReference.message(message: MessageReference(message), media: $0) }), loopVideo: true, enableSound: false, tempFilePath: tempFilePath, captureProtected: captureProtected, storeAfterDownload: generateStoreAfterDownload?(message, file)) + } else { + if true || (file.mimeType == "video/mpeg4" || file.mimeType == "video/mov" || file.mimeType == "video/mp4") { + content = NativeVideoContent(id: .message(message.stableId, file.fileId), userLocation: .peer(message.id.peerId), fileReference: .message(message: MessageReference(message), media: file), imageReference: mediaImage.flatMap({ ImageMediaReference.message(message: MessageReference(message), media: $0) }), streamVideo: .conservative, loopVideo: loopVideos, tempFilePath: tempFilePath, captureProtected: captureProtected, storeAfterDownload: generateStoreAfterDownload?(message, file)) } else { - if true || (file.mimeType == "video/mpeg4" || file.mimeType == "video/mov" || file.mimeType == "video/mp4") { - content = NativeVideoContent(id: .message(message.stableId, file.fileId), userLocation: .peer(message.id.peerId), fileReference: .message(message: MessageReference(message), media: file), imageReference: mediaImage.flatMap({ ImageMediaReference.message(message: MessageReference(message), media: $0) }), streamVideo: .conservative, loopVideo: loopVideos, tempFilePath: tempFilePath, captureProtected: captureProtected, storeAfterDownload: generateStoreAfterDownload?(message, file)) - } else { - content = PlatformVideoContent(id: .message(message.id, message.stableId, file.fileId), userLocation: .peer(message.id.peerId), content: .file(.message(message: MessageReference(message), media: file)), streamVideo: streamVideos, loopVideo: loopVideos) - } + content = PlatformVideoContent(id: .message(message.id, message.stableId, file.fileId), userLocation: .peer(message.id.peerId), content: .file(.message(message: MessageReference(message), media: file)), streamVideo: streamVideos, loopVideo: loopVideos) } - - var entities: [MessageTextEntity] = [] + } + + var entities: [MessageTextEntity] = [] + for attribute in message.attributes { + if let attribute = attribute as? TextEntitiesMessageAttribute { + entities = attribute.entities + break + } + } + var text = galleryMessageCaptionText(message) + if let translateToLanguage, !text.isEmpty { for attribute in message.attributes { - if let attribute = attribute as? TextEntitiesMessageAttribute { + if let attribute = attribute as? TranslationMessageAttribute, !attribute.text.isEmpty, attribute.toLang == translateToLanguage { + text = attribute.text entities = attribute.entities break } } - var text = galleryMessageCaptionText(message) - if let translateToLanguage, !text.isEmpty { - for attribute in message.attributes { - if let attribute = attribute as? TranslationMessageAttribute, !attribute.text.isEmpty, attribute.toLang == translateToLanguage { - text = attribute.text - entities = attribute.entities - break - } - } + } + + if let result = addLocallyGeneratedEntities(text, enabledTypes: [.timecode], entities: entities, mediaDuration: file.duration.flatMap(Double.init)) { + entities = result + } + + var originData = GalleryItemOriginData(title: message.effectiveAuthor.flatMap(EnginePeer.init)?.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder), timestamp: message.timestamp) + if Namespaces.Message.allNonRegular.contains(message.id.namespace) { + originData = GalleryItemOriginData(title: nil, timestamp: nil) + } + + let caption = galleryCaptionStringWithAppliedEntities(context: context, text: text, entities: entities, message: message) + return UniversalVideoGalleryItem( + context: context, + presentationData: presentationData, + content: content, + originData: originData, + indexData: location.flatMap { GalleryItemIndexData(position: Int32($0.index), totalCount: Int32($0.count)) }, + contentInfo: .message(message), + caption: caption, + displayInfoOnTop: displayInfoOnTop, + hideControls: hideControls, + fromPlayingVideo: fromPlayingVideo, + isSecret: isSecret, + landscape: landscape, + timecode: timecode, + peerIsCopyProtected: peerIsCopyProtected, + playbackRate: playbackRate, + configuration: configuration, + playbackCompleted: playbackCompleted, + performAction: performAction, + openActionOptions: openActionOptions, + storeMediaPlaybackState: storeMediaPlaybackState, + present: present + ) + } else { + if let fileName = file.fileName, (fileName as NSString).pathExtension.lowercased() == "json" { + return ChatAnimationGalleryItem(context: context, presentationData: presentationData, message: message, location: location) + } + else if file.mimeType.hasPrefix("image/") && file.mimeType != "image/gif" { + var pixelsCount: Int = 0 + if let dimensions = file.dimensions { + pixelsCount = Int(dimensions.width) * Int(dimensions.height) } - - if let result = addLocallyGeneratedEntities(text, enabledTypes: [.timecode], entities: entities, mediaDuration: file.duration.flatMap(Double.init)) { - entities = result - } - - var originData = GalleryItemOriginData(title: message.effectiveAuthor.flatMap(EnginePeer.init)?.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder), timestamp: message.timestamp) - if Namespaces.Message.allNonRegular.contains(message.id.namespace) { - originData = GalleryItemOriginData(title: nil, timestamp: nil) - } - - let caption = galleryCaptionStringWithAppliedEntities(context: context, text: text, entities: entities, message: message) - return UniversalVideoGalleryItem( - context: context, - presentationData: presentationData, - content: content, - originData: originData, - indexData: location.flatMap { GalleryItemIndexData(position: Int32($0.index), totalCount: Int32($0.count)) }, - contentInfo: .message(message), - caption: caption, - displayInfoOnTop: displayInfoOnTop, - hideControls: hideControls, - fromPlayingVideo: fromPlayingVideo, - isSecret: isSecret, - landscape: landscape, - timecode: timecode, - peerIsCopyProtected: peerIsCopyProtected, - playbackRate: playbackRate, - configuration: configuration, - playbackCompleted: playbackCompleted, - performAction: performAction, - openActionOptions: openActionOptions, - storeMediaPlaybackState: storeMediaPlaybackState, - present: present - ) - } else { - if let fileName = file.fileName, (fileName as NSString).pathExtension.lowercased() == "json" { - return ChatAnimationGalleryItem(context: context, presentationData: presentationData, message: message, location: location) - } - else if file.mimeType.hasPrefix("image/") && file.mimeType != "image/gif" { - var pixelsCount: Int = 0 - if let dimensions = file.dimensions { - pixelsCount = Int(dimensions.width) * Int(dimensions.height) - } - if pixelsCount < 10000 * 10000 { - return ChatImageGalleryItem( - context: context, - presentationData: presentationData, - message: message, - location: location, - translateToLanguage: translateToLanguage, - peerIsCopyProtected: peerIsCopyProtected, - isSecret: isSecret, - displayInfoOnTop: displayInfoOnTop, - performAction: performAction, - openActionOptions: openActionOptions, - present: present - ) - } else { - return ChatDocumentGalleryItem( - context: context, - presentationData: presentationData, - message: message, - location: location - ) - } - } else if internalDocumentItemSupportsMimeType(file.mimeType, fileName: file.fileName) { + if pixelsCount < 10000 * 10000 { + return ChatImageGalleryItem( + context: context, + presentationData: presentationData, + message: message, + location: location, + translateToLanguage: translateToLanguage, + peerIsCopyProtected: peerIsCopyProtected, + isSecret: isSecret, + displayInfoOnTop: displayInfoOnTop, + performAction: performAction, + openActionOptions: openActionOptions, + present: present + ) + } else { return ChatDocumentGalleryItem( context: context, presentationData: presentationData, message: message, location: location ) - } else { - return ChatExternalFileGalleryItem( - context: context, - presentationData: presentationData, - message: message, - location: location - ) } - } - } else if let webpage = media as? TelegramMediaWebpage, case let .Loaded(webpageContent) = webpage.content { - var content: UniversalVideoContent? - switch websiteType(of: webpageContent.websiteName) { - case .instagram where webpageContent.file != nil && webpageContent.image != nil && webpageContent.file!.isVideo: - content = NativeVideoContent(id: .message(message.stableId, webpageContent.file?.id ?? webpage.webpageId), userLocation: .peer(message.id.peerId), fileReference: .message(message: MessageReference(message), media: webpageContent.file!), imageReference: webpageContent.image.flatMap({ ImageMediaReference.message(message: MessageReference(message), media: $0) }), streamVideo: .conservative, enableSound: true, captureProtected: message.isCopyProtected() || message.containsSecretMedia, storeAfterDownload: nil) - default: - if let embedUrl = webpageContent.embedUrl, let image = webpageContent.image { - if let file = webpageContent.file, file.isVideo { - content = NativeVideoContent(id: .message(message.stableId, file.fileId), userLocation: .peer(message.id.peerId), fileReference: .message(message: MessageReference(message), media: file), imageReference: mediaImage.flatMap({ ImageMediaReference.message(message: MessageReference(message), media: $0) }), streamVideo: .conservative, loopVideo: loopVideos, tempFilePath: tempFilePath, captureProtected: message.isCopyProtected() || message.containsSecretMedia, storeAfterDownload: generateStoreAfterDownload?(message, file)) - } else if URL(string: embedUrl)?.pathExtension == "mp4" { - content = SystemVideoContent(userLocation: .peer(message.id.peerId), url: embedUrl, imageReference: .webPage(webPage: WebpageReference(webpage), media: image), dimensions: webpageContent.embedSize?.cgSize ?? CGSize(width: 640.0, height: 640.0), duration: webpageContent.duration.flatMap(Double.init) ?? 0.0) - } - } - if content == nil, let webEmbedContent = WebEmbedVideoContent(userLocation: .peer(message.id.peerId), webPage: webpage, webpageContent: webpageContent, forcedTimestamp: timecode.flatMap(Int.init), openUrl: { url in - performAction(.url(url: url.absoluteString, concealed: false)) - }) { - content = webEmbedContent - } - } - if let content = content { - var description: NSAttributedString? - if let descriptionText = webpageContent.text { - var entities: [MessageTextEntity] = [] - if let result = addLocallyGeneratedEntities(descriptionText, enabledTypes: [.timecode], entities: entities, mediaDuration: 86400) { - entities = result - } - description = galleryCaptionStringWithAppliedEntities(context: context, text: descriptionText, entities: entities, message: message) - } - - var originData = GalleryItemOriginData(title: message.effectiveAuthor.flatMap(EnginePeer.init)?.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder), timestamp: message.timestamp) - if Namespaces.Message.allNonRegular.contains(message.id.namespace) { - originData = GalleryItemOriginData(title: nil, timestamp: nil) - } - - return UniversalVideoGalleryItem( + } else if internalDocumentItemSupportsMimeType(file.mimeType, fileName: file.fileName) { + return ChatDocumentGalleryItem( context: context, presentationData: presentationData, - content: content, - originData: originData, - indexData: location.flatMap { GalleryItemIndexData(position: Int32($0.index), totalCount: Int32($0.count)) }, - contentInfo: .message(message), - caption: NSAttributedString(string: ""), - description: description, - displayInfoOnTop: displayInfoOnTop, - fromPlayingVideo: fromPlayingVideo, - isSecret: isSecret, - landscape: landscape, - timecode: timecode, - playbackRate: playbackRate, - configuration: configuration, - performAction: performAction, - openActionOptions: openActionOptions, - storeMediaPlaybackState: storeMediaPlaybackState, - present: present + message: message, + location: location ) } else { - return nil + return ChatExternalFileGalleryItem( + context: context, + presentationData: presentationData, + message: message, + location: location + ) } } + } else if let webpage = media as? TelegramMediaWebpage, case let .Loaded(webpageContent) = webpage.content { + var content: UniversalVideoContent? + switch websiteType(of: webpageContent.websiteName) { + case .instagram where webpageContent.file != nil && webpageContent.image != nil && webpageContent.file!.isVideo: + content = NativeVideoContent(id: .message(message.stableId, webpageContent.file?.id ?? webpage.webpageId), userLocation: .peer(message.id.peerId), fileReference: .message(message: MessageReference(message), media: webpageContent.file!), imageReference: webpageContent.image.flatMap({ ImageMediaReference.message(message: MessageReference(message), media: $0) }), streamVideo: .conservative, enableSound: true, captureProtected: message.isCopyProtected() || message.containsSecretMedia, storeAfterDownload: nil) + default: + if let embedUrl = webpageContent.embedUrl, let image = webpageContent.image { + if let file = webpageContent.file, file.isVideo { + content = NativeVideoContent(id: .message(message.stableId, file.fileId), userLocation: .peer(message.id.peerId), fileReference: .message(message: MessageReference(message), media: file), imageReference: mediaImage.flatMap({ ImageMediaReference.message(message: MessageReference(message), media: $0) }), streamVideo: .conservative, loopVideo: loopVideos, tempFilePath: tempFilePath, captureProtected: message.isCopyProtected() || message.containsSecretMedia, storeAfterDownload: generateStoreAfterDownload?(message, file)) + } else if URL(string: embedUrl)?.pathExtension == "mp4" { + content = SystemVideoContent(userLocation: .peer(message.id.peerId), url: embedUrl, imageReference: .webPage(webPage: WebpageReference(webpage), media: image), dimensions: webpageContent.embedSize?.cgSize ?? CGSize(width: 640.0, height: 640.0), duration: webpageContent.duration.flatMap(Double.init) ?? 0.0) + } + } + if content == nil, let webEmbedContent = WebEmbedVideoContent(userLocation: .peer(message.id.peerId), webPage: webpage, webpageContent: webpageContent, forcedTimestamp: timecode.flatMap(Int.init), openUrl: { url in + performAction(.url(url: url.absoluteString, concealed: false)) + }) { + content = webEmbedContent + } + } + if let content = content { + var description: NSAttributedString? + if let descriptionText = webpageContent.text { + var entities: [MessageTextEntity] = [] + if let result = addLocallyGeneratedEntities(descriptionText, enabledTypes: [.timecode], entities: entities, mediaDuration: 86400) { + entities = result + } + description = galleryCaptionStringWithAppliedEntities(context: context, text: descriptionText, entities: entities, message: message) + } + + var originData = GalleryItemOriginData(title: message.effectiveAuthor.flatMap(EnginePeer.init)?.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder), timestamp: message.timestamp) + if Namespaces.Message.allNonRegular.contains(message.id.namespace) { + originData = GalleryItemOriginData(title: nil, timestamp: nil) + } + + return UniversalVideoGalleryItem( + context: context, + presentationData: presentationData, + content: content, + originData: originData, + indexData: location.flatMap { GalleryItemIndexData(position: Int32($0.index), totalCount: Int32($0.count)) }, + contentInfo: .message(message), + caption: NSAttributedString(string: ""), + description: description, + displayInfoOnTop: displayInfoOnTop, + fromPlayingVideo: fromPlayingVideo, + isSecret: isSecret, + landscape: landscape, + timecode: timecode, + playbackRate: playbackRate, + configuration: configuration, + performAction: performAction, + openActionOptions: openActionOptions, + storeMediaPlaybackState: storeMediaPlaybackState, + present: present + ) + } } + return nil } @@ -493,6 +513,38 @@ public struct GalleryConfiguration { } } +public struct GalleryEntryStableId: Hashable { + public var stableId: UInt32 + public var mediaIndex: Int? +} + +public struct GalleryEntry { + public var entry: MessageHistoryEntry + public var mediaIndex: Int? + public var location: MessageHistoryEntryLocation? + + public var stableId: GalleryEntryStableId { + return GalleryEntryStableId(stableId: self.entry.message.stableId, mediaIndex: self.mediaIndex) + } +} + +private func galleryEntriesForMessageHistoryEntries(_ entries: [MessageHistoryEntry]) -> [GalleryEntry] { + var results: [GalleryEntry] = [] + for entry in entries { + let mediaCount = mediaForMessage(message: entry.message).count + if mediaCount > 0 { + if mediaCount > 1 { + for i in 0 ..< mediaCount { + results.append(GalleryEntry(entry: entry, mediaIndex: i, location: MessageHistoryEntryLocation(index: i, count: mediaCount))) + } + } else { + results.append(GalleryEntry(entry: entry)) + } + } + } + return results +} + public class GalleryController: ViewController, StandalonePresentableController, KeyShortcutResponder { public static let darkNavigationTheme = NavigationBarTheme(buttonColor: .white, disabledButtonColor: UIColor(rgb: 0x525252), primaryTextColor: .white, backgroundColor: UIColor(white: 0.0, alpha: 0.6), enableBackgroundBlur: false, separatorColor: UIColor(white: 0.0, alpha: 0.8), badgeBackgroundColor: .clear, badgeStrokeColor: .clear, badgeTextColor: .clear) public static let lightNavigationTheme = NavigationBarTheme(buttonColor: UIColor(rgb: 0x007aff), disabledButtonColor: UIColor(rgb: 0xd0d0d0), primaryTextColor: .black, backgroundColor: UIColor(red: 0.968626451, green: 0.968626451, blue: 0.968626451, alpha: 1.0), enableBackgroundBlur: false, separatorColor: UIColor(red: 0.6953125, green: 0.6953125, blue: 0.6953125, alpha: 1.0), badgeBackgroundColor: .clear, badgeStrokeColor: .clear, badgeTextColor: .clear) @@ -526,12 +578,12 @@ public class GalleryController: ViewController, StandalonePresentableController, private let disposable = MetaDisposable() private var peerIsCopyProtected = false - private var entries: [MessageHistoryEntry] = [] + private var entries: [GalleryEntry] = [] private var hasLeftEntries: Bool = false private var hasRightEntries: Bool = false private var loadingMore: Bool = false private var tag: HistoryViewInputTag? - private var centralEntryStableId: UInt32? + private var centralEntryStableId: GalleryEntryStableId? private var configuration: GalleryConfiguration? private let centralItemTitle = Promise() @@ -650,7 +702,7 @@ public class GalleryController: ViewController, StandalonePresentableController, return nil } } - case let .standaloneMessage(m): + case let .standaloneMessage(m, _): message = .single((m, m.isCopyProtected())) case let .custom(messages, messageId, _): message = messages @@ -732,24 +784,25 @@ public class GalleryController: ViewController, StandalonePresentableController, let configuration = GalleryConfiguration.with(appConfiguration: appConfiguration) strongSelf.configuration = configuration - let entries = view.entries - var centralEntryStableId: UInt32? + let entries = galleryEntriesForMessageHistoryEntries(view.entries) + var centralEntryStableId: GalleryEntryStableId? loop: for i in 0 ..< entries.count { - let message = entries[i].message + let entry = entries[i] + let message = entry.entry.message switch source { case let .peerMessagesAtId(messageId, _, _, _): if message.id == messageId { - centralEntryStableId = message.stableId + centralEntryStableId = entry.stableId break loop } - case let .standaloneMessage(m): - if message.id == m.id { - centralEntryStableId = message.stableId + case let .standaloneMessage(m, mediaIndex): + if message.id == m.id && entry.mediaIndex == mediaIndex { + centralEntryStableId = entry.stableId break loop } case let .custom(_, messageId, _): if message.id == messageId { - centralEntryStableId = message.stableId + centralEntryStableId = entry.stableId break loop } } @@ -775,7 +828,7 @@ public class GalleryController: ViewController, StandalonePresentableController, var centralItemIndex: Int? for entry in strongSelf.entries { var isCentral = false - if entry.message.stableId == strongSelf.centralEntryStableId { + if entry.stableId == strongSelf.centralEntryStableId { isCentral = true } if let item = galleryItemForEntry(context: context, presentationData: strongSelf.presentationData, entry: entry, isCentral: isCentral, streamVideos: streamSingleVideo, fromPlayingVideo: isCentral && fromPlayingVideo, landscape: isCentral && landscape, timecode: isCentral ? timecode : nil, playbackRate: { return self?.playbackRate }, displayInfoOnTop: displayInfoOnTop, configuration: configuration, translateToLanguage: translateToLanguage, peerIsCopyProtected: view.peerIsCopyProtected, performAction: strongSelf.performAction, openActionOptions: strongSelf.openActionOptions, storeMediaPlaybackState: strongSelf.actionInteraction?.storeMediaPlaybackState ?? { _, _, _ in }, generateStoreAfterDownload: strongSelf.generateStoreAfterDownload, present: { [weak self] c, a in @@ -1227,13 +1280,25 @@ public class GalleryController: ViewController, StandalonePresentableController, } if let centralItemNode = self.galleryNode.pager.centralItemNode(), let presentationArguments = self.presentationArguments as? GalleryControllerPresentationArguments { - let message = self.entries[centralItemNode.index].message - if let (media, _) = mediaForMessage(message: message), let transitionArguments = presentationArguments.transitionArguments(message.id, media), !forceAway { - animatedOutNode = false - centralItemNode.animateOut(to: transitionArguments.transitionNode, addToTransitionSurface: transitionArguments.addToTransitionSurface, completion: { - animatedOutNode = true - completion() - }) + let entry = self.entries[centralItemNode.index] + let message = entry.entry.message + let media = mediaForMessage(message: message) + if !media.isEmpty { + var selectedMedia: Media? + if let mediaIndex = entry.mediaIndex { + if mediaIndex < media.count { + selectedMedia = media[Int(mediaIndex)].0 + } + } else if let media = media.first { + selectedMedia = media.0 + } + if let selectedMedia, let transitionArguments = presentationArguments.transitionArguments(message.id, selectedMedia), !forceAway { + animatedOutNode = false + centralItemNode.animateOut(to: transitionArguments.transitionNode, addToTransitionSurface: transitionArguments.addToTransitionSurface, completion: { + animatedOutNode = true + completion() + }) + } } } @@ -1287,9 +1352,22 @@ public class GalleryController: ViewController, StandalonePresentableController, self.galleryNode.transitionDataForCentralItem = { [weak self] in if let strongSelf = self { if let centralItemNode = strongSelf.galleryNode.pager.centralItemNode(), let presentationArguments = strongSelf.presentationArguments as? GalleryControllerPresentationArguments { - let message = strongSelf.entries[centralItemNode.index].message - if let (media, _) = mediaForMessage(message: message), let transitionArguments = presentationArguments.transitionArguments(message.id, media) { - return (transitionArguments.transitionNode, transitionArguments.addToTransitionSurface) + let entry = strongSelf.entries[centralItemNode.index] + let message = entry.entry.message + let media = mediaForMessage(message: message) + if !media.isEmpty { + var selectedMedia: Media? + if let mediaIndex = entry.mediaIndex { + if mediaIndex < media.count { + selectedMedia = media[Int(mediaIndex)].0 + } + } else if let media = media.first { + selectedMedia = media.0 + } + + if let selectedMedia, let transitionArguments = presentationArguments.transitionArguments(message.id, selectedMedia) { + return (transitionArguments.transitionNode, transitionArguments.addToTransitionSurface) + } } } } @@ -1355,7 +1433,7 @@ public class GalleryController: ViewController, StandalonePresentableController, var centralItemIndex: Int? for entry in self.entries { var isCentral = false - if entry.message.stableId == self.centralEntryStableId { + if entry.stableId == self.centralEntryStableId { isCentral = true } if let item = galleryItemForEntry(context: self.context, presentationData: self.presentationData, entry: entry, streamVideos: self.streamVideos, fromPlayingVideo: isCentral && self.fromPlayingVideo, landscape: isCentral && self.landscape, timecode: isCentral ? self.timecode : nil, playbackRate: { [weak self] in return self?.playbackRate }, displayInfoOnTop: displayInfoOnTop, configuration: self.configuration, peerIsCopyProtected: self.peerIsCopyProtected, performAction: self.performAction, openActionOptions: self.openActionOptions, storeMediaPlaybackState: self.actionInteraction?.storeMediaPlaybackState ?? { _, _, _ in }, generateStoreAfterDownload: self.generateStoreAfterDownload, present: { [weak self] c, a in @@ -1376,11 +1454,19 @@ public class GalleryController: ViewController, StandalonePresentableController, if let strongSelf = self { var hiddenItem: (MessageId, Media)? if let index = index { - let message = strongSelf.entries[index].message + let entry = strongSelf.entries[index] + let message = strongSelf.entries[index].entry.message - strongSelf.centralEntryStableId = message.stableId - if let (media, _) = mediaForMessage(message: message) { - hiddenItem = (message.id, media) + strongSelf.centralEntryStableId = entry.stableId + let media = mediaForMessage(message: message) + if !media.isEmpty { + if let mediaIndex = entry.mediaIndex { + if mediaIndex < media.count { + hiddenItem = (message.id, media[Int(mediaIndex)].0) + } + } else if let media = media.first { + hiddenItem = (message.id, media.0) + } } if let node = strongSelf.galleryNode.pager.centralItemNode() { @@ -1397,9 +1483,9 @@ public class GalleryController: ViewController, StandalonePresentableController, case let .peerMessagesAtId(_, chatLocation, _, chatLocationContextHolder): var reloadAroundIndex: MessageIndex? if index <= 2 && strongSelf.hasLeftEntries { - reloadAroundIndex = strongSelf.entries.first?.index + reloadAroundIndex = strongSelf.entries.first?.entry.index } else if index >= strongSelf.entries.count - 3 && strongSelf.hasRightEntries { - reloadAroundIndex = strongSelf.entries.last?.index + reloadAroundIndex = strongSelf.entries.last?.entry.index } let peerIsCopyProtected = strongSelf.peerIsCopyProtected if let reloadAroundIndex = reloadAroundIndex, let tag = strongSelf.tag { @@ -1424,8 +1510,8 @@ public class GalleryController: ViewController, StandalonePresentableController, return } - let entries = view.entries - + let entries = galleryEntriesForMessageHistoryEntries(view.entries) + if strongSelf.invertItemOrder { strongSelf.entries = entries.reversed() strongSelf.hasLeftEntries = view.hasLater @@ -1440,7 +1526,7 @@ public class GalleryController: ViewController, StandalonePresentableController, var centralItemIndex: Int? for entry in strongSelf.entries { var isCentral = false - if entry.message.stableId == strongSelf.centralEntryStableId { + if entry.stableId == strongSelf.centralEntryStableId { isCentral = true } if let item = galleryItemForEntry(context: strongSelf.context, presentationData: strongSelf.presentationData, entry: entry, isCentral: isCentral, streamVideos: false, fromPlayingVideo: isCentral && strongSelf.fromPlayingVideo, landscape: isCentral && strongSelf.landscape, timecode: isCentral ? strongSelf.timecode : nil, playbackRate: { return self?.playbackRate }, displayInfoOnTop: displayInfoOnTop, configuration: strongSelf.configuration, peerIsCopyProtected: view.peerIsCopyProtected, performAction: strongSelf.performAction, openActionOptions: strongSelf.openActionOptions, storeMediaPlaybackState: strongSelf.actionInteraction?.storeMediaPlaybackState ?? { _, _, _ in }, generateStoreAfterDownload: strongSelf.generateStoreAfterDownload, present: { [weak self] c, a in @@ -1470,12 +1556,13 @@ public class GalleryController: ViewController, StandalonePresentableController, return } - var entries: [MessageHistoryEntry] = [] + var messageEntries: [MessageHistoryEntry] = [] var index = messages.count for message in messages.reversed() { - entries.append(MessageHistoryEntry(message: message, isRead: false, location: nil, monthLocation: nil, attributes: MutableMessageHistoryEntryAttributes(authorIsContact: false))) + messageEntries.append(MessageHistoryEntry(message: message, isRead: false, location: nil, monthLocation: nil, attributes: MutableMessageHistoryEntryAttributes(authorIsContact: false))) index -= 1 } + let entries = galleryEntriesForMessageHistoryEntries(messageEntries) if entries.count > strongSelf.entries.count { if strongSelf.invertItemOrder { @@ -1492,7 +1579,7 @@ public class GalleryController: ViewController, StandalonePresentableController, var centralItemIndex: Int? for entry in strongSelf.entries { var isCentral = false - if entry.message.stableId == strongSelf.centralEntryStableId { + if entry.stableId == strongSelf.centralEntryStableId { isCentral = true } if let item = galleryItemForEntry(context: strongSelf.context, presentationData: strongSelf.presentationData, entry: entry, isCentral: isCentral, streamVideos: false, fromPlayingVideo: isCentral && strongSelf.fromPlayingVideo, landscape: isCentral && strongSelf.landscape, timecode: isCentral ? strongSelf.timecode : nil, playbackRate: { return self?.playbackRate }, displayInfoOnTop: displayInfoOnTop, configuration: strongSelf.configuration, performAction: strongSelf.performAction, openActionOptions: strongSelf.openActionOptions, storeMediaPlaybackState: strongSelf.actionInteraction?.storeMediaPlaybackState ?? { _, _, _ in }, generateStoreAfterDownload: strongSelf.generateStoreAfterDownload, present: { [weak self] c, a in @@ -1551,7 +1638,8 @@ public class GalleryController: ViewController, StandalonePresentableController, var nodeAnimatesItself = false if let centralItemNode = self.galleryNode.pager.centralItemNode() { - let message = self.entries[centralItemNode.index].message + let entry = self.entries[centralItemNode.index] + self.centralItemTitle.set(centralItemNode.title()) self.centralItemTitleView.set(centralItemNode.titleView()) self.centralItemRightBarButtonItem.set(centralItemNode.rightBarButtonItem()) @@ -1559,17 +1647,30 @@ public class GalleryController: ViewController, StandalonePresentableController, self.centralItemNavigationStyle.set(centralItemNode.navigationStyle()) self.centralItemFooterContentNode.set(centralItemNode.footerContent()) self.galleryNode.pager.pagingEnabledPromise.set(centralItemNode.isPagingEnabled()) - - if let (media, _) = mediaForMessage(message: message) { - if let presentationArguments = self.presentationArguments as? GalleryControllerPresentationArguments, let transitionArguments = presentationArguments.transitionArguments(message.id, media) { - nodeAnimatesItself = true - if presentationArguments.animated { - centralItemNode.animateIn(from: transitionArguments.transitionNode, addToTransitionSurface: transitionArguments.addToTransitionSurface, completion: {}) + + let message = entry.entry.message + let media = mediaForMessage(message: message) + if !media.isEmpty { + var selectedMedia: Media? + if let mediaIndex = entry.mediaIndex { + if mediaIndex < media.count { + selectedMedia = media[Int(mediaIndex)].0 } - - self._hiddenMedia.set(.single((message.id, media))) + } else if let media = media.first { + selectedMedia = media.0 + } + + if let selectedMedia { + if let presentationArguments = self.presentationArguments as? GalleryControllerPresentationArguments, let transitionArguments = presentationArguments.transitionArguments(message.id, selectedMedia) { + nodeAnimatesItself = true + if presentationArguments.animated { + centralItemNode.animateIn(from: transitionArguments.transitionNode, addToTransitionSurface: transitionArguments.addToTransitionSurface, completion: {}) + } + + self._hiddenMedia.set(.single((message.id, selectedMedia))) + } + centralItemNode.activateAsInitial() } - centralItemNode.activateAsInitial() } self.onDidAppear?() @@ -1591,7 +1692,7 @@ public class GalleryController: ViewController, StandalonePresentableController, override public func didAppearInContextPreview() { if let centralItemNode = self.galleryNode.pager.centralItemNode() { - let message = self.entries[centralItemNode.index].message + let message = self.entries[centralItemNode.index].entry.message self.centralItemTitle.set(centralItemNode.title()) self.centralItemTitleView.set(centralItemNode.titleView()) self.centralItemRightBarButtonItem.set(centralItemNode.rightBarButtonItem()) @@ -1600,7 +1701,7 @@ public class GalleryController: ViewController, StandalonePresentableController, self.centralItemFooterContentNode.set(centralItemNode.footerContent()) self.galleryNode.pager.pagingEnabledPromise.set(centralItemNode.isPagingEnabled()) - if let _ = mediaForMessage(message: message) { + if !mediaForMessage(message: message).isEmpty { centralItemNode.activateAsInitial() } } diff --git a/submodules/GalleryUI/Sources/Items/ChatImageGalleryItem.swift b/submodules/GalleryUI/Sources/Items/ChatImageGalleryItem.swift index 95bb15495c..ae09d73a81 100644 --- a/submodules/GalleryUI/Sources/Items/ChatImageGalleryItem.swift +++ b/submodules/GalleryUI/Sources/Items/ChatImageGalleryItem.swift @@ -112,6 +112,7 @@ class ChatImageGalleryItem: GalleryItem { let context: AccountContext let presentationData: PresentationData let message: Message + let mediaIndex: Int? let location: MessageHistoryEntryLocation? let translateToLanguage: String? let peerIsCopyProtected: Bool @@ -121,10 +122,11 @@ class ChatImageGalleryItem: GalleryItem { let openActionOptions: (GalleryControllerInteractionTapAction, Message) -> Void let present: (ViewController, Any?) -> Void - init(context: AccountContext, presentationData: PresentationData, message: Message, location: MessageHistoryEntryLocation?, translateToLanguage: String? = nil, peerIsCopyProtected: Bool = false, isSecret: Bool = false, displayInfoOnTop: Bool, performAction: @escaping (GalleryControllerInteractionTapAction) -> Void, openActionOptions: @escaping (GalleryControllerInteractionTapAction, Message) -> Void, present: @escaping (ViewController, Any?) -> Void) { + init(context: AccountContext, presentationData: PresentationData, message: Message, mediaIndex: Int? = nil, location: MessageHistoryEntryLocation?, translateToLanguage: String? = nil, peerIsCopyProtected: Bool = false, isSecret: Bool = false, displayInfoOnTop: Bool, performAction: @escaping (GalleryControllerInteractionTapAction) -> Void, openActionOptions: @escaping (GalleryControllerInteractionTapAction, Message) -> Void, present: @escaping (ViewController, Any?) -> Void) { self.context = context self.presentationData = presentationData self.message = message + self.mediaIndex = mediaIndex self.location = location self.translateToLanguage = translateToLanguage self.peerIsCopyProtected = peerIsCopyProtected @@ -140,8 +142,11 @@ class ChatImageGalleryItem: GalleryItem { node.setMessage(self.message, displayInfo: !self.displayInfoOnTop, translateToLanguage: self.translateToLanguage, peerIsCopyProtected: self.peerIsCopyProtected, isSecret: self.isSecret) for media in self.message.media { - if let paidContent = media as? TelegramMediaPaidContent, let extendedMedia = paidContent.extendedMedia.first, case let .full(fullMedia) = extendedMedia, let image = fullMedia as? TelegramMediaImage { - node.setImage(userLocation: .peer(self.message.id.peerId), imageReference: .message(message: MessageReference(self.message), media: image)) + if let paidContent = media as? TelegramMediaPaidContent { + let mediaIndex = self.mediaIndex ?? 0 + if case let .full(fullMedia) = paidContent.extendedMedia[Int(mediaIndex)], let image = fullMedia as? TelegramMediaImage { + node.setImage(userLocation: .peer(self.message.id.peerId), imageReference: .message(message: MessageReference(self.message), media: image)) + } } else if let invoice = media as? TelegramMediaInvoice, let extendedMedia = invoice.extendedMedia, case let .full(fullMedia) = extendedMedia, let image = fullMedia as? TelegramMediaImage { node.setImage(userLocation: .peer(self.message.id.peerId), imageReference: .message(message: MessageReference(self.message), media: image)) } else if let image = media as? TelegramMediaImage { @@ -528,7 +533,7 @@ final class ChatImageGalleryItemNode: ZoomableContentGalleryItemNode { }) }))) - if !message.isCopyProtected() && !self.peerIsCopyProtected, let media = self.contextAndMedia?.1 { + if !message.isCopyProtected() && !self.peerIsCopyProtected && message.paidContent == nil, let media = self.contextAndMedia?.1 { items.append(.action(ContextMenuActionItem(text: self.presentationData.strings.Gallery_SaveImage, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Download"), color: theme.actionSheet.primaryTextColor) }, action: { [weak self] _, f in f(.default) diff --git a/submodules/GalleryUI/Sources/Items/UniversalVideoGalleryItem.swift b/submodules/GalleryUI/Sources/Items/UniversalVideoGalleryItem.swift index 204ef30309..d0f101d8e6 100644 --- a/submodules/GalleryUI/Sources/Items/UniversalVideoGalleryItem.swift +++ b/submodules/GalleryUI/Sources/Items/UniversalVideoGalleryItem.swift @@ -2573,7 +2573,7 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode { } } - if let (message, maybeFile, _) = strongSelf.contentInfo(), let file = maybeFile, !message.isCopyProtected() && !item.peerIsCopyProtected { + if let (message, maybeFile, _) = strongSelf.contentInfo(), let file = maybeFile, !message.isCopyProtected() && !item.peerIsCopyProtected && message.paidContent == nil { items.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.strings.Gallery_SaveVideo, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Download"), color: theme.actionSheet.primaryTextColor) }, action: { _, f in f(.default) diff --git a/submodules/GalleryUI/Sources/SecretMediaPreviewController.swift b/submodules/GalleryUI/Sources/SecretMediaPreviewController.swift index 911712c505..0360571fe4 100644 --- a/submodules/GalleryUI/Sources/SecretMediaPreviewController.swift +++ b/submodules/GalleryUI/Sources/SecretMediaPreviewController.swift @@ -525,7 +525,8 @@ public final class SecretMediaPreviewController: ViewController { } } - guard let item = galleryItemForEntry(context: self.context, presentationData: self.presentationData, entry: MessageHistoryEntry(message: message, isRead: false, location: nil, monthLocation: nil, attributes: MutableMessageHistoryEntryAttributes(authorIsContact: false)), streamVideos: false, hideControls: true, isSecret: true, playbackRate: { nil }, peerIsCopyProtected: true, tempFilePath: tempFilePath, playbackCompleted: { [weak self] in + let entry = GalleryEntry(entry: MessageHistoryEntry(message: message, isRead: false, location: nil, monthLocation: nil, attributes: MutableMessageHistoryEntryAttributes(authorIsContact: false))) + guard let item = galleryItemForEntry(context: self.context, presentationData: self.presentationData, entry: entry, streamVideos: false, hideControls: true, isSecret: true, playbackRate: { nil }, peerIsCopyProtected: true, tempFilePath: tempFilePath, playbackCompleted: { [weak self] in if let self { if self.currentNodeMessageIsViewOnce || (duration < 30.0 && !self.currentMessageIsDismissed) { if let node = self.controllerNode.pager.centralItemNode() as? UniversalVideoGalleryItemNode { diff --git a/submodules/MediaPickerUI/Sources/MediaPickerGridItem.swift b/submodules/MediaPickerUI/Sources/MediaPickerGridItem.swift index 02349aae1f..80af3330b0 100644 --- a/submodules/MediaPickerUI/Sources/MediaPickerGridItem.swift +++ b/submodules/MediaPickerUI/Sources/MediaPickerGridItem.swift @@ -131,12 +131,14 @@ final class MediaPickerGridItemNode: GridItemNode { private var interaction: MediaPickerInteraction? private var theme: PresentationTheme? + private struct SelectionState: Equatable { + let selected: Bool + let count: Int + } + private let selectionPromise = ValuePromise(SelectionState(selected: false, count: 0)) private let spoilerDisposable = MetaDisposable() var spoilerNode: SpoilerOverlayNode? - - var priceBackgroundNode: NavigationBackgroundNode? - var priceIconNode: ASImageNode? - var priceLabelNode: ImmediateTextNode? + var priceNode: PriceNode? private let progressDisposable = MetaDisposable() @@ -252,7 +254,7 @@ final class MediaPickerGridItemNode: GridItemNode { self.setNeedsLayout() } - if let interaction = self.interaction, let selectionState = interaction.selectionState { + if let interaction = self.interaction, let selectionState = interaction.selectionState { let selected = selectionState.isIdentifierSelected(self.identifier) if let selectableItem = self.selectableItem { let index = selectionState.index(of: selectableItem) @@ -261,6 +263,7 @@ final class MediaPickerGridItemNode: GridItemNode { } } self.checkNode?.setSelected(selected, animated: animated) + self.selectionPromise.set(SelectionState(selected: selected, count: selectionState.selectedItems().count)) } } @@ -288,6 +291,7 @@ final class MediaPickerGridItemNode: GridItemNode { self.typeIconNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3) self.durationNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3) self.draftNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3) + self.priceNode?.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3) if animateSpoilerNode { self.spoilerNode?.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3) } @@ -564,12 +568,12 @@ final class MediaPickerGridItemNode: GridItemNode { } } - self.spoilerDisposable.set((combineLatest(spoilerSignal, priceSignal) - |> deliverOnMainQueue).start(next: { [weak self] hasSpoiler, price in + self.spoilerDisposable.set((combineLatest(spoilerSignal, priceSignal, self.selectionPromise.get()) + |> deliverOnMainQueue).start(next: { [weak self] hasSpoiler, price, selectionState in guard let strongSelf = self else { return } - strongSelf.updateHasSpoiler(hasSpoiler, price: price) + strongSelf.updateHasSpoiler(hasSpoiler, price: selectionState.selected ? price : nil, isSingle: selectionState.count == 1) })) if self.currentDraftState != nil { @@ -635,7 +639,7 @@ final class MediaPickerGridItemNode: GridItemNode { } private var didSetupSpoiler = false - private func updateHasSpoiler(_ hasSpoiler: Bool, price: Int64?) { + private func updateHasSpoiler(_ hasSpoiler: Bool, price: Int64?, isSingle: Bool) { var animated = true if !self.didSetupSpoiler { animated = false @@ -659,49 +663,35 @@ final class MediaPickerGridItemNode: GridItemNode { self.spoilerNode?.frame = CGRect(origin: .zero, size: bounds.size) if let price { - let backgroundNode: NavigationBackgroundNode - let labelNode: ImmediateTextNode - let iconNode: ASImageNode - - if let currentBackground = self.priceBackgroundNode, let currentLabel = self.priceLabelNode, let currentIcon = self.priceIconNode { - backgroundNode = currentBackground - labelNode = currentLabel - iconNode = currentIcon + let priceNode: PriceNode + if let currentPriceNode = self.priceNode { + priceNode = currentPriceNode } else { - backgroundNode = NavigationBackgroundNode(color: UIColor(rgb: 0x000000, alpha: 0.5), enableBlur: true) - labelNode = ImmediateTextNode() - iconNode = ASImageNode() - iconNode.displaysAsynchronously = false - iconNode.image = UIImage(bundleImageName: "Premium/Stars/StarSmall") - + priceNode = PriceNode() if let spoilerNode = self.spoilerNode { - self.insertSubnode(backgroundNode, aboveSubnode: spoilerNode) + self.insertSubnode(priceNode, aboveSubnode: spoilerNode) } - backgroundNode.addSubnode(labelNode) - backgroundNode.addSubnode(iconNode) + self.priceNode = priceNode - self.priceBackgroundNode = backgroundNode - self.priceLabelNode = labelNode - self.priceIconNode = iconNode + if animated { + priceNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) + } } - labelNode.attributedText = NSAttributedString(string: "\(price)", font: Font.semibold(15.0), textColor: .white) - let labelSize = labelNode.updateLayout(CGSize(width: 200.0, height: 50.0)) - let size = CGSize(width: labelSize.width + 40.0, height: 34.0) - - backgroundNode.update(size: size, cornerRadius: 17.0, transition: .immediate) - backgroundNode.frame = CGRect(origin: CGPoint(x: floor((bounds.width - size.width) / 2.0), y: floor((bounds.height - size.height) / 2.0)), size: size) - - if let icon = iconNode.image { - iconNode.frame = CGRect(origin: CGPoint(x: 10.0, y: floor((size.height - icon.size.height) / 2.0)), size: icon.size) - } - labelNode.frame = CGRect(origin: CGPoint(x: 30.0, y: floor((size.height - labelSize.height) / 2.0)), size: labelSize) + self.priceNode?.update(size: bounds.size, price: isSingle ? price : nil, small: true, transition: .immediate) } } else if let spoilerNode = self.spoilerNode { self.spoilerNode = nil spoilerNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak spoilerNode] _ in spoilerNode?.removeFromSupernode() }) + + if let priceNode = self.priceNode { + self.priceNode = nil + priceNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak priceNode] _ in + priceNode?.removeFromSupernode() + }) + } } } diff --git a/submodules/MediaPickerUI/Sources/MediaPickerScreen.swift b/submodules/MediaPickerUI/Sources/MediaPickerScreen.swift index 555e49a135..631704c86f 100644 --- a/submodules/MediaPickerUI/Sources/MediaPickerScreen.swift +++ b/submodules/MediaPickerUI/Sources/MediaPickerScreen.swift @@ -2496,30 +2496,6 @@ public final class MediaPickerScreen: ViewController, AttachmentContainable { self?.groupedValue = false self?.controllerNode.send(asFile: false, silently: false, scheduleTime: nil, animated: true, parameters: nil, completion: {}) }))) - -// if !items.isEmpty { -// items.append(.separator) -// } -// items.append(.action(ContextMenuActionItem(text: strings.Attachment_Grouped, icon: { theme in -// if !grouped { -// return nil -// } -// return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Check"), color: theme.contextMenu.primaryColor) -// }, action: { [weak self] _, f in -// f(.default) -// -// self?.groupedValue = true -// }))) -// items.append(.action(ContextMenuActionItem(text: strings.Attachment_Ungrouped, icon: { theme in -// if grouped { -// return nil -// } -// return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Check"), color: theme.contextMenu.primaryColor) -// }, action: { [weak self] _, f in -// f(.default) -// -// self?.groupedValue = false -// }))) } var isPaidAvailable = false @@ -2585,7 +2561,7 @@ public final class MediaPickerScreen: ViewController, AttachmentContainable { return } - let controller = self.context.sharedContext.makeStarsAmountScreen(context: self.context, completion: { [weak self] amount in + let controller = self.context.sharedContext.makeStarsAmountScreen(context: self.context, initialValue: price, completion: { [weak self] amount in guard let strongSelf = self else { return } diff --git a/submodules/MediaPickerUI/Sources/MediaPickerSelectedListNode.swift b/submodules/MediaPickerUI/Sources/MediaPickerSelectedListNode.swift index 2fbede77b4..5e6466db4b 100644 --- a/submodules/MediaPickerUI/Sources/MediaPickerSelectedListNode.swift +++ b/submodules/MediaPickerUI/Sources/MediaPickerSelectedListNode.swift @@ -478,6 +478,81 @@ private class MediaPickerSelectedItemNode: ASDisplayNode { } } +final class PriceNode: ASDisplayNode { + let backgroundNode: NavigationBackgroundNode + let iconNode: ASImageNode + let lockNode: ASImageNode + let labelNode: ImmediateTextNode + + override init() { + self.backgroundNode = NavigationBackgroundNode(color: UIColor(rgb: 0x000000, alpha: 0.35), enableBlur: true) + + self.lockNode = ASImageNode() + self.lockNode.displaysAsynchronously = false + self.lockNode.image = generateTintedImage(image: UIImage(bundleImageName: "Media Grid/Lock"), color: .white) + + self.iconNode = ASImageNode() + self.iconNode.displaysAsynchronously = false + self.iconNode.image = UIImage(bundleImageName: "Premium/Stars/StarSmall") + + self.labelNode = ImmediateTextNode() + + super.init() + + self.addSubnode(self.backgroundNode) + self.backgroundNode.addSubnode(self.lockNode) + self.backgroundNode.addSubnode(self.iconNode) + self.backgroundNode.addSubnode(self.labelNode) + } + + func update(size: CGSize, price: Int64?, small: Bool, transition: ContainedViewLayoutTransition) { + var nodeSize = CGSize(width: 50.0, height: 34.0) + var labelSize: CGSize = .zero + + var backgroundTransition = transition + let labelTransition = ContainedViewLayoutTransition.animated(duration: 0.2, curve: .easeInOut) + if let price { + //TODO:localize + self.labelNode.attributedText = NSAttributedString(string: "\(price)", font: Font.semibold(15.0), textColor: .white) + + labelSize = self.labelNode.updateLayout(CGSize(width: 240.0, height: 50.0)) + nodeSize.width = labelSize.width + 40.0 + + if self.labelNode.alpha != 1.0 && self.backgroundNode.frame.width > 0.0 { + backgroundTransition = labelTransition + } + + labelTransition.updateAlpha(node: self.labelNode, alpha: 1.0) + labelTransition.updateAlpha(node: self.lockNode, alpha: 0.0) + } else { + if self.labelNode.alpha != 0.0 && self.backgroundNode.frame.width > 0.0 { + backgroundTransition = labelTransition + } + + labelTransition.updateAlpha(node: self.labelNode, alpha: 0.0) + labelTransition.updateAlpha(node: self.lockNode, alpha: 1.0) + } + + + self.backgroundNode.update(size: nodeSize, cornerRadius: 17.0, transition: backgroundTransition) + backgroundTransition.updateFrame(node: self.backgroundNode, frame: CGRect(origin: CGPoint(x: floor((size.width - nodeSize.width) / 2.0), y: floor((size.height - nodeSize.height) / 2.0)), size: nodeSize)) + + if let _ = price { + if let icon = self.iconNode.image { + self.iconNode.frame = CGRect(origin: CGPoint(x: 9.0 - UIScreenPixel, y: floor((nodeSize.height - icon.size.height) / 2.0)), size: icon.size) + } + self.labelNode.frame = CGRect(origin: CGPoint(x: 30.0, y: floor((nodeSize.height - labelSize.height) / 2.0)), size: labelSize) + } else { + if let icon = self.iconNode.image { + self.iconNode.frame = CGRect(origin: CGPoint(x: 9.0 - UIScreenPixel, y: floor((nodeSize.height - icon.size.height) / 2.0)), size: icon.size) + } + if let icon = self.lockNode.image { + self.lockNode.frame = CGRect(origin: CGPoint(x: 28.0, y: floor((nodeSize.height - icon.size.height) / 2.0)), size: icon.size) + } + } + } +} + private class MessageBackgroundNode: ASDisplayNode { private let backgroundWallpaperNode: ChatMessageBubbleBackdrop private let backgroundNode: ChatMessageBackground @@ -535,6 +610,7 @@ final class MediaPickerSelectedListNode: ASDisplayNode, ASScrollViewDelegate, AS private let scrollNode: ASScrollNode private var backgroundNodes: [Int: MessageBackgroundNode] = [:] private var itemNodes: [String: MediaPickerSelectedItemNode] = [:] + private var priceNodes: [Int: PriceNode] = [:] private var reorderFeedback: HapticFeedback? private var reorderNode: ReorderingItemNode? @@ -649,6 +725,14 @@ final class MediaPickerSelectedListNode: ASDisplayNode, ASScrollViewDelegate, AS } } + for (_, priceNode) in strongSelf.priceNodes { + priceNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25, delay: 0.1) + if strongSelf.isExternalPreview { + ComponentTransition.immediate.setScale(layer: priceNode.layer, scale: 0.001) + transition.updateTransformScale(layer: priceNode.layer, scale: 1.0) + } + } + for (identifier, itemNode) in strongSelf.itemNodes { if !strongSelf.isObscuredExternalPreview, let (transitionView, _, _) = strongSelf.getTransitionView(identifier) { itemNode.animateFrom(transitionView, transition: transition) @@ -690,6 +774,10 @@ final class MediaPickerSelectedListNode: ASDisplayNode, ASScrollViewDelegate, AS itemNode.layer.removeAllAnimations() } + for (_, priceNode) in strongSelf.priceNodes { + priceNode.layer.removeAllAnimations() + } + strongSelf.messageNodes?.first?.layer.removeAllAnimations() strongSelf.messageNodes?.last?.layer.removeAllAnimations() @@ -710,6 +798,13 @@ final class MediaPickerSelectedListNode: ASDisplayNode, ASScrollViewDelegate, AS } } + for (_, priceNode) in self.priceNodes { + priceNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.1, removeOnCompletion: false) + if self.isExternalPreview { + transition.updateTransformScale(layer: priceNode.layer, scale: 0.001) + } + } + for (identifier, itemNode) in self.itemNodes { if !self.isObscuredExternalPreview, let (transitionView, maybeDustNode, completion) = self.getTransitionView(identifier) { itemNode.animateTo(transitionView, dustNode: maybeDustNode, transition: transition, completion: completion) @@ -844,6 +939,8 @@ final class MediaPickerSelectedListNode: ASDisplayNode, ASScrollViewDelegate, AS let sideInset: CGFloat = 34.0 let boundingWidth = min(320.0, size.width - insets.left - insets.right - sideInset * 2.0) + var price: Int64? + var validIds: [String] = [] for item in items { guard let asset = item as? TGMediaEditableItem, let identifier = asset.uniqueIdentifier else { @@ -872,6 +969,10 @@ final class MediaPickerSelectedListNode: ASDisplayNode, ASScrollViewDelegate, AS } else { itemSizes.append(asset.originalSize ?? CGSize()) } + + if price == nil, let priceValue = self.interaction?.editingState.price(for: asset) as? Int64 { + price = priceValue + } } if !self.didSetReady { @@ -1062,6 +1163,31 @@ final class MediaPickerSelectedListNode: ASDisplayNode, ASScrollViewDelegate, AS } } + if let price { + let priceNode: PriceNode + if let current = self.priceNodes[groupIndex] { + priceNode = current + } else { + priceNode = PriceNode() + self.priceNodes[groupIndex] = priceNode + self.scrollNode.addSubnode(priceNode) + } + + if priceNode.frame.width.isZero { + itemTransition = .immediate + } + + let priceNodeFrame = groupRect + itemTransition.updatePosition(node: priceNode, position: priceNodeFrame.center) + itemTransition.updateBounds(node: priceNode, bounds: CGRect(origin: CGPoint(), size: priceNodeFrame.size)) + priceNode.update(size: priceNode.frame.size, price: price, small: false, transition: itemTransition) + } else if let priceNode = self.priceNodes[groupIndex] { + self.priceNodes[groupIndex] = nil + priceNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false, completion: { [weak priceNode] _ in + priceNode?.removeFromSupernode() + }) + } + contentHeight += groupSize.height contentWidth = max(contentWidth, groupSize.width) groupIndex += 1 @@ -1069,7 +1195,7 @@ final class MediaPickerSelectedListNode: ASDisplayNode, ASScrollViewDelegate, AS if let dragNode = self.messageNodes?.last { transition.updateAlpha(node: dragNode, alpha: items.count > 1 ? 1.0 : 0.0) - transition.updateFrame(node: dragNode, frame: CGRect(origin: CGPoint(x: 0.0, y: insets.top + contentHeight + 1.0), size: dragNode.frame.size)) + transition.updateFrame(node: dragNode, frame: CGRect(origin: CGPoint(x: 0.0, y: insets.top + contentHeight + 9.0), size: dragNode.frame.size)) var dragNodeFrame = dragNode.frame dragNodeFrame.origin.y = size.height - dragNodeFrame.origin.y - dragNodeFrame.size.height diff --git a/submodules/PremiumUI/Sources/PremiumDemoScreen.swift b/submodules/PremiumUI/Sources/PremiumDemoScreen.swift index 0618416cfd..855ef16b3a 100644 --- a/submodules/PremiumUI/Sources/PremiumDemoScreen.swift +++ b/submodules/PremiumUI/Sources/PremiumDemoScreen.swift @@ -1077,6 +1077,26 @@ private final class DemoSheetContent: CombinedComponent { ) ) ) + //TODO:localize + availableItems[.messageEffects] = DemoPagerComponent.Item( + AnyComponentWithIdentity( + id: PremiumDemoScreen.Subject.messageEffects, + component: AnyComponent( + PageComponent( + content: AnyComponent(PhoneDemoComponent( + context: component.context, + position: .top, + model: .island, + videoFile: configuration.videos["effects"], + decoration: .swirlStars + )), + title: strings.Premium_MessageEffects, + text: strings.Premium_MessageEffectsInfo, + textColor: textColor + ) + ) + ) + ) let index: Int = 0 var items: [DemoPagerComponent.Item] = [] @@ -1172,6 +1192,9 @@ private final class DemoSheetContent: CombinedComponent { text = strings.Premium_MessagePrivacyInfo case .folderTags: text = strings.Premium_FolderTagsStandaloneInfo + case .messageEffects: + //TODO:localize + text = "Add over 500 animated effects to private messages." default: text = "" } @@ -1441,6 +1464,7 @@ public class PremiumDemoScreen: ViewControllerComponentContainer { case messagePrivacy case business case folderTags + case messageEffects case businessLocation case businessHours @@ -1497,6 +1521,8 @@ public class PremiumDemoScreen: ViewControllerComponentContainer { return .business case .folderTags: return .folderTags + case .messageEffects: + return .messageEffects case .businessLocation: return .businessLocation case .businessHours: diff --git a/submodules/PremiumUI/Sources/PremiumGiftScreen.swift b/submodules/PremiumUI/Sources/PremiumGiftScreen.swift index 8e3668042e..900a4d0cd6 100644 --- a/submodules/PremiumUI/Sources/PremiumGiftScreen.swift +++ b/submodules/PremiumUI/Sources/PremiumGiftScreen.swift @@ -426,18 +426,19 @@ private final class PremiumGiftScreenContentComponent: CombinedComponent { UIColor(rgb: 0xef6922), UIColor(rgb: 0xe95a2c), UIColor(rgb: 0xe74e33), - UIColor(rgb: 0xe74e33), //replace + UIColor(rgb: 0xe74e33), UIColor(rgb: 0xe54937), UIColor(rgb: 0xe3433c), UIColor(rgb: 0xdb374b), UIColor(rgb: 0xcb3e6d), UIColor(rgb: 0xbc4395), UIColor(rgb: 0xab4ac4), + UIColor(rgb: 0xab4ac4), UIColor(rgb: 0xa34cd7), UIColor(rgb: 0x9b4fed), UIColor(rgb: 0x8958ff), UIColor(rgb: 0x676bff), - UIColor(rgb: 0x676bff), //replace + UIColor(rgb: 0x676bff), UIColor(rgb: 0x6172ff), UIColor(rgb: 0x5b79ff), UIColor(rgb: 0x4492ff), @@ -534,6 +535,8 @@ private final class PremiumGiftScreenContentComponent: CombinedComponent { demoSubject = .lastSeen case .messagePrivacy: demoSubject = .messagePrivacy + case .messageEffects: + demoSubject = .messageEffects case .business: demoSubject = .business default: diff --git a/submodules/PremiumUI/Sources/PremiumIntroScreen.swift b/submodules/PremiumUI/Sources/PremiumIntroScreen.swift index 1c33b2729f..3e5086d9aa 100644 --- a/submodules/PremiumUI/Sources/PremiumIntroScreen.swift +++ b/submodules/PremiumUI/Sources/PremiumIntroScreen.swift @@ -296,6 +296,12 @@ public enum PremiumSource: Equatable { } else { return false } + case .messageEffects: + if case .messageEffects = rhs { + return true + } else { + return false + } } } @@ -342,6 +348,7 @@ public enum PremiumSource: Equatable { case readTime case messageTags case folderTags + case messageEffects var identifier: String? { switch self { @@ -433,6 +440,8 @@ public enum PremiumSource: Equatable { return "saved_tags" case .folderTags: return "folder_tags" + case .messageEffects: + return "effects" } } } @@ -460,6 +469,7 @@ public enum PremiumPerk: CaseIterable { case messagePrivacy case business case folderTags + case messageEffects case businessLocation case businessHours @@ -493,7 +503,8 @@ public enum PremiumPerk: CaseIterable { .lastSeen, .messagePrivacy, .folderTags, - .business + .business, + .messageEffects ] } @@ -565,6 +576,8 @@ public enum PremiumPerk: CaseIterable { return "message_privacy" case .folderTags: return "folder_tags" + case .messageEffects: + return "effects" case .business: return "business" case .businessLocation: @@ -632,7 +645,8 @@ public enum PremiumPerk: CaseIterable { return strings.Premium_FolderTags case .business: return strings.Premium_Business - + case .messageEffects: + return strings.Premium_MessageEffects case .businessLocation: return strings.Business_Location case .businessHours: @@ -698,7 +712,8 @@ public enum PremiumPerk: CaseIterable { return strings.Premium_FolderTagsInfo case .business: return strings.Premium_BusinessInfo - + case .messageEffects: + return strings.Premium_MessageEffectsInfo case .businessLocation: return strings.Business_LocationInfo case .businessHours: @@ -764,6 +779,8 @@ public enum PremiumPerk: CaseIterable { return "Premium/Perk/MessageTags" case .business: return "Premium/Perk/Business" + case .messageEffects: + return "Premium/Perk/MessageEffects" case .businessLocation: return "Premium/BusinessPerk/Location" @@ -797,6 +814,7 @@ struct PremiumIntroConfiguration { .translation, .animatedEmoji, .emojiStatus, + .messageEffects, .messageTags, .colors, .wallpapers, @@ -1835,18 +1853,19 @@ private final class PremiumIntroScreenContentComponent: CombinedComponent { UIColor(rgb: 0xef6922), UIColor(rgb: 0xe95a2c), UIColor(rgb: 0xe74e33), - UIColor(rgb: 0xe74e33), //replace + UIColor(rgb: 0xe74e33), UIColor(rgb: 0xe54937), UIColor(rgb: 0xe3433c), UIColor(rgb: 0xdb374b), UIColor(rgb: 0xcb3e6d), UIColor(rgb: 0xbc4395), UIColor(rgb: 0xab4ac4), + UIColor(rgb: 0xab4ac4), UIColor(rgb: 0xa34cd7), UIColor(rgb: 0x9b4fed), UIColor(rgb: 0x8958ff), UIColor(rgb: 0x676bff), - UIColor(rgb: 0x676bff), //replace + UIColor(rgb: 0x676bff), UIColor(rgb: 0x6172ff), UIColor(rgb: 0x5b79ff), UIColor(rgb: 0x4492ff), @@ -2071,6 +2090,8 @@ private final class PremiumIntroScreenContentComponent: CombinedComponent { demoSubject = .lastSeen case .messagePrivacy: demoSubject = .messagePrivacy + case .messageEffects: + demoSubject = .messageEffects case .business: demoSubject = .business let _ = ApplicationSpecificNotice.setDismissedBusinessBadge(accountManager: accountContext.sharedContext.accountManager).startStandalone() diff --git a/submodules/PremiumUI/Sources/PremiumLimitsListScreen.swift b/submodules/PremiumUI/Sources/PremiumLimitsListScreen.swift index 21f3233b3f..ab35d50747 100644 --- a/submodules/PremiumUI/Sources/PremiumLimitsListScreen.swift +++ b/submodules/PremiumUI/Sources/PremiumLimitsListScreen.swift @@ -826,6 +826,26 @@ public class PremiumLimitsListScreen: ViewController { ) ) ) + //TODO:localize + availableItems[.messageEffects] = DemoPagerComponent.Item( + AnyComponentWithIdentity( + id: PremiumDemoScreen.Subject.messageEffects, + component: AnyComponent( + PageComponent( + content: AnyComponent(PhoneDemoComponent( + context: context, + position: .top, + model: .island, + videoFile: videos["effects"], + decoration: .swirlStars + )), + title: strings.Premium_MessageEffects, + text: strings.Premium_MessageEffectsInfo, + textColor: textColor + ) + ) + ) + ) availableItems[.business] = DemoPagerComponent.Item( AnyComponentWithIdentity( id: PremiumDemoScreen.Subject.business, diff --git a/submodules/StatisticsUI/BUILD b/submodules/StatisticsUI/BUILD index d336056b54..2f50f27c25 100644 --- a/submodules/StatisticsUI/BUILD +++ b/submodules/StatisticsUI/BUILD @@ -50,6 +50,10 @@ swift_library( "//submodules/PasswordSetupUI", "//submodules/TelegramUI/Components/PeerManagement/OwnershipTransferController", "//submodules/TelegramUI/Components/ListItemComponentAdaptor", + "//submodules/TelegramUI/Components/Stars/StarsWithdrawalScreen", + "//submodules/TelegramUI/Components/ButtonComponent", + "//submodules/TelegramUI/Components/ListActionItemComponent", + "//submodules/TelegramUI/Components/Stars/StarsAvatarComponent", ], visibility = [ "//visibility:public", diff --git a/submodules/StatisticsUI/Sources/ChannelStatsController.swift b/submodules/StatisticsUI/Sources/ChannelStatsController.swift index bcd1c8f501..6b3f1f5062 100644 --- a/submodules/StatisticsUI/Sources/ChannelStatsController.swift +++ b/submodules/StatisticsUI/Sources/ChannelStatsController.swift @@ -25,6 +25,7 @@ import StoryContainerScreen import TelegramNotices import ComponentFlow import BoostLevelIconComponent +import StarsWithdrawalScreen private let initialBoostersDisplayedLimit: Int32 = 5 private let initialTransactionsDisplayedLimit: Int32 = 5 @@ -42,17 +43,20 @@ private final class ChannelStatsControllerArguments { let openGifts: () -> Void let createPrepaidGiveaway: (PrepaidGiveaway) -> Void let updateGiftsSelected: (Bool) -> Void + let updateStarsSelected: (Bool) -> Void - let requestWithdraw: () -> Void + let requestTonWithdraw: () -> Void + let requestStarsWithdraw: () -> Void let openMonetizationIntro: () -> Void let openMonetizationInfo: () -> Void - let openTransaction: (RevenueStatsTransactionsContext.State.Transaction) -> Void + let openTonTransaction: (RevenueStatsTransactionsContext.State.Transaction) -> Void + let openStarsTransaction: (StarsContext.State.Transaction) -> Void let expandTransactions: () -> Void let updateCpmEnabled: (Bool) -> Void let presentCpmLocked: () -> Void let dismissInput: () -> Void - init(context: AccountContext, loadDetailedGraph: @escaping (StatsGraph, Int64) -> Signal, openPostStats: @escaping (EnginePeer, StatsPostItem) -> Void, openStory: @escaping (EngineStoryItem, UIView) -> Void, contextAction: @escaping (MessageId, ASDisplayNode, ContextGesture?) -> Void, copyBoostLink: @escaping (String) -> Void, shareBoostLink: @escaping (String) -> Void, openBoost: @escaping (ChannelBoostersContext.State.Boost) -> Void, expandBoosters: @escaping () -> Void, openGifts: @escaping () -> Void, createPrepaidGiveaway: @escaping (PrepaidGiveaway) -> Void, updateGiftsSelected: @escaping (Bool) -> Void, requestWithdraw: @escaping () -> Void, openMonetizationIntro: @escaping () -> Void, openMonetizationInfo: @escaping () -> Void, openTransaction: @escaping (RevenueStatsTransactionsContext.State.Transaction) -> Void, expandTransactions: @escaping () -> Void, updateCpmEnabled: @escaping (Bool) -> Void, presentCpmLocked: @escaping () -> Void, dismissInput: @escaping () -> Void) { + init(context: AccountContext, loadDetailedGraph: @escaping (StatsGraph, Int64) -> Signal, openPostStats: @escaping (EnginePeer, StatsPostItem) -> Void, openStory: @escaping (EngineStoryItem, UIView) -> Void, contextAction: @escaping (MessageId, ASDisplayNode, ContextGesture?) -> Void, copyBoostLink: @escaping (String) -> Void, shareBoostLink: @escaping (String) -> Void, openBoost: @escaping (ChannelBoostersContext.State.Boost) -> Void, expandBoosters: @escaping () -> Void, openGifts: @escaping () -> Void, createPrepaidGiveaway: @escaping (PrepaidGiveaway) -> Void, updateGiftsSelected: @escaping (Bool) -> Void, updateStarsSelected: @escaping (Bool) -> Void, requestTonWithdraw: @escaping () -> Void, requestStarsWithdraw: @escaping () -> Void, openMonetizationIntro: @escaping () -> Void, openMonetizationInfo: @escaping () -> Void, openTonTransaction: @escaping (RevenueStatsTransactionsContext.State.Transaction) -> Void, openStarsTransaction: @escaping (StarsContext.State.Transaction) -> Void, expandTransactions: @escaping () -> Void, updateCpmEnabled: @escaping (Bool) -> Void, presentCpmLocked: @escaping () -> Void, dismissInput: @escaping () -> Void) { self.context = context self.loadDetailedGraph = loadDetailedGraph self.openPostStats = openPostStats @@ -65,10 +69,13 @@ private final class ChannelStatsControllerArguments { self.openGifts = openGifts self.createPrepaidGiveaway = createPrepaidGiveaway self.updateGiftsSelected = updateGiftsSelected - self.requestWithdraw = requestWithdraw + self.updateStarsSelected = updateStarsSelected + self.requestTonWithdraw = requestTonWithdraw + self.requestStarsWithdraw = requestStarsWithdraw self.openMonetizationIntro = openMonetizationIntro self.openMonetizationInfo = openMonetizationInfo - self.openTransaction = openTransaction + self.openTonTransaction = openTonTransaction + self.openStarsTransaction = openStarsTransaction self.expandTransactions = expandTransactions self.updateCpmEnabled = updateCpmEnabled self.presentCpmLocked = presentCpmLocked @@ -101,9 +108,11 @@ private enum StatsSection: Int32 { case adsHeader case adsImpressions - case adsRevenue + case adsTonRevenue + case adsStarsRevenue case adsProceeds - case adsBalance + case adsTonBalance + case adsStarsBalance case adsTransactions case adsCpm } @@ -218,18 +227,27 @@ private enum StatsEntry: ItemListNodeEntry { case adsImpressionsTitle(PresentationTheme, String) case adsImpressionsGraph(PresentationTheme, PresentationStrings, PresentationDateTimeFormat, StatsGraph, ChartType) - case adsRevenueTitle(PresentationTheme, String) - case adsRevenueGraph(PresentationTheme, PresentationStrings, PresentationDateTimeFormat, StatsGraph, ChartType, Double) + case adsTonRevenueTitle(PresentationTheme, String) + case adsTonRevenueGraph(PresentationTheme, PresentationStrings, PresentationDateTimeFormat, StatsGraph, ChartType, Double) + + case adsStarsRevenueTitle(PresentationTheme, String) + case adsStarsRevenueGraph(PresentationTheme, PresentationStrings, PresentationDateTimeFormat, StatsGraph, ChartType, Double) case adsProceedsTitle(PresentationTheme, String) - case adsProceedsOverview(PresentationTheme, RevenueStats, TelegramMediaFile?) + case adsProceedsOverview(PresentationTheme, RevenueStats, StarsRevenueStats?) - case adsBalanceTitle(PresentationTheme, String) - case adsBalance(PresentationTheme, RevenueStats, Bool, Bool, TelegramMediaFile?) - case adsBalanceInfo(PresentationTheme, String) + case adsTonBalanceTitle(PresentationTheme, String) + case adsTonBalance(PresentationTheme, RevenueStats, Bool, Bool) + case adsTonBalanceInfo(PresentationTheme, String) + + case adsStarsBalanceTitle(PresentationTheme, String) + case adsStarsBalance(PresentationTheme, StarsRevenueStats, Bool, Bool) + case adsStarsBalanceInfo(PresentationTheme, String) case adsTransactionsTitle(PresentationTheme, String) + case adsTransactionsTabs(PresentationTheme, String, String, Bool) case adsTransaction(Int32, PresentationTheme, RevenueStatsTransactionsContext.State.Transaction) + case adsStarsTransaction(Int32, PresentationTheme, StarsContext.State.Transaction) case adsTransactionsExpand(PresentationTheme, String) case adsCpmToggle(PresentationTheme, String, Int32, Bool?) @@ -281,13 +299,17 @@ private enum StatsEntry: ItemListNodeEntry { return StatsSection.adsHeader.rawValue case .adsImpressionsTitle, .adsImpressionsGraph: return StatsSection.adsImpressions.rawValue - case .adsRevenueTitle, .adsRevenueGraph: - return StatsSection.adsRevenue.rawValue + case .adsTonRevenueTitle, .adsTonRevenueGraph: + return StatsSection.adsTonRevenue.rawValue + case .adsStarsRevenueTitle, .adsStarsRevenueGraph: + return StatsSection.adsStarsRevenue.rawValue case .adsProceedsTitle, .adsProceedsOverview: return StatsSection.adsProceeds.rawValue - case .adsBalanceTitle, .adsBalance, .adsBalanceInfo: - return StatsSection.adsBalance.rawValue - case .adsTransactionsTitle, .adsTransaction, .adsTransactionsExpand: + case .adsTonBalanceTitle, .adsTonBalance, .adsTonBalanceInfo: + return StatsSection.adsTonBalance.rawValue + case .adsStarsBalanceTitle, .adsStarsBalance, .adsStarsBalanceInfo: + return StatsSection.adsStarsBalance.rawValue + case .adsTransactionsTitle, .adsTransactionsTabs, .adsTransaction, .adsStarsTransaction, .adsTransactionsExpand: return StatsSection.adsTransactions.rawValue case .adsCpmToggle, .adsCpmInfo: return StatsSection.adsCpm.rawValue @@ -392,30 +414,44 @@ private enum StatsEntry: ItemListNodeEntry { return 20001 case .adsImpressionsGraph: return 20002 - case .adsRevenueTitle: + case .adsTonRevenueTitle: return 20003 - case .adsRevenueGraph: + case .adsTonRevenueGraph: return 20004 - case .adsProceedsTitle: + case .adsStarsRevenueTitle: return 20005 - case .adsProceedsOverview: + case .adsStarsRevenueGraph: return 20006 - case .adsBalanceTitle: + case .adsProceedsTitle: return 20007 - case .adsBalance: + case .adsProceedsOverview: return 20008 - case .adsBalanceInfo: + case .adsTonBalanceTitle: return 20009 - case .adsTransactionsTitle: + case .adsTonBalance: return 20010 + case .adsTonBalanceInfo: + return 20011 + case .adsStarsBalanceTitle: + return 20012 + case .adsStarsBalance: + return 20013 + case .adsStarsBalanceInfo: + return 20014 + case .adsTransactionsTitle: + return 20015 + case .adsTransactionsTabs: + return 20016 case let .adsTransaction(index, _, _): - return 20011 + index + return 20017 + index + case let .adsStarsTransaction(index, _, _): + return 30017 + index case .adsTransactionsExpand: - return 30000 + return 40000 case .adsCpmToggle: - return 30001 + return 40001 case .adsCpmInfo: - return 30002 + return 40002 } } @@ -709,14 +745,26 @@ private enum StatsEntry: ItemListNodeEntry { } else { return false } - case let .adsRevenueTitle(lhsTheme, lhsText): - if case let .adsRevenueTitle(rhsTheme, rhsText) = rhs, lhsTheme === rhsTheme, lhsText == rhsText { + case let .adsTonRevenueTitle(lhsTheme, lhsText): + if case let .adsTonRevenueTitle(rhsTheme, rhsText) = rhs, lhsTheme === rhsTheme, lhsText == rhsText { return true } else { return false } - case let .adsRevenueGraph(lhsTheme, lhsStrings, lhsDateTimeFormat, lhsGraph, lhsType, lhsRate): - if case let .adsRevenueGraph(rhsTheme, rhsStrings, rhsDateTimeFormat, rhsGraph, rhsType, rhsRate) = rhs, lhsTheme === rhsTheme, lhsStrings === rhsStrings, lhsDateTimeFormat == rhsDateTimeFormat, lhsGraph == rhsGraph, lhsType == rhsType, lhsRate == rhsRate { + case let .adsTonRevenueGraph(lhsTheme, lhsStrings, lhsDateTimeFormat, lhsGraph, lhsType, lhsRate): + if case let .adsTonRevenueGraph(rhsTheme, rhsStrings, rhsDateTimeFormat, rhsGraph, rhsType, rhsRate) = rhs, lhsTheme === rhsTheme, lhsStrings === rhsStrings, lhsDateTimeFormat == rhsDateTimeFormat, lhsGraph == rhsGraph, lhsType == rhsType, lhsRate == rhsRate { + return true + } else { + return false + } + case let .adsStarsRevenueTitle(lhsTheme, lhsText): + if case let .adsStarsRevenueTitle(rhsTheme, rhsText) = rhs, lhsTheme === rhsTheme, lhsText == rhsText { + return true + } else { + return false + } + case let .adsStarsRevenueGraph(lhsTheme, lhsStrings, lhsDateTimeFormat, lhsGraph, lhsType, lhsRate): + if case let .adsStarsRevenueGraph(rhsTheme, rhsStrings, rhsDateTimeFormat, rhsGraph, rhsType, rhsRate) = rhs, lhsTheme === rhsTheme, lhsStrings === rhsStrings, lhsDateTimeFormat == rhsDateTimeFormat, lhsGraph == rhsGraph, lhsType == rhsType, lhsRate == rhsRate { return true } else { return false @@ -727,26 +775,44 @@ private enum StatsEntry: ItemListNodeEntry { } else { return false } - case let .adsProceedsOverview(lhsTheme, lhsStatus, lhsAnimatedEmoji): - if case let .adsProceedsOverview(rhsTheme, rhsStatus, rhsAnimatedEmoji) = rhs, lhsTheme === rhsTheme, lhsStatus == rhsStatus, lhsAnimatedEmoji == rhsAnimatedEmoji { + case let .adsProceedsOverview(lhsTheme, lhsStatus, lhsStarsStatus): + if case let .adsProceedsOverview(rhsTheme, rhsStatus, rhsStarsStatus) = rhs, lhsTheme === rhsTheme, lhsStatus == rhsStatus, lhsStarsStatus == rhsStarsStatus { return true } else { return false } - case let .adsBalanceTitle(lhsTheme, lhsText): - if case let .adsBalanceTitle(rhsTheme, rhsText) = rhs, lhsTheme === rhsTheme, lhsText == rhsText { + case let .adsTonBalanceTitle(lhsTheme, lhsText): + if case let .adsTonBalanceTitle(rhsTheme, rhsText) = rhs, lhsTheme === rhsTheme, lhsText == rhsText { return true } else { return false } - case let .adsBalance(lhsTheme, lhsStats, lhsCanWithdraw, lhsIsEnabled, lhsAnimatedEmoji): - if case let .adsBalance(rhsTheme, rhsStats, rhsCanWithdraw, rhsIsEnabled, rhsAnimatedEmoji) = rhs, lhsTheme === rhsTheme, lhsStats == rhsStats, lhsCanWithdraw == rhsCanWithdraw, lhsIsEnabled == rhsIsEnabled, lhsAnimatedEmoji == rhsAnimatedEmoji { + case let .adsTonBalance(lhsTheme, lhsStats, lhsCanWithdraw, lhsIsEnabled): + if case let .adsTonBalance(rhsTheme, rhsStats, rhsCanWithdraw, rhsIsEnabled) = rhs, lhsTheme === rhsTheme, lhsStats == rhsStats, lhsCanWithdraw == rhsCanWithdraw, lhsIsEnabled == rhsIsEnabled { return true } else { return false } - case let .adsBalanceInfo(lhsTheme, lhsText): - if case let .adsBalanceInfo(rhsTheme, rhsText) = rhs, lhsTheme === rhsTheme, lhsText == rhsText { + case let .adsTonBalanceInfo(lhsTheme, lhsText): + if case let .adsTonBalanceInfo(rhsTheme, rhsText) = rhs, lhsTheme === rhsTheme, lhsText == rhsText { + return true + } else { + return false + } + case let .adsStarsBalanceTitle(lhsTheme, lhsText): + if case let .adsStarsBalanceTitle(rhsTheme, rhsText) = rhs, lhsTheme === rhsTheme, lhsText == rhsText { + return true + } else { + return false + } + case let .adsStarsBalance(lhsTheme, lhsStats, lhsCanWithdraw, lhsIsEnabled): + if case let .adsStarsBalance(rhsTheme, rhsStats, rhsCanWithdraw, rhsIsEnabled) = rhs, lhsTheme === rhsTheme, lhsStats == rhsStats, lhsCanWithdraw == rhsCanWithdraw, lhsIsEnabled == rhsIsEnabled { + return true + } else { + return false + } + case let .adsStarsBalanceInfo(lhsTheme, lhsText): + if case let .adsStarsBalanceInfo(rhsTheme, rhsText) = rhs, lhsTheme === rhsTheme, lhsText == rhsText { return true } else { return false @@ -757,12 +823,24 @@ private enum StatsEntry: ItemListNodeEntry { } else { return false } + case let .adsTransactionsTabs(lhsTheme, lhsTonText, lhsStarsText, lhsStarsSelected): + if case let .adsTransactionsTabs(rhsTheme, rhsTonText, rhsStarsText, rhsStarsSelected) = rhs, lhsTheme === rhsTheme, lhsTonText == rhsTonText, lhsStarsText == rhsStarsText, lhsStarsSelected == rhsStarsSelected { + return true + } else { + return false + } case let .adsTransaction(lhsIndex, lhsTheme, lhsTransaction): if case let .adsTransaction(rhsIndex, rhsTheme, rhsTransaction) = rhs, lhsIndex == rhsIndex, lhsTheme === rhsTheme, lhsTransaction == rhsTransaction { return true } else { return false } + case let .adsStarsTransaction(lhsIndex, lhsTheme, lhsTransaction): + if case let .adsStarsTransaction(rhsIndex, rhsTheme, rhsTransaction) = rhs, lhsIndex == rhsIndex, lhsTheme === rhsTheme, lhsTransaction == rhsTransaction { + return true + } else { + return false + } case let .adsTransactionsExpand(lhsTheme, lhsText): if case let .adsTransactionsExpand(rhsTheme, rhsText) = rhs, lhsTheme === rhsTheme, lhsText == rhsText { return true @@ -811,9 +889,11 @@ private enum StatsEntry: ItemListNodeEntry { let .boostersTitle(_, text), let .boostLinkTitle(_, text), let .adsImpressionsTitle(_, text), - let .adsRevenueTitle(_, text), + let .adsTonRevenueTitle(_, text), + let .adsStarsRevenueTitle(_, text), let .adsProceedsTitle(_, text), - let .adsBalanceTitle(_, text), + let .adsTonBalanceTitle(_, text), + let .adsStarsBalanceTitle(_, text), let .adsTransactionsTitle(_, text): return ItemListSectionHeaderItem(presentationData: presentationData, text: text, sectionId: self.section) case let .boostPrepaidInfo(_, text), @@ -835,7 +915,9 @@ private enum StatsEntry: ItemListNodeEntry { let .storyReactionsByEmotionGraph(_, _, _, graph, type), let .adsImpressionsGraph(_, _, _, graph, type): return StatsGraphItem(presentationData: presentationData, graph: graph, type: type, sectionId: self.section, style: .blocks) - case let .adsRevenueGraph(_, _, _, graph, type, rate): + case let .adsTonRevenueGraph(_, _, _, graph, type, rate): + return StatsGraphItem(presentationData: presentationData, graph: graph, type: type, conversionRate: rate, sectionId: self.section, style: .blocks) + case let .adsStarsRevenueGraph(_, _, _, graph, type, rate): return StatsGraphItem(presentationData: presentationData, graph: graph, type: type, conversionRate: rate, sectionId: self.section, style: .blocks) case let .postInteractionsGraph(_, _, _, graph, type), let .instantPageInteractionsGraph(_, _, _, graph, type), @@ -959,9 +1041,9 @@ private enum StatsEntry: ItemListNodeEntry { return ItemListTextItem(presentationData: presentationData, text: .markdown(text), sectionId: self.section, linkAction: { _ in arguments.openMonetizationIntro() }) - case let .adsProceedsOverview(_, stats, animatedEmoji): - return StatsOverviewItem(context: arguments.context, presentationData: presentationData, isGroup: false, stats: stats, animatedEmoji: animatedEmoji, sectionId: self.section, style: .blocks) - case let .adsBalance(_, stats, canWithdraw, isEnabled, _): + case let .adsProceedsOverview(_, stats, starsStats): + return StatsOverviewItem(context: arguments.context, presentationData: presentationData, isGroup: false, stats: stats, additionalStats: starsStats, sectionId: self.section, style: .blocks) + case let .adsTonBalance(_, stats, canWithdraw, isEnabled): return MonetizationBalanceItem( context: arguments.context, presentationData: presentationData, @@ -969,15 +1051,36 @@ private enum StatsEntry: ItemListNodeEntry { canWithdraw: canWithdraw, isEnabled: isEnabled, withdrawAction: { - arguments.requestWithdraw() + arguments.requestTonWithdraw() }, sectionId: self.section, style: .blocks ) - case let .adsBalanceInfo(_, text): + case let .adsTonBalanceInfo(_, text): return ItemListTextItem(presentationData: presentationData, text: .markdown(text), sectionId: self.section, linkAction: { _ in arguments.openMonetizationInfo() }) + case let .adsStarsBalance(_, stats, canWithdraw, isEnabled): + return MonetizationBalanceItem( + context: arguments.context, + presentationData: presentationData, + stats: stats, + canWithdraw: canWithdraw, + isEnabled: isEnabled, + withdrawAction: { + arguments.requestStarsWithdraw() + }, + sectionId: self.section, + style: .blocks + ) + case let .adsStarsBalanceInfo(_, text): + return ItemListTextItem(presentationData: presentationData, text: .markdown(text), sectionId: self.section, linkAction: { _ in + arguments.openMonetizationInfo() + }) + case let .adsTransactionsTabs(_, tonText, starsText, starsSelected): + return BoostsTabsItem(theme: presentationData.theme, boostsText: tonText, giftsText: starsText, selectedTab: starsSelected ? .gifts : .boosts, sectionId: self.section, selectionUpdated: { tab in + arguments.updateStarsSelected(tab == .gifts) + }) case let .adsTransaction(_, theme, transaction): let font = Font.with(size: floor(presentationData.fontSize.itemListBaseFontSize)) let smallLabelFont = Font.with(size: floor(presentationData.fontSize.itemListBaseFontSize / 17.0 * 13.0)) @@ -1024,8 +1127,12 @@ private enum StatsEntry: ItemListNodeEntry { } return ItemListDisclosureItem(presentationData: presentationData, title: "", attributedTitle: title, label: "", attributedLabel: label, labelStyle: .coloredText(labelColor), additionalDetailLabel: detailText, additionalDetailLabelColor: detailColor, sectionId: self.section, style: .blocks, disclosureStyle: .none, action: { - arguments.openTransaction(transaction) + arguments.openTonTransaction(transaction) }) + case let .adsStarsTransaction(_, _, transaction): + return StarsTransactionItem(context: arguments.context, presentationData: presentationData, transaction: transaction, action: { + arguments.openStarsTransaction(transaction) + }, sectionId: self.section, style: .blocks) case let .adsTransactionsExpand(theme, title): return ItemListPeerActionItem(presentationData: presentationData, icon: PresentationResourcesItemList.downArrowImage(theme), title: title, sectionId: self.section, editing: false, action: { arguments.expandTransactions() @@ -1062,23 +1169,26 @@ private struct ChannelStatsControllerState: Equatable { let boostersExpanded: Bool let moreBoostersDisplayed: Int32 let giftsSelected: Bool + let starsSelected: Bool let transactionsExpanded: Bool let moreTransactionsDisplayed: Int32 - + init() { self.section = .stats self.boostersExpanded = false self.moreBoostersDisplayed = 0 self.giftsSelected = false + self.starsSelected = false self.transactionsExpanded = false self.moreTransactionsDisplayed = 0 } - init(section: ChannelStatsSection, boostersExpanded: Bool, moreBoostersDisplayed: Int32, giftsSelected: Bool, transactionsExpanded: Bool, moreTransactionsDisplayed: Int32) { + init(section: ChannelStatsSection, boostersExpanded: Bool, moreBoostersDisplayed: Int32, giftsSelected: Bool, starsSelected: Bool, transactionsExpanded: Bool, moreTransactionsDisplayed: Int32) { self.section = section self.boostersExpanded = boostersExpanded self.moreBoostersDisplayed = moreBoostersDisplayed self.giftsSelected = giftsSelected + self.starsSelected = starsSelected self.transactionsExpanded = transactionsExpanded self.moreTransactionsDisplayed = moreTransactionsDisplayed } @@ -1096,6 +1206,9 @@ private struct ChannelStatsControllerState: Equatable { if lhs.giftsSelected != rhs.giftsSelected { return false } + if lhs.starsSelected != rhs.starsSelected { + return false + } if lhs.transactionsExpanded != rhs.transactionsExpanded { return false } @@ -1106,27 +1219,31 @@ private struct ChannelStatsControllerState: Equatable { } func withUpdatedSection(_ section: ChannelStatsSection) -> ChannelStatsControllerState { - return ChannelStatsControllerState(section: section, boostersExpanded: self.boostersExpanded, moreBoostersDisplayed: self.moreBoostersDisplayed, giftsSelected: self.giftsSelected, transactionsExpanded: self.transactionsExpanded, moreTransactionsDisplayed: self.moreTransactionsDisplayed) + return ChannelStatsControllerState(section: section, boostersExpanded: self.boostersExpanded, moreBoostersDisplayed: self.moreBoostersDisplayed, giftsSelected: self.giftsSelected, starsSelected: self.starsSelected, transactionsExpanded: self.transactionsExpanded, moreTransactionsDisplayed: self.moreTransactionsDisplayed) } func withUpdatedBoostersExpanded(_ boostersExpanded: Bool) -> ChannelStatsControllerState { - return ChannelStatsControllerState(section: self.section, boostersExpanded: boostersExpanded, moreBoostersDisplayed: self.moreBoostersDisplayed, giftsSelected: self.giftsSelected, transactionsExpanded: self.transactionsExpanded, moreTransactionsDisplayed: self.moreTransactionsDisplayed) + return ChannelStatsControllerState(section: self.section, boostersExpanded: boostersExpanded, moreBoostersDisplayed: self.moreBoostersDisplayed, giftsSelected: self.giftsSelected, starsSelected: self.starsSelected, transactionsExpanded: self.transactionsExpanded, moreTransactionsDisplayed: self.moreTransactionsDisplayed) } func withUpdatedMoreBoostersDisplayed(_ moreBoostersDisplayed: Int32) -> ChannelStatsControllerState { - return ChannelStatsControllerState(section: self.section, boostersExpanded: self.boostersExpanded, moreBoostersDisplayed: moreBoostersDisplayed, giftsSelected: self.giftsSelected, transactionsExpanded: self.transactionsExpanded, moreTransactionsDisplayed: self.moreTransactionsDisplayed) + return ChannelStatsControllerState(section: self.section, boostersExpanded: self.boostersExpanded, moreBoostersDisplayed: moreBoostersDisplayed, giftsSelected: self.giftsSelected, starsSelected: self.starsSelected, transactionsExpanded: self.transactionsExpanded, moreTransactionsDisplayed: self.moreTransactionsDisplayed) } func withUpdatedGiftsSelected(_ giftsSelected: Bool) -> ChannelStatsControllerState { - return ChannelStatsControllerState(section: self.section, boostersExpanded: self.boostersExpanded, moreBoostersDisplayed: self.moreBoostersDisplayed, giftsSelected: giftsSelected, transactionsExpanded: self.transactionsExpanded, moreTransactionsDisplayed: self.moreTransactionsDisplayed) + return ChannelStatsControllerState(section: self.section, boostersExpanded: self.boostersExpanded, moreBoostersDisplayed: self.moreBoostersDisplayed, giftsSelected: giftsSelected, starsSelected: self.starsSelected, transactionsExpanded: self.transactionsExpanded, moreTransactionsDisplayed: self.moreTransactionsDisplayed) + } + + func withUpdatedStarsSelected(_ giftsSelected: Bool) -> ChannelStatsControllerState { + return ChannelStatsControllerState(section: self.section, boostersExpanded: self.boostersExpanded, moreBoostersDisplayed: self.moreBoostersDisplayed, giftsSelected: self.giftsSelected, starsSelected: starsSelected, transactionsExpanded: self.transactionsExpanded, moreTransactionsDisplayed: self.moreTransactionsDisplayed) } func withUpdatedTransactionsExpanded(_ transactionsExpanded: Bool) -> ChannelStatsControllerState { - return ChannelStatsControllerState(section: self.section, boostersExpanded: self.boostersExpanded, moreBoostersDisplayed: self.moreBoostersDisplayed, giftsSelected: self.giftsSelected, transactionsExpanded: transactionsExpanded, moreTransactionsDisplayed: self.moreTransactionsDisplayed) + return ChannelStatsControllerState(section: self.section, boostersExpanded: self.boostersExpanded, moreBoostersDisplayed: self.moreBoostersDisplayed, giftsSelected: self.giftsSelected, starsSelected: self.starsSelected, transactionsExpanded: transactionsExpanded, moreTransactionsDisplayed: self.moreTransactionsDisplayed) } func withUpdatedMoreTransactionsDisplayed(_ moreTransactionsDisplayed: Int32) -> ChannelStatsControllerState { - return ChannelStatsControllerState(section: self.section, boostersExpanded: self.boostersExpanded, moreBoostersDisplayed: self.moreBoostersDisplayed, giftsSelected: self.giftsSelected, transactionsExpanded: self.transactionsExpanded, moreTransactionsDisplayed: moreTransactionsDisplayed) + return ChannelStatsControllerState(section: self.section, boostersExpanded: self.boostersExpanded, moreBoostersDisplayed: self.moreBoostersDisplayed, giftsSelected: self.giftsSelected, starsSelected: self.starsSelected, transactionsExpanded: self.transactionsExpanded, moreTransactionsDisplayed: moreTransactionsDisplayed) } } @@ -1373,13 +1490,12 @@ private func monetizationEntries( data: RevenueStats, boostData: ChannelBoostStatus?, transactionsInfo: RevenueStatsTransactionsContext.State, + starsData: StarsRevenueStats?, + starsTransactionsInfo: StarsTransactionsContext.State, adsRestricted: Bool, - animatedEmojis: [String: [StickerPackItem]], premiumConfiguration: PremiumConfiguration, monetizationConfiguration: MonetizationConfiguration ) -> [StatsEntry] { - let diamond = animatedEmojis["💎"]?.first?.file - var entries: [StatsEntry] = [] entries.append(.adsHeader(presentationData.theme, presentationData.strings.Monetization_Header)) @@ -1389,19 +1505,24 @@ private func monetizationEntries( } if !data.revenueGraph.isEmpty { - entries.append(.adsRevenueTitle(presentationData.theme, presentationData.strings.Monetization_AdRevenueTitle)) - entries.append(.adsRevenueGraph(presentationData.theme, presentationData.strings, presentationData.dateTimeFormat, data.revenueGraph, .currency, data.usdRate)) + entries.append(.adsTonRevenueTitle(presentationData.theme, presentationData.strings.Monetization_AdRevenueTitle)) + entries.append(.adsTonRevenueGraph(presentationData.theme, presentationData.strings, presentationData.dateTimeFormat, data.revenueGraph, .currency, data.usdRate)) + } + + if let starsData, !starsData.revenueGraph.isEmpty { + entries.append(.adsStarsRevenueTitle(presentationData.theme, presentationData.strings.Monetization_StarsRevenueTitle)) + entries.append(.adsStarsRevenueGraph(presentationData.theme, presentationData.strings, presentationData.dateTimeFormat, starsData.revenueGraph, .stars, starsData.usdRate)) } entries.append(.adsProceedsTitle(presentationData.theme, presentationData.strings.Monetization_OverviewTitle)) - entries.append(.adsProceedsOverview(presentationData.theme, data, diamond)) + entries.append(.adsProceedsOverview(presentationData.theme, data, starsData)) var isCreator = false if let peer, case let .channel(channel) = peer, channel.flags.contains(.isCreator) { isCreator = true } - entries.append(.adsBalanceTitle(presentationData.theme, presentationData.strings.Monetization_BalanceTitle)) - entries.append(.adsBalance(presentationData.theme, data, isCreator && data.balances.availableBalance > 0, monetizationConfiguration.withdrawalAvailable, diamond)) + entries.append(.adsTonBalanceTitle(presentationData.theme, presentationData.strings.Monetization_TonBalanceTitle)) + entries.append(.adsTonBalance(presentationData.theme, data, isCreator && data.balances.availableBalance > 0, monetizationConfiguration.withdrawalAvailable)) if isCreator { let withdrawalInfoText: String @@ -1412,11 +1533,35 @@ private func monetizationEntries( } else { withdrawalInfoText = presentationData.strings.Monetization_Balance_ComingLaterInfo } - entries.append(.adsBalanceInfo(presentationData.theme, withdrawalInfoText)) + entries.append(.adsTonBalanceInfo(presentationData.theme, withdrawalInfoText)) } - - if !transactionsInfo.transactions.isEmpty { - entries.append(.adsTransactionsTitle(presentationData.theme, presentationData.strings.Monetization_TransactionsTitle)) + + if let starsData, starsData.balances.overallRevenue > 0 { + entries.append(.adsStarsBalanceTitle(presentationData.theme, presentationData.strings.Monetization_StarsBalanceTitle)) + entries.append(.adsStarsBalance(presentationData.theme, starsData, isCreator && starsData.balances.availableBalance > 0, starsData.balances.withdrawEnabled)) + entries.append(.adsStarsBalanceInfo(presentationData.theme, presentationData.strings.Monetization_Balance_StarsInfo)) + } + + var addedTransactionsTabs = false + if !transactionsInfo.transactions.isEmpty && !starsTransactionsInfo.transactions.isEmpty { + addedTransactionsTabs = true + entries.append(.adsTransactionsTabs(presentationData.theme, presentationData.strings.Monetization_TonTransactions, presentationData.strings.Monetization_StarsTransactions, state.starsSelected)) + } + + var displayTonTransactions = false + if !transactionsInfo.transactions.isEmpty && (starsTransactionsInfo.transactions.isEmpty || !state.starsSelected) { + displayTonTransactions = true + } + + var displayStarsTransactions = false + if !starsTransactionsInfo.transactions.isEmpty && (transactionsInfo.transactions.isEmpty || state.starsSelected) { + displayStarsTransactions = true + } + + if displayTonTransactions { + if !addedTransactionsTabs { + entries.append(.adsTransactionsTitle(presentationData.theme, presentationData.strings.Monetization_TonTransactions.uppercased())) + } var transactions = transactionsInfo.transactions var limit: Int32 @@ -1438,7 +1583,38 @@ private func monetizationEntries( if !state.transactionsExpanded { moreCount = min(20, transactionsInfo.count - Int32(transactions.count)) } else { - moreCount = min(500, transactionsInfo.count - Int32(transactions.count)) + moreCount = min(50, transactionsInfo.count - Int32(transactions.count)) + } + entries.append(.adsTransactionsExpand(presentationData.theme, presentationData.strings.Monetization_Transaction_ShowMoreTransactions(moreCount))) + } + } + + if displayStarsTransactions { + if !addedTransactionsTabs { + entries.append(.adsTransactionsTitle(presentationData.theme, presentationData.strings.Monetization_StarsTransactions.uppercased())) + } + + var transactions = starsTransactionsInfo.transactions + var limit: Int32 + if state.transactionsExpanded { + limit = 25 + state.moreTransactionsDisplayed + } else { + limit = initialTransactionsDisplayedLimit + } + transactions = Array(transactions.prefix(Int(limit))) + + var i: Int32 = 0 + for transaction in transactions { + entries.append(.adsStarsTransaction(i, presentationData.theme, transaction)) + i += 1 + } + + if starsTransactionsInfo.canLoadMore || starsTransactionsInfo.transactions.count > transactions.count { + let moreCount: Int32 + if !state.transactionsExpanded { + moreCount = min(20, Int32(starsTransactionsInfo.transactions.count - transactions.count)) + } else { + moreCount = min(50, Int32(starsTransactionsInfo.transactions.count - transactions.count)) } entries.append(.adsTransactionsExpand(presentationData.theme, presentationData.strings.Monetization_Transaction_ShowMoreTransactions(moreCount))) } @@ -1471,9 +1647,10 @@ private func channelStatsControllerEntries( giveawayAvailable: Bool, isGroup: Bool, boostsOnly: Bool, - animatedEmojis: [String: [StickerPackItem]], revenueState: RevenueStats?, revenueTransactions: RevenueStatsTransactionsContext.State, + starsState: StarsRevenueStats?, + starsTransactions: StarsTransactionsContext.State, adsRestricted: Bool, premiumConfiguration: PremiumConfiguration, monetizationConfiguration: MonetizationConfiguration @@ -1512,8 +1689,9 @@ private func channelStatsControllerEntries( data: revenueState, boostData: boostData, transactionsInfo: revenueTransactions, + starsData: starsState, + starsTransactionsInfo: starsTransactions, adsRestricted: adsRestricted, - animatedEmojis: animatedEmojis, premiumConfiguration: premiumConfiguration, monetizationConfiguration: monetizationConfiguration ) @@ -1523,8 +1701,8 @@ private func channelStatsControllerEntries( } public func channelStatsController(context: AccountContext, updatedPresentationData: (initial: PresentationData, signal: Signal)? = nil, peerId: PeerId, section: ChannelStatsSection = .stats, boostStatus: ChannelBoostStatus? = nil, boostStatusUpdated: ((ChannelBoostStatus) -> Void)? = nil) -> ViewController { - let statePromise = ValuePromise(ChannelStatsControllerState(section: section, boostersExpanded: false, moreBoostersDisplayed: 0, giftsSelected: false, transactionsExpanded: false, moreTransactionsDisplayed: 0), ignoreRepeated: true) - let stateValue = Atomic(value: ChannelStatsControllerState(section: section, boostersExpanded: false, moreBoostersDisplayed: 0, giftsSelected: false, transactionsExpanded: false, moreTransactionsDisplayed: 0)) + let statePromise = ValuePromise(ChannelStatsControllerState(section: section, boostersExpanded: false, moreBoostersDisplayed: 0, giftsSelected: false, starsSelected: false, transactionsExpanded: false, moreTransactionsDisplayed: 0), ignoreRepeated: true) + let stateValue = Atomic(value: ChannelStatsControllerState(section: section, boostersExpanded: false, moreBoostersDisplayed: 0, giftsSelected: false, starsSelected: false, transactionsExpanded: false, moreTransactionsDisplayed: 0)) let updateState: ((ChannelStatsControllerState) -> ChannelStatsControllerState) -> Void = { f in statePromise.set(stateValue.modify { f($0) }) } @@ -1583,7 +1761,13 @@ public func channelStatsController(context: AccountContext, updatedPresentationD let revenueState = Promise() revenueState.set(.single(nil) |> then(revenueContext.state |> map(Optional.init))) + let starsContext = context.engine.payments.peerStarsRevenueContext(peerId: peerId) + let starsState = Promise() + starsState.set(.single(nil) |> then(starsContext.state |> map(Optional.init))) + let revenueTransactions = RevenueStatsTransactionsContext(account: context.account, peerId: peerId) + let starsTransactions = context.engine.payments.peerStarsTransactionsContext(subject: .peer(peerId), mode: .all) + starsTransactions.loadMore() var dismissAllTooltipsImpl: (() -> Void)? var presentImpl: ((ViewController) -> Void)? @@ -1592,8 +1776,10 @@ public func channelStatsController(context: AccountContext, updatedPresentationD var navigateToChatImpl: ((EnginePeer) -> Void)? var navigateToMessageImpl: ((EngineMessage.Id) -> Void)? var openBoostImpl: ((Bool) -> Void)? - var openTransactionImpl: ((RevenueStatsTransactionsContext.State.Transaction) -> Void)? - var requestWithdrawImpl: (() -> Void)? + var openTonTransactionImpl: ((RevenueStatsTransactionsContext.State.Transaction) -> Void)? + var openStarsTransactionImpl: ((StarsContext.State.Transaction) -> Void)? + var requestTonWithdrawImpl: (() -> Void)? + var requestStarsWithdrawImpl: (() -> Void)? var updateStatusBarImpl: ((StatusBarStyle) -> Void)? var dismissInputImpl: (() -> Void)? @@ -1716,8 +1902,14 @@ public func channelStatsController(context: AccountContext, updatedPresentationD updateGiftsSelected: { selected in updateState { $0.withUpdatedGiftsSelected(selected).withUpdatedBoostersExpanded(false) } }, - requestWithdraw: { - requestWithdrawImpl?() + updateStarsSelected: { selected in + updateState { $0.withUpdatedStarsSelected(selected).withUpdatedTransactionsExpanded(false) } + }, + requestTonWithdraw: { + requestTonWithdrawImpl?() + }, + requestStarsWithdraw: { + requestStarsWithdrawImpl?() }, openMonetizationIntro: { let controller = MonetizationIntroScreen(context: context, openMore: {}) @@ -1727,18 +1919,27 @@ public func channelStatsController(context: AccountContext, updatedPresentationD let presentationData = context.sharedContext.currentPresentationData.with { $0 } context.sharedContext.openExternalUrl(context: context, urlContext: .generic, url: presentationData.strings.Monetization_BalanceInfo_URL, forceExternal: true, presentationData: presentationData, navigationController: nil, dismissInput: {}) }, - openTransaction: { transaction in - openTransactionImpl?(transaction) + openTonTransaction: { transaction in + openTonTransactionImpl?(transaction) + }, + openStarsTransaction: { transaction in + openStarsTransactionImpl?(transaction) }, expandTransactions: { + var starsSelected = false updateState { state in + starsSelected = state.starsSelected if state.transactionsExpanded { return state.withUpdatedMoreTransactionsDisplayed(state.moreTransactionsDisplayed + 50) } else { return state.withUpdatedTransactionsExpanded(true) } } - revenueTransactions.loadMore() + if starsSelected { + starsTransactions.loadMore() + } else { + revenueTransactions.loadMore() + } }, updateCpmEnabled: { value in let _ = context.engine.peers.updateChannelRestrictAdMessages(peerId: peerId, restricted: value).start() @@ -1802,12 +2003,13 @@ public func channelStatsController(context: AccountContext, updatedPresentationD giftsContext.state, revenueState.get(), revenueTransactions.state, + starsState.get(), + starsTransactions.state, peerData, - longLoadingSignal, - context.animatedEmojiStickers + longLoadingSignal ) |> deliverOnMainQueue - |> map { presentationData, state, peer, data, messageView, stories, boostData, boostersState, giftsState, revenueState, revenueTransactions, peerData, longLoading, animatedEmojiStickers -> (ItemListControllerState, (ItemListNodeState, Any)) in + |> map { presentationData, state, peer, data, messageView, stories, boostData, boostersState, giftsState, revenueState, revenueTransactions, starsState, starsTransactions, peerData, longLoading -> (ItemListControllerState, (ItemListNodeState, Any)) in let (adsRestricted, canViewRevenue) = peerData var isGroup = false @@ -1900,7 +2102,7 @@ public func channelStatsController(context: AccountContext, updatedPresentationD } let controllerState = ItemListControllerState(presentationData: ItemListPresentationData(presentationData), title: title, leftNavigationButton: leftNavigationButton, rightNavigationButton: nil, backNavigationButton: ItemListBackButton(title: presentationData.strings.Common_Back), animateChanges: true) - let listState = ItemListNodeState(presentationData: ItemListPresentationData(presentationData), entries: channelStatsControllerEntries(presentationData: presentationData, state: state, peer: peer, data: data, messages: messages, stories: stories, interactions: interactions, boostData: boostData, boostersState: boostersState, giftsState: giftsState, giveawayAvailable: premiumConfiguration.giveawayGiftsPurchaseAvailable, isGroup: isGroup, boostsOnly: boostsOnly, animatedEmojis: animatedEmojiStickers, revenueState: revenueState?.stats, revenueTransactions: revenueTransactions, adsRestricted: adsRestricted, premiumConfiguration: premiumConfiguration, monetizationConfiguration: monetizationConfiguration), style: .blocks, emptyStateItem: emptyStateItem, headerItem: headerItem, crossfadeState: previous == nil, animateChanges: false) + let listState = ItemListNodeState(presentationData: ItemListPresentationData(presentationData), entries: channelStatsControllerEntries(presentationData: presentationData, state: state, peer: peer, data: data, messages: messages, stories: stories, interactions: interactions, boostData: boostData, boostersState: boostersState, giftsState: giftsState, giveawayAvailable: premiumConfiguration.giveawayGiftsPurchaseAvailable, isGroup: isGroup, boostsOnly: boostsOnly, revenueState: revenueState?.stats, revenueTransactions: revenueTransactions, starsState: starsState?.stats, starsTransactions: starsTransactions, adsRestricted: adsRestricted, premiumConfiguration: premiumConfiguration, monetizationConfiguration: monetizationConfiguration), style: .blocks, emptyStateItem: emptyStateItem, headerItem: headerItem, crossfadeState: previous == nil, animateChanges: false) return (controllerState, (listState, arguments)) } @@ -1909,6 +2111,7 @@ public func channelStatsController(context: AccountContext, updatedPresentationD let _ = statsContext.state let _ = storyList.state let _ = revenueContext.state + let _ = starsContext.state } let controller = ItemListController(context: context, state: signal) @@ -2122,7 +2325,7 @@ public func channelStatsController(context: AccountContext, updatedPresentationD }) } } - requestWithdrawImpl = { + requestTonWithdrawImpl = { withdrawalDisposable.set((context.engine.peers.checkChannelRevenueWithdrawalAvailability() |> deliverOnMainQueue).start(error: { error in let controller = revenueWithdrawalController(context: context, updatedPresentationData: updatedPresentationData, peerId: peerId, initialError: error, present: { c, _ in @@ -2134,7 +2337,44 @@ public func channelStatsController(context: AccountContext, updatedPresentationD presentImpl?(controller) })) } - openTransactionImpl = { transaction in + requestStarsWithdrawImpl = { + withdrawalDisposable.set((context.engine.peers.checkStarsRevenueWithdrawalAvailability() + |> deliverOnMainQueue).start(error: { error in + switch error { + case .requestPassword: + let _ = (starsContext.state + |> take(1) + |> deliverOnMainQueue).startStandalone(next: { state in + guard let stats = state.stats else { + return + } + let controller = context.sharedContext.makeStarsWithdrawalScreen(context: context, stats: stats, completion: { amount in + let controller = confirmStarsRevenueWithdrawalController(context: context, peerId: peerId, amount: amount, present: { c, a in + presentImpl?(c) + }, completion: { url in + let presentationData = context.sharedContext.currentPresentationData.with { $0 } + context.sharedContext.openExternalUrl(context: context, urlContext: .generic, url: url, forceExternal: true, presentationData: presentationData, navigationController: nil, dismissInput: {}) + + Queue.mainQueue().after(2.0) { + starsContext.reload() + starsTransactions.reload() + } + }) + presentImpl?(controller) + }) + pushImpl?(controller) + }) + default: + let controller = starsRevenueWithdrawalController(context: context, peerId: peerId, amount: 0, initialError: error, present: { c, a in + presentImpl?(c) + }, completion: { _ in + + }) + presentImpl?(controller) + } + })) + } + openTonTransactionImpl = { transaction in let _ = (peer.get() |> take(1) |> deliverOnMainQueue).start(next: { peer in @@ -2146,6 +2386,16 @@ public func channelStatsController(context: AccountContext, updatedPresentationD })) }) } + openStarsTransactionImpl = { transaction in + let _ = (peer.get() + |> take(1) + |> deliverOnMainQueue).start(next: { peer in + guard let peer else { + return + } + pushImpl?(context.sharedContext.makeStarsTransactionScreen(context: context, transaction: transaction, peer: peer)) + }) + } updateStatusBarImpl = { [weak controller] style in controller?.setStatusBarStyle(style, animated: true) } diff --git a/submodules/StatisticsUI/Sources/MonetizationBalanceItem.swift b/submodules/StatisticsUI/Sources/MonetizationBalanceItem.swift index 2e7ad59bdc..554f802386 100644 --- a/submodules/StatisticsUI/Sources/MonetizationBalanceItem.swift +++ b/submodules/StatisticsUI/Sources/MonetizationBalanceItem.swift @@ -13,7 +13,7 @@ import TextFormat final class MonetizationBalanceItem: ListViewItem, ItemListItem { let context: AccountContext let presentationData: ItemListPresentationData - let stats: RevenueStats + let stats: Stats let canWithdraw: Bool let isEnabled: Bool let withdrawAction: () -> Void @@ -23,7 +23,7 @@ final class MonetizationBalanceItem: ListViewItem, ItemListItem { init( context: AccountContext, presentationData: ItemListPresentationData, - stats: RevenueStats, + stats: Stats, canWithdraw: Bool, isEnabled: Bool, withdrawAction: @escaping () -> Void, @@ -158,13 +158,24 @@ final class MonetizationBalanceItemNode: ListViewItemNode, ItemListItemNode { let integralFont = Font.with(size: 48.0, design: .round, weight: .semibold) let fractionalFont = Font.with(size: 24.0, design: .round, weight: .semibold) - let cryptoValue = formatBalanceText(item.stats.balances.availableBalance, decimalSeparator: item.presentationData.dateTimeFormat.decimalSeparator) + let amountString: NSAttributedString + let value: String - let amountString = amountAttributedString(cryptoValue, integralFont: integralFont, fractionalFont: fractionalFont, color: item.presentationData.theme.list.itemPrimaryTextColor) + var isStars = false + if let stats = item.stats as? RevenueStats { + let cryptoValue = formatBalanceText(stats.balances.availableBalance, decimalSeparator: item.presentationData.dateTimeFormat.decimalSeparator) + amountString = amountAttributedString(cryptoValue, integralFont: integralFont, fractionalFont: fractionalFont, color: item.presentationData.theme.list.itemPrimaryTextColor) + value = stats.balances.availableBalance == 0 ? "" : "≈\(formatUsdValue(stats.balances.availableBalance, rate: stats.usdRate))" + } else if let stats = item.stats as? StarsRevenueStats { + amountString = NSAttributedString(string: presentationStringsFormattedNumber(Int32(stats.balances.availableBalance), item.presentationData.dateTimeFormat.groupingSeparator), font: integralFont, textColor: item.presentationData.theme.list.itemPrimaryTextColor) + value = stats.balances.availableBalance == 0 ? "" : "≈\(formatUsdValue(stats.balances.availableBalance, rate: stats.usdRate))" + isStars = true + } else { + fatalError() + } let (balanceLayout, balanceApply) = makeBalanceTextLayout(TextNodeLayoutArguments(attributedString: amountString, backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .middle, constrainedSize: CGSize(width: constrainedWidth, height: CGFloat.greatestFiniteMagnitude), alignment: .center, cutout: nil, insets: UIEdgeInsets())) - let value = item.stats.balances.availableBalance == 0 ? "" : "≈\(formatUsdValue(item.stats.balances.availableBalance, rate: item.stats.usdRate))" let (valueLayout, valueApply) = makeValueTextLayout(TextNodeLayoutArguments(attributedString: NSAttributedString(string: value, font: Font.regular(17.0), textColor: item.presentationData.theme.list.itemSecondaryTextColor), backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .middle, constrainedSize: CGSize(width: constrainedWidth, height: CGFloat.greatestFiniteMagnitude), alignment: .center, cutout: nil, insets: UIEdgeInsets())) let verticalInset: CGFloat = 13.0 @@ -272,7 +283,11 @@ final class MonetizationBalanceItemNode: ListViewItemNode, ItemListItemNode { } if themeUpdated { - strongSelf.iconNode.image = generateTintedImage(image: UIImage(bundleImageName: "Ads/TonBig"), color: item.presentationData.theme.list.itemAccentColor) + if isStars { + strongSelf.iconNode.image = UIImage(bundleImageName: "Premium/Stars/BalanceStar") + } else { + strongSelf.iconNode.image = generateTintedImage(image: UIImage(bundleImageName: "Ads/TonBig"), color: item.presentationData.theme.list.itemAccentColor) + } } var emojiItemSize = CGSize() @@ -304,7 +319,7 @@ final class MonetizationBalanceItemNode: ListViewItemNode, ItemListItemNode { strongSelf.addSubnode(withdrawButtonNode) strongSelf.withdrawButtonNode = withdrawButtonNode } - withdrawButtonNode.title = item.presentationData.strings.Monetization_BalanceWithdraw + withdrawButtonNode.title = isStars ? item.presentationData.strings.Monetization_BalanceStarsWithdraw : item.presentationData.strings.Monetization_BalanceWithdraw withdrawButtonNode.isEnabled = item.isEnabled let buttonWidth = contentSize.width - leftInset - rightInset diff --git a/submodules/StatisticsUI/Sources/StarsTransactionItem.swift b/submodules/StatisticsUI/Sources/StarsTransactionItem.swift new file mode 100644 index 0000000000..5cac23f091 --- /dev/null +++ b/submodules/StatisticsUI/Sources/StarsTransactionItem.swift @@ -0,0 +1,395 @@ +import Foundation +import UIKit +import Display +import AsyncDisplayKit +import SwiftSignalKit +import TelegramCore +import AccountContext +import TelegramPresentationData +import ItemListUI +import ComponentFlow +import ListActionItemComponent +import MultilineTextComponent +import TelegramStringFormatting +import StarsAvatarComponent + +final class StarsTransactionItem: ListViewItem, ItemListItem { + let context: AccountContext + let presentationData: ItemListPresentationData + let transaction: StarsContext.State.Transaction + let action: () -> Void + let sectionId: ItemListSectionId + let style: ItemListStyle + + init( + context: AccountContext, + presentationData: ItemListPresentationData, + transaction: StarsContext.State.Transaction, + action: @escaping () -> Void, + sectionId: ItemListSectionId, + style: ItemListStyle + ) { + self.context = context + self.presentationData = presentationData + self.transaction = transaction + self.action = action + self.sectionId = sectionId + self.style = style + } + + func nodeConfiguredForParams(async: @escaping (@escaping () -> Void) -> Void, params: ListViewItemLayoutParams, synchronousLoads: Bool, previousItem: ListViewItem?, nextItem: ListViewItem?, completion: @escaping (ListViewItemNode, @escaping () -> (Signal?, (ListViewItemApply) -> Void)) -> Void) { + async { + let node = StarsTransactionItemNode() + let (layout, apply) = node.asyncLayout()(self, params, itemListNeighbors(item: self, topItem: previousItem as? ItemListItem, bottomItem: nextItem as? ItemListItem)) + + node.contentSize = layout.contentSize + node.insets = layout.insets + + Queue.mainQueue().async { + completion(node, { + return (nil, { _ in apply() }) + }) + } + } + } + + func updateNode(async: @escaping (@escaping () -> Void) -> Void, node: @escaping () -> ListViewItemNode, params: ListViewItemLayoutParams, previousItem: ListViewItem?, nextItem: ListViewItem?, animation: ListViewItemUpdateAnimation, completion: @escaping (ListViewItemNodeLayout, @escaping (ListViewItemApply) -> Void) -> Void) { + Queue.mainQueue().async { + if let nodeValue = node() as? StarsTransactionItemNode { + let makeLayout = nodeValue.asyncLayout() + + async { + let (layout, apply) = makeLayout(self, params, itemListNeighbors(item: self, topItem: previousItem as? ItemListItem, bottomItem: nextItem as? ItemListItem)) + Queue.mainQueue().async { + completion(layout, { _ in + apply() + }) + } + } + } + } + } + + var selectable: Bool = true + + public func selected(listView: ListView) { + listView.clearHighlightAnimated(true) + self.action() + } +} + +final class StarsTransactionItemNode: ListViewItemNode, ItemListItemNode { + private let backgroundNode: ASDisplayNode + private let topStripeNode: ASDisplayNode + private let bottomStripeNode: ASDisplayNode + private let highlightedBackgroundNode: ASDisplayNode + private let maskNode: ASImageNode + + private let componentView: ComponentView + + private let activateArea: AccessibilityAreaNode + + private var item: StarsTransactionItem? + + var tag: ItemListItemTag? = nil + + init() { + self.backgroundNode = ASDisplayNode() + self.backgroundNode.isLayerBacked = true + self.backgroundNode.backgroundColor = .white + + self.maskNode = ASImageNode() + + self.topStripeNode = ASDisplayNode() + self.topStripeNode.isLayerBacked = true + + self.bottomStripeNode = ASDisplayNode() + self.bottomStripeNode.isLayerBacked = true + + self.highlightedBackgroundNode = ASDisplayNode() + self.highlightedBackgroundNode.isLayerBacked = true + + self.componentView = ComponentView() + + self.activateArea = AccessibilityAreaNode() + + super.init(layerBacked: false, dynamicBounce: false) + } + + func asyncLayout() -> (_ item: StarsTransactionItem, _ params: ListViewItemLayoutParams, _ insets: ItemListNeighbors) -> (ListViewItemNodeLayout, () -> Void) { + let currentItem = self.item + + return { item, params, neighbors in + var updatedTheme: PresentationTheme? + if currentItem?.presentationData.theme !== item.presentationData.theme { + updatedTheme = item.presentationData.theme + } + + let contentSize: CGSize + let insets: UIEdgeInsets + let separatorHeight = UIScreenPixel + let itemBackgroundColor: UIColor + let itemSeparatorColor: UIColor + + let leftInset = 16.0 + params.leftInset + + let height: CGFloat = 78.0 + + switch item.style { + case .plain: + itemBackgroundColor = item.presentationData.theme.list.plainBackgroundColor + itemSeparatorColor = .clear + insets = UIEdgeInsets() + case .blocks: + itemBackgroundColor = item.presentationData.theme.list.itemBlocksBackgroundColor + itemSeparatorColor = item.presentationData.theme.list.itemBlocksSeparatorColor + insets = itemListNeighborsGroupedInsets(neighbors, params) + } + + contentSize = CGSize(width: params.width, height: height) + + let layout = ListViewItemNodeLayout(contentSize: contentSize, insets: insets) + + return (ListViewItemNodeLayout(contentSize: contentSize, insets: insets), { [weak self] in + if let strongSelf = self { + strongSelf.item = item + + strongSelf.activateArea.frame = CGRect(origin: CGPoint(x: params.leftInset, y: 0.0), size: CGSize(width: params.width - params.leftInset - params.rightInset, height: layout.contentSize.height)) + strongSelf.activateArea.accessibilityTraits = [] + + if let _ = updatedTheme { + strongSelf.topStripeNode.backgroundColor = itemSeparatorColor + strongSelf.bottomStripeNode.backgroundColor = itemSeparatorColor + strongSelf.backgroundNode.backgroundColor = itemBackgroundColor + strongSelf.highlightedBackgroundNode.backgroundColor = item.presentationData.theme.list.itemHighlightedBackgroundColor + } + + switch item.style { + case .plain: + if strongSelf.backgroundNode.supernode != nil { + strongSelf.backgroundNode.removeFromSupernode() + } + if strongSelf.topStripeNode.supernode != nil { + strongSelf.topStripeNode.removeFromSupernode() + } + if strongSelf.bottomStripeNode.supernode == nil { + strongSelf.insertSubnode(strongSelf.bottomStripeNode, at: 0) + } + if strongSelf.maskNode.supernode != nil { + strongSelf.maskNode.removeFromSupernode() + } + strongSelf.bottomStripeNode.frame = CGRect(origin: CGPoint(x: leftInset, y: contentSize.height - separatorHeight), size: CGSize(width: params.width - leftInset, height: separatorHeight)) + case .blocks: + if strongSelf.backgroundNode.supernode == nil { + strongSelf.insertSubnode(strongSelf.backgroundNode, at: 0) + } + if strongSelf.topStripeNode.supernode == nil { + strongSelf.insertSubnode(strongSelf.topStripeNode, at: 1) + } + if strongSelf.bottomStripeNode.supernode == nil { + strongSelf.insertSubnode(strongSelf.bottomStripeNode, at: 2) + } + if strongSelf.maskNode.supernode == nil { + strongSelf.insertSubnode(strongSelf.maskNode, at: 3) + } + + let hasCorners = itemListHasRoundedBlockLayout(params) + var hasTopCorners = false + var hasBottomCorners = false + switch neighbors.top { + case .sameSection(false): + strongSelf.topStripeNode.isHidden = true + default: + hasTopCorners = true + strongSelf.topStripeNode.isHidden = hasCorners + } + let bottomStripeInset: CGFloat + switch neighbors.bottom { + case .sameSection(false): + bottomStripeInset = leftInset + strongSelf.bottomStripeNode.isHidden = false + default: + bottomStripeInset = 0.0 + hasBottomCorners = true + strongSelf.bottomStripeNode.isHidden = hasCorners + } + + strongSelf.maskNode.image = hasCorners ? PresentationResourcesItemList.cornersImage(item.presentationData.theme, top: hasTopCorners, bottom: hasBottomCorners) : nil + + strongSelf.backgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -min(insets.top, separatorHeight)), size: CGSize(width: params.width, height: contentSize.height + min(insets.top, separatorHeight) + min(insets.bottom, separatorHeight))) + strongSelf.maskNode.frame = strongSelf.backgroundNode.frame.insetBy(dx: params.leftInset, dy: 0.0) + strongSelf.topStripeNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -min(insets.top, separatorHeight)), size: CGSize(width: params.width, height: separatorHeight)) + strongSelf.bottomStripeNode.frame = CGRect(origin: CGPoint(x: bottomStripeInset, y: contentSize.height - separatorHeight), size: CGSize(width: params.width - bottomStripeInset, height: separatorHeight)) + } + + strongSelf.highlightedBackgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -UIScreenPixel), size: CGSize(width: params.width, height: layout.contentSize.height + UIScreenPixel + UIScreenPixel)) + + let fontBaseDisplaySize = 17.0 + + let itemTitle: String + let itemSubtitle: String? + var itemDate: String + switch item.transaction.peer { + case let .peer(peer): + if !item.transaction.media.isEmpty { + //TODO:localize + itemTitle = "Media Purchase" + itemSubtitle = peer.displayTitle(strings: item.presentationData.strings, displayOrder: .firstLast) + } else if let title = item.transaction.title { + itemTitle = title + itemSubtitle = peer.displayTitle(strings: item.presentationData.strings, displayOrder: .firstLast) + } else { + itemTitle = peer.displayTitle(strings: item.presentationData.strings, displayOrder: .firstLast) + itemSubtitle = nil + } + case .appStore: + itemTitle = item.presentationData.strings.Stars_Intro_Transaction_AppleTopUp_Title + itemSubtitle = item.presentationData.strings.Stars_Intro_Transaction_AppleTopUp_Subtitle + case .playMarket: + itemTitle = item.presentationData.strings.Stars_Intro_Transaction_GoogleTopUp_Title + itemSubtitle = item.presentationData.strings.Stars_Intro_Transaction_GoogleTopUp_Subtitle + case .fragment: + itemTitle = item.presentationData.strings.Stars_Intro_Transaction_FragmentWithdrawal_Title + itemSubtitle = item.presentationData.strings.Stars_Intro_Transaction_FragmentWithdrawal_Subtitle + case .premiumBot: + itemTitle = item.presentationData.strings.Stars_Intro_Transaction_PremiumBotTopUp_Title + itemSubtitle = item.presentationData.strings.Stars_Intro_Transaction_PremiumBotTopUp_Subtitle + case .unsupported: + itemTitle = item.presentationData.strings.Stars_Intro_Transaction_Unsupported_Title + itemSubtitle = nil + } + + let itemLabel: NSAttributedString + let labelString: String + + let formattedLabel = presentationStringsFormattedNumber(abs(Int32(item.transaction.count)), item.presentationData.dateTimeFormat.groupingSeparator) + if item.transaction.count < 0 { + labelString = "- \(formattedLabel)" + } else { + labelString = "+ \(formattedLabel)" + } + itemLabel = NSAttributedString(string: labelString, font: Font.medium(fontBaseDisplaySize), textColor: labelString.hasPrefix("-") ? item.presentationData.theme.list.itemDestructiveColor : item.presentationData.theme.list.itemDisclosureActions.constructive.fillColor) + + itemDate = stringForMediumCompactDate(timestamp: item.transaction.date, strings: item.presentationData.strings, dateTimeFormat: item.presentationData.dateTimeFormat) + if item.transaction.flags.contains(.isRefund) { + itemDate += " – \(item.presentationData.strings.Stars_Intro_Transaction_Refund)" + } + + var titleComponents: [AnyComponentWithIdentity] = [] + titleComponents.append( + AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString( + string: itemTitle, + font: Font.semibold(fontBaseDisplaySize), + textColor: item.presentationData.theme.list.itemPrimaryTextColor + )), + maximumNumberOfLines: 1 + ))) + ) + if let itemSubtitle { + titleComponents.append( + AnyComponentWithIdentity(id: AnyHashable(1), component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString( + string: itemSubtitle, + font: Font.regular(fontBaseDisplaySize * 16.0 / 17.0), + textColor: item.presentationData.theme.list.itemPrimaryTextColor + )), + maximumNumberOfLines: 1 + ))) + ) + } + titleComponents.append( + AnyComponentWithIdentity(id: AnyHashable(2), component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString( + string: itemDate, + font: Font.regular(floor(fontBaseDisplaySize * 14.0 / 17.0)), + textColor: item.presentationData.theme.list.itemSecondaryTextColor + )), + maximumNumberOfLines: 1 + ))) + ) + let itemSize = strongSelf.componentView.update( + transition: .immediate, + component: AnyComponent(ListActionItemComponent( + theme: item.presentationData.theme, + title: AnyComponent(VStack(titleComponents, alignment: .left, spacing: 2.0)), + contentInsets: UIEdgeInsets(top: 9.0, left: 0.0, bottom: 8.0, right: 0.0), + leftIcon: .custom(AnyComponentWithIdentity(id: "avatar", component: AnyComponent(StarsAvatarComponent(context: item.context, theme: item.presentationData.theme, peer: item.transaction.peer, photo: item.transaction.photo, media: item.transaction.media))), false), + icon: nil, + accessory: .custom(ListActionItemComponent.CustomAccessory(component: AnyComponentWithIdentity(id: "label", component: AnyComponent(StarsLabelComponent(text: itemLabel))), insets: UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 16.0))), + action: { [weak self] _ in + guard let self, let item = self.item else { + return + } + if !item.transaction.flags.contains(.isLocal) { + item.action() + } + } + )), + environment: {}, + containerSize: CGSize(width: params.width - params.leftInset - params.rightInset, height: height) + ) + let itemFrame = CGRect(origin: CGPoint(x: params.leftInset, y: 0.0), size: itemSize) + if let itemComponentView = strongSelf.componentView.view { + if itemComponentView.superview == nil { + strongSelf.view.addSubview(itemComponentView) + } + itemComponentView.isUserInteractionEnabled = false + itemComponentView.frame = itemFrame + } + } + }) + } + } + + + override public func setHighlighted(_ highlighted: Bool, at point: CGPoint, animated: Bool) { + super.setHighlighted(highlighted, at: point, animated: animated) + + if highlighted { + self.highlightedBackgroundNode.alpha = 1.0 + if self.highlightedBackgroundNode.supernode == nil { + var anchorNode: ASDisplayNode? + if self.bottomStripeNode.supernode != nil { + anchorNode = self.bottomStripeNode + } else if self.topStripeNode.supernode != nil { + anchorNode = self.topStripeNode + } else if self.backgroundNode.supernode != nil { + anchorNode = self.backgroundNode + } + if let anchorNode = anchorNode { + self.insertSubnode(self.highlightedBackgroundNode, aboveSubnode: anchorNode) + } else { + self.addSubnode(self.highlightedBackgroundNode) + } + } + } else { + if self.highlightedBackgroundNode.supernode != nil { + if animated { + self.highlightedBackgroundNode.layer.animateAlpha(from: self.highlightedBackgroundNode.alpha, to: 0.0, duration: 0.4, completion: { [weak self] completed in + if let strongSelf = self { + if completed { + strongSelf.highlightedBackgroundNode.removeFromSupernode() + } + } + }) + self.highlightedBackgroundNode.alpha = 0.0 + } else { + self.highlightedBackgroundNode.removeFromSupernode() + } + } + } + } + + override public func animateInsertion(_ currentTimestamp: Double, duration: Double, options: ListViewItemAnimationOptions) { + self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.4) + } + + override public func animateAdded(_ currentTimestamp: Double, duration: Double) { + self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) + } + + override public func animateRemoved(_ currentTimestamp: Double, duration: Double) { + self.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false) + } +} diff --git a/submodules/StatisticsUI/Sources/StatsOverviewItem.swift b/submodules/StatisticsUI/Sources/StatsOverviewItem.swift index 80510f2a7d..28cb76aaa1 100644 --- a/submodules/StatisticsUI/Sources/StatsOverviewItem.swift +++ b/submodules/StatisticsUI/Sources/StatsOverviewItem.swift @@ -39,25 +39,29 @@ extension RevenueStats: Stats { } +extension StarsRevenueStats: Stats { + +} + class StatsOverviewItem: ListViewItem, ItemListItem { let context: AccountContext let presentationData: ItemListPresentationData let isGroup: Bool let stats: Stats + let additionalStats: Stats? let storyViews: EngineStoryItem.Views? let publicShares: Int32? - let animatedEmoji: TelegramMediaFile? let sectionId: ItemListSectionId let style: ItemListStyle - init(context: AccountContext, presentationData: ItemListPresentationData, isGroup: Bool, stats: Stats, storyViews: EngineStoryItem.Views? = nil, publicShares: Int32? = nil, animatedEmoji: TelegramMediaFile? = nil, sectionId: ItemListSectionId, style: ItemListStyle) { + init(context: AccountContext, presentationData: ItemListPresentationData, isGroup: Bool, stats: Stats, additionalStats: Stats? = nil, storyViews: EngineStoryItem.Views? = nil, publicShares: Int32? = nil, sectionId: ItemListSectionId, style: ItemListStyle) { self.context = context self.presentationData = presentationData self.isGroup = isGroup self.stats = stats + self.additionalStats = additionalStats self.storyViews = storyViews self.publicShares = publicShares - self.animatedEmoji = animatedEmoji self.sectionId = sectionId self.style = style } @@ -99,6 +103,12 @@ class StatsOverviewItem: ListViewItem, ItemListItem { } private final class ValueItemNode: ASDisplayNode { + enum Mode { + case generic + case ton + case stars + } + enum DeltaColor { case generic case positive @@ -110,6 +120,7 @@ private final class ValueItemNode: ASDisplayNode { private let deltaNode: TextNode private var iconNode: ASImageNode? + var currentIconName: String? var currentTheme: PresentationTheme? var pressed: (() -> Void)? @@ -127,13 +138,13 @@ private final class ValueItemNode: ASDisplayNode { self.addSubnode(self.deltaNode) } - static func asyncLayout(_ current: ValueItemNode?) -> (_ context: AccountContext, _ width: CGFloat, _ presentationData: ItemListPresentationData, _ value: String, _ title: String, _ delta: (String, DeltaColor)?, _ isTon: Bool) -> (CGSize, () -> ValueItemNode) { + static func asyncLayout(_ current: ValueItemNode?) -> (_ context: AccountContext, _ width: CGFloat, _ presentationData: ItemListPresentationData, _ value: String, _ title: String, _ delta: (String, DeltaColor)?, _ mode: Mode) -> (CGSize, () -> ValueItemNode) { let maybeMakeValueLayout = (current?.valueNode).flatMap(TextNode.asyncLayout) let maybeMakeTitleLayout = (current?.titleNode).flatMap(TextNode.asyncLayout) let maybeMakeDeltaLayout = (current?.deltaNode).flatMap(TextNode.asyncLayout) - return { context, width, presentationData, value, title, delta, isTon in + return { context, width, presentationData, value, title, delta, mode in let targetNode: ValueItemNode if let current = current { targetNode = current @@ -188,7 +199,7 @@ private final class ValueItemNode: ASDisplayNode { let constrainedSize = CGSize(width: width, height: CGFloat.greatestFiniteMagnitude) let valueString: NSAttributedString - if isTon { + if case .ton = mode { valueString = amountAttributedString(value, integralFont: valueFont, fractionalFont: smallValueFont, color: valueColor) } else { valueString = NSAttributedString(string: value, font: valueFont, textColor: valueColor) @@ -214,7 +225,27 @@ private final class ValueItemNode: ASDisplayNode { let _ = deltaApply() var valueOffset: CGFloat = 0.0 - if isTon { + let iconName: String? + var iconTinted = false + switch mode { + case .ton: + iconName = "Ads/TonMedium" + iconTinted = true + valueOffset = 17.0 + case .stars: + iconName = "Premium/Stars/StarMedium" + valueOffset = 19.0 + default: + iconName = nil + } + + var iconNameUpdated = false + if targetNode.currentIconName != iconName { + targetNode.currentIconName = iconName + iconNameUpdated = true + } + + if let iconName { let iconNode: ASImageNode if let current = targetNode.iconNode { iconNode = current @@ -225,16 +256,18 @@ private final class ValueItemNode: ASDisplayNode { targetNode.addSubnode(iconNode) } - if themeUpdated { - iconNode.image = generateTintedImage(image: UIImage(bundleImageName: "Ads/TonMedium"), color: presentationData.theme.list.itemAccentColor) + if themeUpdated || iconNameUpdated { + if iconTinted { + iconNode.image = generateTintedImage(image: UIImage(bundleImageName: iconName), color: presentationData.theme.list.itemAccentColor) + } else { + iconNode.image = UIImage(bundleImageName: iconName) + } } if let icon = iconNode.image { let iconFrame = CGRect(origin: CGPoint(x: 0.0, y: floorToScreenPixels((valueLayout.size.height - icon.size.height) / 2.0) - 1.0), size: icon.size) iconNode.frame = iconFrame } - - valueOffset += 17.0 } else if let iconNode = targetNode.iconNode { iconNode.removeFromSupernode() targetNode.iconNode = nil @@ -382,7 +415,7 @@ class StatsOverviewItemNode: ListViewItemNode { compactNumericCountString(stats.views), item.presentationData.strings.Stats_Message_Views, nil, - false + .generic ) topRightItemLayoutAndApply = makeTopRightItemLayout( @@ -392,7 +425,7 @@ class StatsOverviewItemNode: ListViewItemNode { item.publicShares.flatMap { compactNumericCountString(Int($0)) } ?? "–", item.presentationData.strings.Stats_Message_PublicShares, nil, - false + .generic ) middle1LeftItemLayoutAndApply = makeMiddle1LeftItemLayout( @@ -402,7 +435,7 @@ class StatsOverviewItemNode: ListViewItemNode { compactNumericCountString(stats.reactions), item.presentationData.strings.Stats_Message_Reactions, nil, - false + .generic ) middle1RightItemLayoutAndApply = makeMiddle1RightItemLayout( @@ -412,7 +445,7 @@ class StatsOverviewItemNode: ListViewItemNode { item.publicShares.flatMap { "≈\( compactNumericCountString(max(0, stats.forwards - Int($0))))" } ?? "–", item.presentationData.strings.Stats_Message_PrivateShares, nil, - false + .generic ) height += topRightItemLayoutAndApply!.0.height * 2.0 + verticalSpacing @@ -424,7 +457,7 @@ class StatsOverviewItemNode: ListViewItemNode { compactNumericCountString(views.seenCount), item.presentationData.strings.Stats_Message_Views, nil, - false + .generic ) topRightItemLayoutAndApply = makeTopRightItemLayout( @@ -434,7 +467,7 @@ class StatsOverviewItemNode: ListViewItemNode { item.publicShares.flatMap { compactNumericCountString(Int($0)) } ?? "–", item.presentationData.strings.Stats_Message_PublicShares, nil, - false + .generic ) middle1LeftItemLayoutAndApply = makeMiddle1LeftItemLayout( @@ -444,7 +477,7 @@ class StatsOverviewItemNode: ListViewItemNode { compactNumericCountString(views.reactedCount), item.presentationData.strings.Stats_Message_Reactions, nil, - false + .generic ) middle1RightItemLayoutAndApply = makeMiddle1RightItemLayout( @@ -454,7 +487,7 @@ class StatsOverviewItemNode: ListViewItemNode { item.publicShares.flatMap { "≈\( compactNumericCountString(max(0, views.forwardCount - Int($0))))" } ?? "–", item.presentationData.strings.Stats_Message_PrivateShares, nil, - false + .generic ) height += topRightItemLayoutAndApply!.0.height * 2.0 + verticalSpacing @@ -466,7 +499,7 @@ class StatsOverviewItemNode: ListViewItemNode { "\(stats.level)", item.presentationData.strings.Stats_Boosts_Level, nil, - false + .generic ) var premiumSubscribers: Double = 0.0 @@ -481,7 +514,7 @@ class StatsOverviewItemNode: ListViewItemNode { "≈\(Int(stats.premiumAudience?.value ?? 0))", item.isGroup ? item.presentationData.strings.Stats_Boosts_PremiumMembers : item.presentationData.strings.Stats_Boosts_PremiumSubscribers, (String(format: "%.02f%%", premiumSubscribers * 100.0), .generic), - false + .generic ) middle1LeftItemLayoutAndApply = makeMiddle1LeftItemLayout( @@ -491,7 +524,7 @@ class StatsOverviewItemNode: ListViewItemNode { "\(stats.boosts)", item.presentationData.strings.Stats_Boosts_ExistingBoosts, nil, - false + .generic ) let boostsLeft: Int32 @@ -507,7 +540,7 @@ class StatsOverviewItemNode: ListViewItemNode { "\(boostsLeft)", item.presentationData.strings.Stats_Boosts_BoostsToLevelUp, nil, - false + .generic ) if twoColumnLayout { @@ -532,7 +565,7 @@ class StatsOverviewItemNode: ListViewItemNode { compactNumericCountString(Int(stats.followers.current)), item.presentationData.strings.Stats_Followers, (followersDelta.text, followersDelta.positive ? .positive : .negative), - false + .generic ) var enabledNotifications: Double = 0.0 @@ -546,7 +579,7 @@ class StatsOverviewItemNode: ListViewItemNode { String(format: "%.02f%%", enabledNotifications * 100.0), item.presentationData.strings.Stats_EnabledNotifications, nil, - false + .generic ) let hasMessages = stats.viewsPerPost.current > 0 || viewsPerPostDelta.hasValue @@ -605,7 +638,7 @@ class StatsOverviewItemNode: ListViewItemNode { value, title, delta, - false + .generic ) } if let (value, title, delta) = items[1] { @@ -616,7 +649,7 @@ class StatsOverviewItemNode: ListViewItemNode { value, title, delta, - false + .generic ) } if let (value, title, delta) = items[2] { @@ -627,7 +660,7 @@ class StatsOverviewItemNode: ListViewItemNode { value, title, delta, - false + .generic ) } if let (value, title, delta) = items[3] { @@ -638,7 +671,7 @@ class StatsOverviewItemNode: ListViewItemNode { value, title, delta, - false + .generic ) } if let (value, title, delta) = items[4] { @@ -649,7 +682,7 @@ class StatsOverviewItemNode: ListViewItemNode { value, title, delta, - false + .generic ) } if let (value, title, delta) = items[5] { @@ -660,7 +693,7 @@ class StatsOverviewItemNode: ListViewItemNode { value, title, delta, - false + .generic ) } @@ -684,7 +717,7 @@ class StatsOverviewItemNode: ListViewItemNode { compactNumericCountString(Int(stats.members.current)), item.presentationData.strings.Stats_GroupMembers, (membersDelta.text, membersDelta.positive ? .positive : .negative), - false + .generic ) let messagesDelta = deltaText(stats.messages) @@ -695,7 +728,7 @@ class StatsOverviewItemNode: ListViewItemNode { compactNumericCountString(Int(stats.messages.current)), item.presentationData.strings.Stats_GroupMessages, (messagesDelta.text, messagesDelta.positive ? .positive : .negative), - false + .generic ) if displayBottomRow { @@ -706,7 +739,7 @@ class StatsOverviewItemNode: ListViewItemNode { compactNumericCountString(Int(stats.viewers.current)), item.presentationData.strings.Stats_GroupViewers, (viewersDelta.text, viewersDelta.positive ? .positive : .negative), - false + .generic ) middle1RightItemLayoutAndApply = makeMiddle1RightItemLayout( @@ -716,7 +749,7 @@ class StatsOverviewItemNode: ListViewItemNode { compactNumericCountString(Int(stats.posters.current)), item.presentationData.strings.Stats_GroupPosters, (postersDelta.text, postersDelta.positive ? .positive : .negative), - false + .generic ) } @@ -726,39 +759,105 @@ class StatsOverviewItemNode: ListViewItemNode { height += topLeftItemLayoutAndApply!.0.height * 4.0 + verticalSpacing * 3.0 } } else if let stats = item.stats as? RevenueStats { - twoColumnLayout = false - - topLeftItemLayoutAndApply = makeTopLeftItemLayout( - item.context, - params.width, - item.presentationData, - formatBalanceText(stats.balances.availableBalance, decimalSeparator: item.presentationData.dateTimeFormat.decimalSeparator), - item.presentationData.strings.Monetization_Overview_Available, - (stats.balances.availableBalance == 0 ? "" : "≈\(formatUsdValue(stats.balances.availableBalance, rate: stats.usdRate))", .generic), - true - ) - - topRightItemLayoutAndApply = makeTopRightItemLayout( - item.context, - params.width, - item.presentationData, - formatBalanceText(stats.balances.currentBalance, decimalSeparator: item.presentationData.dateTimeFormat.decimalSeparator), - item.presentationData.strings.Monetization_Overview_Current, - (stats.balances.currentBalance == 0 ? "" : "≈\(formatUsdValue(stats.balances.currentBalance, rate: stats.usdRate))", .generic), - true - ) - - middle1LeftItemLayoutAndApply = makeMiddle1LeftItemLayout( - item.context, - params.width, - item.presentationData, - formatBalanceText(stats.balances.overallRevenue, decimalSeparator: item.presentationData.dateTimeFormat.decimalSeparator), - item.presentationData.strings.Monetization_Overview_Total, - (stats.balances.overallRevenue == 0 ? "" : "≈\(formatUsdValue(stats.balances.overallRevenue, rate: stats.usdRate))", .generic), - true - ) - - height += topLeftItemLayoutAndApply!.0.height * 3.0 + verticalSpacing * 2.0 + if let additionalStats = item.additionalStats as? StarsRevenueStats, additionalStats.balances.overallRevenue > 0 { + twoColumnLayout = true + + topLeftItemLayoutAndApply = makeTopLeftItemLayout( + item.context, + params.width, + item.presentationData, + formatBalanceText(stats.balances.availableBalance, decimalSeparator: item.presentationData.dateTimeFormat.decimalSeparator), + "Available Balance", //item.presentationData.strings.Monetization_Overview_Available, + (stats.balances.availableBalance == 0 ? "" : "≈\(formatUsdValue(stats.balances.availableBalance, rate: stats.usdRate))", .generic), + .ton + ) + + middle1LeftItemLayoutAndApply = makeMiddle1LeftItemLayout( + item.context, + params.width, + item.presentationData, + formatBalanceText(stats.balances.currentBalance, decimalSeparator: item.presentationData.dateTimeFormat.decimalSeparator), + "Current Balance", //item.presentationData.strings.Monetization_Overview_Current, + (stats.balances.currentBalance == 0 ? "" : "≈\(formatUsdValue(stats.balances.currentBalance, rate: stats.usdRate))", .generic), + .ton + ) + + middle2LeftItemLayoutAndApply = makeMiddle2LeftItemLayout( + item.context, + params.width, + item.presentationData, + formatBalanceText(stats.balances.overallRevenue, decimalSeparator: item.presentationData.dateTimeFormat.decimalSeparator), + "Lifetime Proceeds",//item.presentationData.strings.Monetization_Overview_Total, + (stats.balances.overallRevenue == 0 ? "" : "≈\(formatUsdValue(stats.balances.overallRevenue, rate: stats.usdRate))", .generic), + .ton + ) + + topRightItemLayoutAndApply = makeTopRightItemLayout( + item.context, + params.width, + item.presentationData, + presentationStringsFormattedNumber(Int32(additionalStats.balances.availableBalance), item.presentationData.dateTimeFormat.groupingSeparator), + " ", + (additionalStats.balances.availableBalance == 0 ? "" : "≈\(formatUsdValue(additionalStats.balances.availableBalance, rate: additionalStats.usdRate))", .generic), + .stars + ) + + middle1RightItemLayoutAndApply = makeMiddle1RightItemLayout( + item.context, + params.width, + item.presentationData, + presentationStringsFormattedNumber(Int32(additionalStats.balances.currentBalance), item.presentationData.dateTimeFormat.groupingSeparator), + " ", + (additionalStats.balances.currentBalance == 0 ? "" : "≈\(formatUsdValue(additionalStats.balances.currentBalance, rate: additionalStats.usdRate))", .generic), + .stars + ) + + middle2RightItemLayoutAndApply = makeMiddle2RightItemLayout( + item.context, + params.width, + item.presentationData, + presentationStringsFormattedNumber(Int32(additionalStats.balances.overallRevenue), item.presentationData.dateTimeFormat.groupingSeparator), + " ", + (additionalStats.balances.overallRevenue == 0 ? "" : "≈\(formatUsdValue(additionalStats.balances.overallRevenue, rate: additionalStats.usdRate))", .generic), + .stars + ) + + height += topLeftItemLayoutAndApply!.0.height * 3.0 + verticalSpacing * 2.0 + } else { + twoColumnLayout = false + + topLeftItemLayoutAndApply = makeTopLeftItemLayout( + item.context, + params.width, + item.presentationData, + formatBalanceText(stats.balances.availableBalance, decimalSeparator: item.presentationData.dateTimeFormat.decimalSeparator), + item.presentationData.strings.Monetization_Overview_Available, + (stats.balances.availableBalance == 0 ? "" : "≈\(formatUsdValue(stats.balances.availableBalance, rate: stats.usdRate))", .generic), + .ton + ) + + topRightItemLayoutAndApply = makeTopRightItemLayout( + item.context, + params.width, + item.presentationData, + formatBalanceText(stats.balances.currentBalance, decimalSeparator: item.presentationData.dateTimeFormat.decimalSeparator), + item.presentationData.strings.Monetization_Overview_Current, + (stats.balances.currentBalance == 0 ? "" : "≈\(formatUsdValue(stats.balances.currentBalance, rate: stats.usdRate))", .generic), + .ton + ) + + middle1LeftItemLayoutAndApply = makeMiddle1LeftItemLayout( + item.context, + params.width, + item.presentationData, + formatBalanceText(stats.balances.overallRevenue, decimalSeparator: item.presentationData.dateTimeFormat.decimalSeparator), + item.presentationData.strings.Monetization_Overview_Total, + (stats.balances.overallRevenue == 0 ? "" : "≈\(formatUsdValue(stats.balances.overallRevenue, rate: stats.usdRate))", .generic), + .ton + ) + + height += topLeftItemLayoutAndApply!.0.height * 3.0 + verticalSpacing * 2.0 + } } let contentSize = CGSize(width: params.width, height: height) diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Payments/Stars.swift b/submodules/TelegramCore/Sources/TelegramEngine/Payments/Stars.swift index cba43eb47c..c41f8543e9 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Payments/Stars.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Payments/Stars.swift @@ -114,7 +114,7 @@ private func _internal_requestStarsState(account: Account, peerId: EnginePeer.Id var parsedTransactions: [StarsContext.State.Transaction] = [] for entry in history { - if let parsedTransaction = StarsContext.State.Transaction(apiTransaction: entry, transaction: transaction) { + if let parsedTransaction = StarsContext.State.Transaction(apiTransaction: entry, peerId: peerId != account.peerId ? peerId : nil, transaction: transaction) { parsedTransactions.append(parsedTransaction) } } @@ -236,7 +236,7 @@ private final class StarsContextImpl { } private extension StarsContext.State.Transaction { - init?(apiTransaction: Api.StarsTransaction, transaction: Transaction) { + init?(apiTransaction: Api.StarsTransaction, peerId: EnginePeer.Id?, transaction: Transaction) { switch apiTransaction { case let .starsTransaction(apiFlags, id, stars, date, transactionPeer, title, description, photo, transactionDate, transactionUrl, _, messageId, extendedMedia): let parsedPeer: StarsContext.State.Transaction.Peer @@ -258,7 +258,11 @@ private extension StarsContext.State.Transaction { } parsedPeer = .peer(EnginePeer(peer)) if let messageId { - paidMessageId = MessageId(peerId: peer.id, namespace: Namespaces.Message.Cloud, id: messageId) + if let peerId { + paidMessageId = MessageId(peerId: peerId, namespace: Namespaces.Message.Cloud, id: messageId) + } else { + paidMessageId = MessageId(peerId: peer.id, namespace: Namespaces.Message.Cloud, id: messageId) + } } } diff --git a/submodules/TelegramCore/Sources/Utils/MessageUtils.swift b/submodules/TelegramCore/Sources/Utils/MessageUtils.swift index 741799e6c3..e266dc6d11 100644 --- a/submodules/TelegramCore/Sources/Utils/MessageUtils.swift +++ b/submodules/TelegramCore/Sources/Utils/MessageUtils.swift @@ -526,6 +526,10 @@ public extension Message { } return nil } + + var paidContent: TelegramMediaPaidContent? { + return self.media.first(where: { $0 is TelegramMediaPaidContent }) as? TelegramMediaPaidContent + } } public extension Message { diff --git a/submodules/TelegramUI/BUILD b/submodules/TelegramUI/BUILD index 84ab3ce44d..bd18f2c52d 100644 --- a/submodules/TelegramUI/BUILD +++ b/submodules/TelegramUI/BUILD @@ -450,6 +450,8 @@ swift_library( "//submodules/TelegramUI/Components/Stars/StarsTransactionsScreen", "//submodules/TelegramUI/Components/Stars/StarsPurchaseScreen", "//submodules/TelegramUI/Components/Stars/StarsTransferScreen", + "//submodules/TelegramUI/Components/Stars/StarsTransactionScreen", + "//submodules/TelegramUI/Components/Stars/StarsWithdrawalScreen", "//submodules/TelegramUI/Components/Chat/FactCheckAlertController", "//submodules/TelegramUI/Components/PeerManagement/OwnershipTransferController", "//submodules/TelegramUI/Components/PeerManagement/OldChannelsController", diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/BUILD b/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/BUILD index e3bf9e7105..a0c1bb4171 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/BUILD +++ b/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/BUILD @@ -81,6 +81,8 @@ swift_library( "//submodules/TelegramUI/Components/Chat/ChatMessageGiveawayBubbleContentNode", "//submodules/TelegramUI/Components/Chat/ChatMessageJoinedChannelBubbleContentNode", "//submodules/TelegramUI/Components/Chat/ChatMessageFactCheckBubbleContentNode", + "//submodules/TelegramUI/Components/Chat/ChatMessageUnlockMediaNode", + "//submodules/TelegramUI/Components/Chat/ChatMessageStarsMediaInfoNode", "//submodules/UIKitRuntimeUtils", "//submodules/TelegramUI/Components/Chat/ChatMessageTransitionNode", "//submodules/AnimatedStickerNode", diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift index 0d98f9734f..e7ab0d5bbc 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift @@ -71,6 +71,8 @@ import ChatMessageGiftBubbleContentNode import ChatMessageGiveawayBubbleContentNode import ChatMessageJoinedChannelBubbleContentNode import ChatMessageFactCheckBubbleContentNode +import ChatMessageUnlockMediaNode +import ChatMessageStarsMediaInfoNode import UIKitRuntimeUtils import ChatMessageTransitionNode import AnimatedStickerNode @@ -134,7 +136,7 @@ private func contentNodeMessagesAndClassesForItem(_ item: ChatMessageItem) -> ([ if let media = media as? TelegramMediaPaidContent { var index = 0 for _ in media.extendedMedia { - result.append((message, ChatMessageMediaBubbleContentNode.self, itemAttributes, BubbleItemAttributes(index: index, isAttachment: true, neighborType: .media, neighborSpacing: .default))) + result.append((message, ChatMessageMediaBubbleContentNode.self, itemAttributes, BubbleItemAttributes(index: index, isAttachment: false, neighborType: .media, neighborSpacing: .default))) index += 1 } } else if let _ = media as? TelegramMediaImage { @@ -636,6 +638,9 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI private var actionButtonsNode: ChatMessageActionButtonsNode? private var reactionButtonsNode: ChatMessageReactionButtonsNode? + private var unlockButtonNode: ChatMessageUnlockMediaNode? + private var mediaInfoNode: ChatMessageStarsMediaInfoNode? + private var shareButtonNode: ChatMessageShareButton? private let messageAccessibilityArea: AccessibilityAreaNode @@ -680,6 +685,10 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI replyInfoNode.visibility = self.visibility != .none } + if let unlockButtonNode = self.unlockButtonNode { + unlockButtonNode.visibility = self.visibility != .none + } + self.visibilityStatus = self.visibility != .none self.updateVisibility() @@ -1198,6 +1207,11 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI if let replyInfoNode = strongSelf.replyInfoNode, replyInfoNode.frame.contains(point) { return .waitForSingleTap } + if let unlockButtonNode = strongSelf.unlockButtonNode, unlockButtonNode.frame.contains(point) { + if let _ = unlockButtonNode.hitTest(strongSelf.view.convert(point, to: unlockButtonNode.view), with: nil) { + return .fail + } + } if let forwardInfoNode = strongSelf.forwardInfoNode, forwardInfoNode.frame.contains(point) { if forwardInfoNode.hasAction(at: strongSelf.view.convert(point, to: forwardInfoNode.view)) { return .fail @@ -1366,6 +1380,8 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI let replyInfoLayout = ChatMessageReplyInfoNode.asyncLayout(self.replyInfoNode) let actionButtonsLayout = ChatMessageActionButtonsNode.asyncLayout(self.actionButtonsNode) let reactionButtonsLayout = ChatMessageReactionButtonsNode.asyncLayout(self.reactionButtonsNode) + let unlockButtonLayout = ChatMessageUnlockMediaNode.asyncLayout(self.unlockButtonNode) + let mediaInfoLayout = ChatMessageStarsMediaInfoNode.asyncLayout(self.mediaInfoNode) let mosaicStatusLayout = ChatMessageDateAndStatusNode.asyncLayout(self.mosaicStatusNode) @@ -1391,6 +1407,8 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI replyInfoLayout: replyInfoLayout, actionButtonsLayout: actionButtonsLayout, reactionButtonsLayout: reactionButtonsLayout, + unlockButtonLayout: unlockButtonLayout, + mediaInfoLayout: mediaInfoLayout, mosaicStatusLayout: mosaicStatusLayout, layoutConstants: layoutConstants, currentItem: currentItem, @@ -1412,6 +1430,8 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI replyInfoLayout: (ChatMessageReplyInfoNode.Arguments) -> (CGSize, (CGSize, Bool, ListViewItemUpdateAnimation) -> ChatMessageReplyInfoNode), actionButtonsLayout: (AccountContext, ChatPresentationThemeData, PresentationChatBubbleCorners, PresentationStrings, WallpaperBackgroundNode?, ReplyMarkupMessageAttribute, Message, CGFloat) -> (minWidth: CGFloat, layout: (CGFloat) -> (CGSize, (ListViewItemUpdateAnimation) -> ChatMessageActionButtonsNode)), reactionButtonsLayout: (ChatMessageReactionButtonsNode.Arguments) -> (minWidth: CGFloat, layout: (CGFloat) -> (size: CGSize, apply: (ListViewItemUpdateAnimation) -> ChatMessageReactionButtonsNode)), + unlockButtonLayout: (ChatMessageUnlockMediaNode.Arguments) -> (CGSize, (Bool) -> ChatMessageUnlockMediaNode), + mediaInfoLayout: (ChatMessageStarsMediaInfoNode.Arguments) -> (CGSize, (Bool) -> ChatMessageStarsMediaInfoNode), mosaicStatusLayout: (ChatMessageDateAndStatusNode.Arguments) -> (CGFloat, (CGFloat) -> (CGSize, (ListViewItemUpdateAnimation) -> ChatMessageDateAndStatusNode)), layoutConstants: ChatMessageItemLayoutConstants, currentItem: ChatMessageItem?, @@ -2185,7 +2205,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } if case .customChatContents = item.associatedData.subject { - } else if (mosaicRange.upperBound == contentPropertiesAndLayouts.count || contentPropertiesAndLayouts[contentPropertiesAndLayouts.count - 1].3.isAttachment) && !hasText { + } else if (mosaicRange.upperBound == contentPropertiesAndLayouts.count || contentPropertiesAndLayouts[contentPropertiesAndLayouts.count - 1].3.isAttachment) && (!hasText || item.message.invertMedia) { let message = item.content.firstMessage var edited = false @@ -2286,6 +2306,9 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI var forwardSource: Peer? var forwardAuthorSignature: String? + var unlockButtonSizeApply: (CGSize, (Bool) -> ChatMessageUnlockMediaNode?) = (CGSize(), { _ in nil }) + var mediaInfoSizeApply: (CGSize, (Bool) -> ChatMessageStarsMediaInfoNode?) = (CGSize(), { _ in nil }) + if displayHeader { let bubbleWidthInsets: CGFloat = mosaicRange == nil ? layoutConstants.text.bubbleInsets.left + layoutConstants.text.bubbleInsets.right : 0.0 if authorNameString != nil || inlineBotNameString != nil { @@ -2824,9 +2847,11 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI var detachedContentNodesHeight: CGFloat = 0.0 var mosaicStatusOrigin: CGPoint? + var unlockButtonPosition: CGPoint? + var mediaInfoOrigin: CGPoint? for i in 0 ..< contentNodePropertiesAndFinalize.count { let (properties, position, finalize, contentGroupId, itemSelection) = contentNodePropertiesAndFinalize[i] - + if let position = position, case let .linear(top, bottom) = position { if case let .Neighbour(_, _, spacing) = top, case let .overlap(overlap) = spacing { currentContainerGroupOverlap = overlap @@ -2851,12 +2876,17 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI contentNodeFramesPropertiesAndApply.append((contentNodeFrame, properties, true, apply)) if i == mosaicRange.upperBound - 1 { + unlockButtonPosition = CGPoint(x: size.width / 2.0, y: contentNodesHeight + size.height / 2.0) + mediaInfoOrigin = CGPoint(x: size.width, y: contentNodesHeight) + contentNodesHeight += size.height totalContentNodesHeight += size.height - + mosaicStatusOrigin = contentNodeFrame.bottomRight } } else { + let contentProperties = contentPropertiesAndLayouts[i].3 + if i == 0 && !headerSize.height.isZero { if contentGroupId == nil { contentNodesHeight += properties.headerSpacing @@ -2870,7 +2900,9 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI if !contentContainerNodeFrames.isEmpty { overlapOffset = currentContainerGroupOverlap } - contentContainerNodeFrames.append((containerGroupId, CGRect(x: 0.0, y: headerSize.height + totalContentNodesHeight - contentNodesHeight - overlapOffset, width: maxContentWidth, height: contentNodesHeight), currentItemSelection, currentContainerGroupOverlap)) + let containerFrame = CGRect(x: 0.0, y: headerSize.height + totalContentNodesHeight - contentNodesHeight - overlapOffset, width: maxContentWidth, height: contentNodesHeight) + contentContainerNodeFrames.append((containerGroupId, containerFrame, currentItemSelection, currentContainerGroupOverlap)) + if !overlapOffset.isZero { totalContentNodesHeight -= currentContainerGroupOverlap } @@ -2885,7 +2917,13 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI let contentNodeOriginY = contentNodesHeight - detachedContentNodesHeight let (size, apply) = finalize(maxContentWidth) - contentNodeFramesPropertiesAndApply.append((CGRect(origin: CGPoint(x: 0.0, y: contentNodeOriginY), size: size), properties, contentGroupId == nil, apply)) + let containerFrame = CGRect(origin: CGPoint(x: 0.0, y: contentNodeOriginY), size: size) + contentNodeFramesPropertiesAndApply.append((containerFrame, properties, contentGroupId == nil, apply)) + + if contentProperties.neighborType == .media && unlockButtonPosition == nil { + unlockButtonPosition = containerFrame.center + mediaInfoOrigin = CGPoint(x: containerFrame.width, y: containerFrame.minY) + } contentNodesHeight += size.height totalContentNodesHeight += size.height @@ -2909,6 +2947,34 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI contentSize.height += totalContentNodesHeight + if let paidContent = item.message.media.first(where: { $0 is TelegramMediaPaidContent }) as? TelegramMediaPaidContent, let media = paidContent.extendedMedia.first { + if case .preview = media { + let sizeAndApply = unlockButtonLayout(ChatMessageUnlockMediaNode.Arguments( + presentationData: item.presentationData, + strings: item.presentationData.strings, + context: item.context, + controllerInteraction: item.controllerInteraction, + message: item.message, + media: paidContent, + constrainedSize: CGSize(width: maximumNodeWidth - layoutConstants.text.bubbleInsets.left - layoutConstants.text.bubbleInsets.right, height: CGFloat.greatestFiniteMagnitude), + animationCache: item.controllerInteraction.presentationContext.animationCache, + animationRenderer: item.controllerInteraction.presentationContext.animationRenderer + )) + unlockButtonSizeApply = (sizeAndApply.0, { synchronousLoads in sizeAndApply.1(synchronousLoads) }) + } else { + let sizeAndApply = mediaInfoLayout(ChatMessageStarsMediaInfoNode.Arguments( + presentationData: item.presentationData, + context: item.context, + message: item.message, + media: paidContent, + constrainedSize: CGSize(width: maximumNodeWidth - layoutConstants.text.bubbleInsets.left - layoutConstants.text.bubbleInsets.right, height: CGFloat.greatestFiniteMagnitude), + animationCache: item.controllerInteraction.presentationContext.animationCache, + animationRenderer: item.controllerInteraction.presentationContext.animationRenderer + )) + mediaInfoSizeApply = (sizeAndApply.0, { synchronousLoads in sizeAndApply.1(synchronousLoads) }) + } + } + var actionButtonsSizeAndApply: (CGSize, (ListViewItemUpdateAnimation) -> ChatMessageActionButtonsNode)? if let actionButtonsFinalize = actionButtonsFinalize { actionButtonsSizeAndApply = actionButtonsFinalize(maxContentWidth) @@ -3039,6 +3105,10 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI contentContainerNodeFrames: contentContainerNodeFrames, mosaicStatusOrigin: mosaicStatusOrigin, mosaicStatusSizeAndApply: mosaicStatusSizeAndApply, + unlockButtonPosition: unlockButtonPosition, + unlockButtonSizeAndApply: unlockButtonSizeApply, + mediaInfoOrigin: mediaInfoOrigin, + mediaInfoSizeAndApply: mediaInfoSizeApply, needsShareButton: needsShareButton, shareButtonOffset: shareButtonOffset, avatarOffset: avatarOffset, @@ -3093,6 +3163,10 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI contentContainerNodeFrames: [(UInt32, CGRect, Bool?, CGFloat)], mosaicStatusOrigin: CGPoint?, mosaicStatusSizeAndApply: (CGSize, (ListViewItemUpdateAnimation) -> ChatMessageDateAndStatusNode)?, + unlockButtonPosition: CGPoint?, + unlockButtonSizeAndApply: (CGSize, (Bool) -> ChatMessageUnlockMediaNode?), + mediaInfoOrigin: CGPoint?, + mediaInfoSizeAndApply: (CGSize, (Bool) -> ChatMessageStarsMediaInfoNode?), needsShareButton: Bool, shareButtonOffset: CGPoint?, avatarOffset: CGFloat?, @@ -4096,6 +4170,56 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI strongSelf.mosaicStatusNode = nil mosaicStatusNode.removeFromSupernode() } + + if let unlockButtonPosition { + let (size, apply) = unlockButtonSizeAndApply + var unlockButtonNodeAnimation = animation + if strongSelf.unlockButtonNode == nil { + unlockButtonNodeAnimation = .None + } + let unlockButtonNode = apply(strongSelf.unlockButtonNode != nil) + if unlockButtonNode !== strongSelf.unlockButtonNode { + strongSelf.unlockButtonNode?.removeFromSupernode() + strongSelf.unlockButtonNode = unlockButtonNode + if let unlockButtonNode { + strongSelf.clippingNode.addSubnode(unlockButtonNode) + } + } + let absoluteOrigin = unlockButtonPosition.offsetBy(dx: contentOrigin.x, dy: contentOrigin.y) + if let unlockButtonNode { + unlockButtonNodeAnimation.animator.updateFrame(layer: unlockButtonNode.layer, frame: CGRect(origin: CGPoint(x: floor(absoluteOrigin.x - size.width / 2.0), y: floor(absoluteOrigin.y - size.height / 2.0)), size: size), completion: nil) + } + } else if let unlockButtonNode = strongSelf.unlockButtonNode { + strongSelf.unlockButtonNode = nil + unlockButtonNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, removeOnCompletion: false, completion: { _ in + unlockButtonNode.removeFromSupernode() + }) + } + + if let mediaInfoOrigin { + let (size, apply) = mediaInfoSizeAndApply + var unlockButtonNodeAnimation = animation + if strongSelf.unlockButtonNode == nil { + unlockButtonNodeAnimation = .None + } + let mediaInfoNode = apply(strongSelf.mediaInfoNode != nil) + if mediaInfoNode !== strongSelf.mediaInfoNode { + strongSelf.mediaInfoNode?.removeFromSupernode() + strongSelf.mediaInfoNode = mediaInfoNode + if let mediaInfoNode { + strongSelf.clippingNode.addSubnode(mediaInfoNode) + } + } + let absoluteOrigin = mediaInfoOrigin.offsetBy(dx: contentOrigin.x, dy: contentOrigin.y) + if let mediaInfoNode { + unlockButtonNodeAnimation.animator.updateFrame(layer: mediaInfoNode.layer, frame: CGRect(origin: CGPoint(x: absoluteOrigin.x - size.width - 8.0, y: absoluteOrigin.y + 8.0), size: size), completion: nil) + } + } else if let mediaInfoNode = strongSelf.mediaInfoNode { + strongSelf.mediaInfoNode = nil + mediaInfoNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, removeOnCompletion: false, completion: { _ in + mediaInfoNode.removeFromSupernode() + }) + } if needsShareButton { if strongSelf.shareButtonNode == nil { @@ -5157,6 +5281,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } override public func updateHiddenMedia() { + var hasHiddenMediaInfo = false var hasHiddenMosaicStatus = false var hasHiddenBackground = false if let item = self.item { @@ -5169,6 +5294,9 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI if let mosaicStatusNode = self.mosaicStatusNode, mosaicStatusNode.frame.intersects(contentNode.frame) { hasHiddenMosaicStatus = true } + if let mediaInfoNode = self.mediaInfoNode, mediaInfoNode.frame.intersects(contentNode.frame) { + hasHiddenMediaInfo = true + } } } } @@ -5185,6 +5313,17 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } } + if let mediaInfoNode = self.mediaInfoNode { + if mediaInfoNode.alpha.isZero != hasHiddenMediaInfo { + if hasHiddenMediaInfo { + mediaInfoNode.alpha = 0.0 + } else { + mediaInfoNode.alpha = 1.0 + mediaInfoNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) + } + } + } + self.backgroundNode.isHidden = hasHiddenBackground self.backgroundWallpaperNode.isHidden = hasHiddenBackground } diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageInteractiveMediaNode/Sources/ChatMessageInteractiveMediaNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageInteractiveMediaNode/Sources/ChatMessageInteractiveMediaNode.swift index e60de57a6d..1ec0b22b75 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageInteractiveMediaNode/Sources/ChatMessageInteractiveMediaNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageInteractiveMediaNode/Sources/ChatMessageInteractiveMediaNode.swift @@ -2386,7 +2386,7 @@ public final class ChatMessageInteractiveMediaNode: ASDisplayNode, GalleryItemTr var viewText: String = "" if message.isAgeRestricted() { - //TODO: localize + //TODO:localize viewText = "18+ Content" } else { outer: for attribute in message.attributes { @@ -2403,9 +2403,9 @@ public final class ChatMessageInteractiveMediaNode: ASDisplayNode, GalleryItemTr } } } - if let paidContent { - viewText = "⭐️\(paidContent.amount)" - } +// if let paidContent { +// viewText = "⭐️\(paidContent.amount)" +// } self.extendedMediaOverlayNode?.update(size: self.imageNode.frame.size, text: viewText, imageSignal: self.currentBlurredImageSignal, imageFrame: self.imageNode.view.convert(self.imageNode.bounds, to: self.extendedMediaOverlayNode?.view), corners: self.currentImageArguments?.corners) } else if let extendedMediaOverlayNode = self.extendedMediaOverlayNode { self.extendedMediaOverlayNode = nil diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageMediaBubbleContentNode/Sources/ChatMessageMediaBubbleContentNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageMediaBubbleContentNode/Sources/ChatMessageMediaBubbleContentNode.swift index 2cb14f7885..68dccc6c03 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageMediaBubbleContentNode/Sources/ChatMessageMediaBubbleContentNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageMediaBubbleContentNode/Sources/ChatMessageMediaBubbleContentNode.swift @@ -56,7 +56,7 @@ public class ChatMessageMediaBubbleContentNode: ChatMessageBubbleContentNode { case .automaticPlayback: openChatMessageMode = .automaticPlayback } - let _ = item.controllerInteraction.openMessage(item.message, OpenMessageParams(mode: openChatMessageMode)) + let _ = item.controllerInteraction.openMessage(item.message, OpenMessageParams(mode: openChatMessageMode, mediaIndex: self.mediaIndex)) } self.interactiveImageNode.activateAgeRestrictedMedia = { [weak self] in diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageStarsMediaInfoNode/BUILD b/submodules/TelegramUI/Components/Chat/ChatMessageStarsMediaInfoNode/BUILD new file mode 100644 index 0000000000..85588cbecc --- /dev/null +++ b/submodules/TelegramUI/Components/Chat/ChatMessageStarsMediaInfoNode/BUILD @@ -0,0 +1,32 @@ +load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library") + +swift_library( + name = "ChatMessageStarsMediaInfoNode", + module_name = "ChatMessageStarsMediaInfoNode", + srcs = glob([ + "Sources/**/*.swift", + ]), + copts = [ + "-warnings-as-errors", + ], + deps = [ + "//submodules/AsyncDisplayKit", + "//submodules/Postbox", + "//submodules/Display", + "//submodules/TelegramCore", + "//submodules/SSignalKit/SwiftSignalKit", + "//submodules/TelegramPresentationData", + "//submodules/AccountContext", + "//submodules/LocalizedPeerData", + "//submodules/PhotoResources", + "//submodules/TelegramStringFormatting", + "//submodules/TextFormat", + "//submodules/TelegramUI/Components/TextNodeWithEntities", + "//submodules/ComponentFlow", + "//submodules/WallpaperBackgroundNode", + "//submodules/TelegramUI/Components/ChatControllerInteraction", + ], + visibility = [ + "//visibility:public", + ], +) diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageStarsMediaInfoNode/Sources/ChatMessageStarsMediaInfoNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageStarsMediaInfoNode/Sources/ChatMessageStarsMediaInfoNode.swift new file mode 100644 index 0000000000..6293c100a2 --- /dev/null +++ b/submodules/TelegramUI/Components/Chat/ChatMessageStarsMediaInfoNode/Sources/ChatMessageStarsMediaInfoNode.swift @@ -0,0 +1,288 @@ +import Foundation +import UIKit +import AsyncDisplayKit +import Postbox +import Display +import TelegramCore +import SwiftSignalKit +import TelegramPresentationData +import AccountContext +import LocalizedPeerData +import PhotoResources +import TelegramStringFormatting +import TextFormat +import TextNodeWithEntities +import AnimationCache +import MultiAnimationRenderer +import ComponentFlow +import ChatControllerInteraction + +private func generateRectsImage(color: UIColor, rects: [CGRect], inset: CGFloat, outerRadius: CGFloat, innerRadius: CGFloat) -> (CGPoint, UIImage?) { + enum CornerType { + case topLeft + case topRight + case bottomLeft + case bottomRight + } + + func drawFullCorner(context: CGContext, color: UIColor, at point: CGPoint, type: CornerType, radius: CGFloat) { + if radius.isZero { + return + } + context.setFillColor(color.cgColor) + switch type { + case .topLeft: + context.clear(CGRect(origin: point, size: CGSize(width: radius, height: radius))) + context.fillEllipse(in: CGRect(origin: point, size: CGSize(width: radius * 2.0, height: radius * 2.0))) + case .topRight: + context.clear(CGRect(origin: CGPoint(x: point.x - radius, y: point.y), size: CGSize(width: radius, height: radius))) + context.fillEllipse(in: CGRect(origin: CGPoint(x: point.x - radius * 2.0, y: point.y), size: CGSize(width: radius * 2.0, height: radius * 2.0))) + case .bottomLeft: + context.clear(CGRect(origin: CGPoint(x: point.x, y: point.y - radius), size: CGSize(width: radius, height: radius))) + context.fillEllipse(in: CGRect(origin: CGPoint(x: point.x, y: point.y - radius * 2.0), size: CGSize(width: radius * 2.0, height: radius * 2.0))) + case .bottomRight: + context.clear(CGRect(origin: CGPoint(x: point.x - radius, y: point.y - radius), size: CGSize(width: radius, height: radius))) + context.fillEllipse(in: CGRect(origin: CGPoint(x: point.x - radius * 2.0, y: point.y - radius * 2.0), size: CGSize(width: radius * 2.0, height: radius * 2.0))) + } + } + + func drawConnectingCorner(context: CGContext, color: UIColor, at point: CGPoint, type: CornerType, radius: CGFloat) { + context.setFillColor(color.cgColor) + switch type { + case .topLeft: + context.fill(CGRect(origin: CGPoint(x: point.x - radius, y: point.y), size: CGSize(width: radius, height: radius))) + context.setFillColor(UIColor.clear.cgColor) + context.fillEllipse(in: CGRect(origin: CGPoint(x: point.x - radius * 2.0, y: point.y), size: CGSize(width: radius * 2.0, height: radius * 2.0))) + case .topRight: + context.fill(CGRect(origin: CGPoint(x: point.x, y: point.y), size: CGSize(width: radius, height: radius))) + context.setFillColor(UIColor.clear.cgColor) + context.fillEllipse(in: CGRect(origin: CGPoint(x: point.x, y: point.y), size: CGSize(width: radius * 2.0, height: radius * 2.0))) + case .bottomLeft: + context.fill(CGRect(origin: CGPoint(x: point.x - radius, y: point.y - radius), size: CGSize(width: radius, height: radius))) + context.setFillColor(UIColor.clear.cgColor) + context.fillEllipse(in: CGRect(origin: CGPoint(x: point.x - radius * 2.0, y: point.y - radius * 2.0), size: CGSize(width: radius * 2.0, height: radius * 2.0))) + case .bottomRight: + context.fill(CGRect(origin: CGPoint(x: point.x, y: point.y - radius), size: CGSize(width: radius, height: radius))) + context.setFillColor(UIColor.clear.cgColor) + context.fillEllipse(in: CGRect(origin: CGPoint(x: point.x, y: point.y - radius * 2.0), size: CGSize(width: radius * 2.0, height: radius * 2.0))) + } + } + + if rects.isEmpty { + return (CGPoint(), nil) + } + + var topLeft = rects[0].origin + var bottomRight = CGPoint(x: rects[0].maxX, y: rects[0].maxY) + for i in 1 ..< rects.count { + topLeft.x = min(topLeft.x, rects[i].origin.x) + topLeft.y = min(topLeft.y, rects[i].origin.y) + bottomRight.x = max(bottomRight.x, rects[i].maxX) + bottomRight.y = max(bottomRight.y, rects[i].maxY) + } + + topLeft.x -= inset + topLeft.y -= inset + bottomRight.x += inset * 2.0 + bottomRight.y += inset * 2.0 + + return (topLeft, generateImage(CGSize(width: bottomRight.x - topLeft.x, height: bottomRight.y - topLeft.y), rotatedContext: { size, context in + context.clear(CGRect(origin: CGPoint(), size: size)) + context.setFillColor(color.cgColor) + + context.setBlendMode(.copy) + + for i in 0 ..< rects.count { + let rect = rects[i].insetBy(dx: -inset, dy: -inset) + context.fill(rect.offsetBy(dx: -topLeft.x, dy: -topLeft.y)) + } + + for i in 0 ..< rects.count { + let rect = rects[i].insetBy(dx: -inset, dy: -inset).offsetBy(dx: -topLeft.x, dy: -topLeft.y) + + var previous: CGRect? + if i != 0 { + previous = rects[i - 1].insetBy(dx: -inset, dy: -inset).offsetBy(dx: -topLeft.x, dy: -topLeft.y) + } + + var next: CGRect? + if i != rects.count - 1 { + next = rects[i + 1].insetBy(dx: -inset, dy: -inset).offsetBy(dx: -topLeft.x, dy: -topLeft.y) + } + + if let previous = previous { + if previous.contains(rect.topLeft) { + if abs(rect.topLeft.x - previous.minX) >= innerRadius { + var radius = innerRadius + if let next = next { + radius = min(radius, floor((next.minY - previous.maxY) / 2.0)) + } + drawConnectingCorner(context: context, color: color, at: CGPoint(x: rect.topLeft.x, y: previous.maxY), type: .topLeft, radius: radius) + } + } else { + drawFullCorner(context: context, color: color, at: rect.topLeft, type: .topLeft, radius: outerRadius) + } + if previous.contains(rect.topRight.offsetBy(dx: -1.0, dy: 0.0)) { + if abs(rect.topRight.x - previous.maxX) >= innerRadius { + var radius = innerRadius + if let next = next { + radius = min(radius, floor((next.minY - previous.maxY) / 2.0)) + } + drawConnectingCorner(context: context, color: color, at: CGPoint(x: rect.topRight.x, y: previous.maxY), type: .topRight, radius: radius) + } + } else { + drawFullCorner(context: context, color: color, at: rect.topRight, type: .topRight, radius: outerRadius) + } + } else { + drawFullCorner(context: context, color: color, at: rect.topLeft, type: .topLeft, radius: outerRadius) + drawFullCorner(context: context, color: color, at: rect.topRight, type: .topRight, radius: outerRadius) + } + + if let next = next { + if next.contains(rect.bottomLeft) { + if abs(rect.bottomRight.x - next.maxX) >= innerRadius { + var radius = innerRadius + if let previous = previous { + radius = min(radius, floor((next.minY - previous.maxY) / 2.0)) + } + drawConnectingCorner(context: context, color: color, at: CGPoint(x: rect.bottomLeft.x, y: next.minY), type: .bottomLeft, radius: radius) + } + } else { + drawFullCorner(context: context, color: color, at: rect.bottomLeft, type: .bottomLeft, radius: outerRadius) + } + if next.contains(rect.bottomRight.offsetBy(dx: -1.0, dy: 0.0)) { + if abs(rect.bottomRight.x - next.maxX) >= innerRadius { + var radius = innerRadius + if let previous = previous { + radius = min(radius, floor((next.minY - previous.maxY) / 2.0)) + } + drawConnectingCorner(context: context, color: color, at: CGPoint(x: rect.bottomRight.x, y: next.minY), type: .bottomRight, radius: radius) + } + } else { + drawFullCorner(context: context, color: color, at: rect.bottomRight, type: .bottomRight, radius: outerRadius) + } + } else { + drawFullCorner(context: context, color: color, at: rect.bottomLeft, type: .bottomLeft, radius: outerRadius) + drawFullCorner(context: context, color: color, at: rect.bottomRight, type: .bottomRight, radius: outerRadius) + } + } + })) +} + +public enum ChatMessageThreadInfoType { + case bubble(incoming: Bool) + case standalone +} + +public class ChatMessageStarsMediaInfoNode: ASDisplayNode { + public class Arguments { + public let presentationData: ChatPresentationData + public let context: AccountContext + public let message: Message + public let media: TelegramMediaPaidContent + public let constrainedSize: CGSize + public let animationCache: AnimationCache? + public let animationRenderer: MultiAnimationRenderer? + + public init( + presentationData: ChatPresentationData, + context: AccountContext, + message: Message, + media: TelegramMediaPaidContent, + constrainedSize: CGSize, + animationCache: AnimationCache?, + animationRenderer: MultiAnimationRenderer? + ) { + self.presentationData = presentationData + self.context = context + self.message = message + self.media = media + self.constrainedSize = constrainedSize + self.animationCache = animationCache + self.animationRenderer = animationRenderer + } + } + + public var visibility: Bool = false { + didSet { + if self.visibility != oldValue { + self.textNode?.visibilityRect = self.visibility ? CGRect.infinite : nil + } + } + } + + private let contentBackgroundNode: ASImageNode + private var textNode: TextNodeWithEntities? + + override public init() { + self.contentBackgroundNode = ASImageNode() + self.contentBackgroundNode.displaysAsynchronously = false + self.contentBackgroundNode.displayWithoutProcessing = true + self.contentBackgroundNode.isLayerBacked = true + self.contentBackgroundNode.isUserInteractionEnabled = false + + super.init() + + self.addSubnode(self.contentBackgroundNode) + } + + public class func asyncLayout(_ maybeNode: ChatMessageStarsMediaInfoNode?) -> (_ arguments: Arguments) -> (CGSize, (Bool) -> ChatMessageStarsMediaInfoNode) { + let textNodeLayout = TextNodeWithEntities.asyncLayout(maybeNode?.textNode) + + return { arguments in + let fontSize = floor(arguments.presentationData.fontSize.baseDisplaySize * 11.0 / 17.0) + let textFont = Font.regular(fontSize) + + let text: NSMutableAttributedString + if let peer = arguments.message.peers[arguments.message.id.peerId] as? TelegramChannel, peer.flags.contains(.isCreator) || peer.adminRights != nil { + text = NSMutableAttributedString(string: "⭐️\(arguments.media.amount)", font: textFont, textColor: .white) + } else { + text = NSMutableAttributedString(string: "Purchased", font: textFont, textColor: .white) + } + if let range = text.string.range(of: "⭐️") { + text.addAttribute(ChatTextInputAttributes.customEmoji, value: ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: nil, fileId: 0, file: nil, custom: .stars(tinted: true)), range: NSRange(range, in: text.string)) + text.addAttribute(.baselineOffset, value: 2.0, range: NSRange(range, in: text.string)) + } + + let (textLayout, textApply) = textNodeLayout(TextNodeLayoutArguments(attributedString: text, backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: arguments.constrainedSize.width, height: arguments.constrainedSize.height), alignment: .natural, cutout: nil, insets: .zero)) + + let padding: CGFloat = 6.0 + let size = CGSize(width: textLayout.size.width + padding * 2.0, height: 18.0) + + return (size, { attemptSynchronous in + let node: ChatMessageStarsMediaInfoNode + if let maybeNode = maybeNode { + node = maybeNode + } else { + node = ChatMessageStarsMediaInfoNode() + } + + if node.contentBackgroundNode.image == nil { + node.contentBackgroundNode.image = generateStretchableFilledCircleImage(radius: 9.0, color: UIColor(rgb: 0x000000, alpha: 0.3)) + } + + node.textNode?.textNode.displaysAsynchronously = !arguments.presentationData.isPreview + + var textArguments: TextNodeWithEntities.Arguments? + if let cache = arguments.animationCache, let renderer = arguments.animationRenderer { + textArguments = TextNodeWithEntities.Arguments(context: arguments.context, cache: cache, renderer: renderer, placeholderColor: .clear, attemptSynchronous: attemptSynchronous) + } + let textNode = textApply(textArguments) + textNode.visibilityRect = node.visibility ? CGRect.infinite : nil + + if node.textNode == nil { + textNode.textNode.isUserInteractionEnabled = false + node.textNode = textNode + node.addSubnode(textNode.textNode) + } + + node.contentBackgroundNode.frame = CGRect(origin: .zero, size: size) + + let textFrame = CGRect(origin: CGPoint(x: padding, y: floorToScreenPixels((size.height - textLayout.size.height) / 2.0) + UIScreenPixel), size: textLayout.size) + textNode.textNode.frame = textFrame + + return node + }) + } + } +} diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageUnlockMediaNode/BUILD b/submodules/TelegramUI/Components/Chat/ChatMessageUnlockMediaNode/BUILD new file mode 100644 index 0000000000..a031cda0e1 --- /dev/null +++ b/submodules/TelegramUI/Components/Chat/ChatMessageUnlockMediaNode/BUILD @@ -0,0 +1,37 @@ +load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library") + +swift_library( + name = "ChatMessageUnlockMediaNode", + module_name = "ChatMessageUnlockMediaNode", + srcs = glob([ + "Sources/**/*.swift", + ]), + copts = [ + "-warnings-as-errors", + ], + deps = [ + "//submodules/AsyncDisplayKit", + "//submodules/Postbox", + "//submodules/Display", + "//submodules/TelegramCore", + "//submodules/SSignalKit/SwiftSignalKit", + "//submodules/TelegramPresentationData", + "//submodules/AccountContext", + "//submodules/LocalizedPeerData", + "//submodules/PhotoResources", + "//submodules/TelegramStringFormatting", + "//submodules/TextFormat", + "//submodules/InvisibleInkDustNode", + "//submodules/TelegramUI/Components/TextNodeWithEntities", + "//submodules/TelegramUI/Components/AnimationCache", + "//submodules/TelegramUI/Components/MultiAnimationRenderer", + "//submodules/ComponentFlow", + "//submodules/TelegramUI/Components/EmojiStatusComponent", + "//submodules/WallpaperBackgroundNode", + "//submodules/TelegramUI/Components/ChatControllerInteraction", + "//submodules/AvatarNode", + ], + visibility = [ + "//visibility:public", + ], +) diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageUnlockMediaNode/Sources/ChatMessageUnlockMediaNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageUnlockMediaNode/Sources/ChatMessageUnlockMediaNode.swift new file mode 100644 index 0000000000..d43eff65bf --- /dev/null +++ b/submodules/TelegramUI/Components/Chat/ChatMessageUnlockMediaNode/Sources/ChatMessageUnlockMediaNode.swift @@ -0,0 +1,165 @@ +import Foundation +import UIKit +import AsyncDisplayKit +import Postbox +import Display +import TelegramCore +import SwiftSignalKit +import TelegramPresentationData +import AccountContext +import LocalizedPeerData +import PhotoResources +import TelegramStringFormatting +import TextFormat +import TextNodeWithEntities +import AnimationCache +import MultiAnimationRenderer +import ComponentFlow +import ChatControllerInteraction + +public class ChatMessageUnlockMediaNode: ASDisplayNode { + public class Arguments { + public let presentationData: ChatPresentationData + public let strings: PresentationStrings + public let context: AccountContext + public let controllerInteraction: ChatControllerInteraction + public let message: Message + public let media: TelegramMediaPaidContent + public let constrainedSize: CGSize + public let animationCache: AnimationCache? + public let animationRenderer: MultiAnimationRenderer? + + public init( + presentationData: ChatPresentationData, + strings: PresentationStrings, + context: AccountContext, + controllerInteraction: ChatControllerInteraction, + message: Message, + media: TelegramMediaPaidContent, + constrainedSize: CGSize, + animationCache: AnimationCache?, + animationRenderer: MultiAnimationRenderer? + ) { + self.presentationData = presentationData + self.strings = strings + self.context = context + self.controllerInteraction = controllerInteraction + self.message = message + self.media = media + self.constrainedSize = constrainedSize + self.animationCache = animationCache + self.animationRenderer = animationRenderer + } + } + + public var visibility: Bool = false { + didSet { + if self.visibility != oldValue { + self.textNode?.visibilityRect = self.visibility ? CGRect.infinite : nil + } + } + } + + private let contentNode: HighlightTrackingButtonNode + private let backgroundNode: NavigationBackgroundNode + private var textNode: TextNodeWithEntities? + + private var pressed = { } + + private var absolutePosition: (CGRect, CGSize)? + + override public init() { + self.contentNode = HighlightTrackingButtonNode() + + self.backgroundNode = NavigationBackgroundNode(color: UIColor(rgb: 0x000000, alpha: 0.3)) + + super.init() + + self.contentNode.isUserInteractionEnabled = true + + self.addSubnode(self.contentNode) + self.contentNode.addSubnode(self.backgroundNode) + +// self.contentNode.highligthedChanged = { [weak self] highlighted in +// if let strongSelf = self { +// if highlighted, !strongSelf.frame.width.isZero { +// let scale = (strongSelf.frame.width - 10.0) / strongSelf.frame.width +// +// strongSelf.contentNode.layer.animateScale(from: 1.0, to: scale, duration: 0.15, removeOnCompletion: false) +// +// strongSelf.backgroundNode.layer.removeAnimation(forKey: "opacity") +// strongSelf.backgroundNode.alpha = 0.2 +// } else if let presentationLayer = strongSelf.contentNode.layer.presentation() { +// strongSelf.contentNode.layer.animateScale(from: CGFloat((presentationLayer.value(forKeyPath: "transform.scale.y") as? NSNumber)?.floatValue ?? 1.0), to: 1.0, duration: 0.25, removeOnCompletion: false) +// +// strongSelf.backgroundNode.alpha = 1.0 +// strongSelf.backgroundNode.layer.animateAlpha(from: 0.2, to: 1.0, duration: 0.2) +// } +// } +// } + + self.contentNode.addTarget(self, action: #selector(self.buttonPressed), forControlEvents: .touchUpInside) + } + + @objc private func buttonPressed() { + self.pressed() + } + + public class func asyncLayout(_ maybeNode: ChatMessageUnlockMediaNode?) -> (_ arguments: Arguments) -> (CGSize, (Bool) -> ChatMessageUnlockMediaNode) { + let textNodeLayout = TextNodeWithEntities.asyncLayout(maybeNode?.textNode) + + return { arguments in + let fontSize = floor(arguments.presentationData.fontSize.baseDisplaySize * 14.0 / 17.0) + let textFont = Font.medium(fontSize) + + let padding: CGFloat = 10.0 + //TODO:localize + let text = NSMutableAttributedString(string: "Unlock for ⭐️ \(arguments.media.amount)", font: textFont, textColor: .white) + if let range = text.string.range(of: "⭐️") { + text.addAttribute(ChatTextInputAttributes.customEmoji, value: ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: nil, fileId: 0, file: nil, custom: .stars(tinted: false)), range: NSRange(range, in: text.string)) + text.addAttribute(.baselineOffset, value: 0.5, range: NSRange(range, in: text.string)) + } + + let (textLayout, textApply) = textNodeLayout(TextNodeLayoutArguments(attributedString: text, backgroundColor: nil, maximumNumberOfLines: 2, truncationType: .end, constrainedSize: CGSize(width: arguments.constrainedSize.width, height: arguments.constrainedSize.height), alignment: .natural, cutout: nil, insets: .zero)) + + let size = CGSize(width: textLayout.size.width + padding * 2.0, height: 32.0) + + return (size, { attemptSynchronous in + let node: ChatMessageUnlockMediaNode + if let maybeNode = maybeNode { + node = maybeNode + } else { + node = ChatMessageUnlockMediaNode() + } + + node.pressed = { + let _ = arguments.controllerInteraction.openMessage(arguments.message, OpenMessageParams(mode: .default)) + } + + node.textNode?.textNode.displaysAsynchronously = !arguments.presentationData.isPreview + + var textArguments: TextNodeWithEntities.Arguments? + if let cache = arguments.animationCache, let renderer = arguments.animationRenderer { + textArguments = TextNodeWithEntities.Arguments(context: arguments.context, cache: cache, renderer: renderer, placeholderColor: .clear, attemptSynchronous: attemptSynchronous) + } + let textNode = textApply(textArguments) + textNode.visibilityRect = node.visibility ? CGRect.infinite : nil + + if node.textNode == nil { + textNode.textNode.isUserInteractionEnabled = false + node.textNode = textNode + node.contentNode.addSubnode(textNode.textNode) + } + + let textFrame = CGRect(origin: CGPoint(x: padding, y: floorToScreenPixels((size.height - textLayout.size.height) / 2.0)), size: textLayout.size) + textNode.textNode.frame = textFrame + + node.backgroundNode.frame = CGRect(origin: CGPoint(), size: size) + node.backgroundNode.update(size: size, cornerRadius: size.height / 2.0, transition: .immediate) + node.contentNode.frame = CGRect(origin: CGPoint(), size: size) + + return node + }) + } + } +} diff --git a/submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsControllerNode.swift b/submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsControllerNode.swift index 0cae4ca8fc..a67caf2d7e 100644 --- a/submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsControllerNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsControllerNode.swift @@ -230,7 +230,7 @@ final class ChatRecentActionsControllerNode: ViewControllerTracingNode { break } } - let gallerySource = GalleryControllerItemSource.standaloneMessage(message) + let gallerySource = GalleryControllerItemSource.standaloneMessage(message, nil) return context.sharedContext.openChatMessage(OpenChatMessageParams(context: context, chatLocation: nil, chatFilterTag: nil, chatLocationContextHolder: nil, message: message, standalone: true, reverseMessageGalleryOrder: false, navigationController: navigationController, dismissInput: { //self?.chatDisplayNode.dismissInput() }, present: { c, a in diff --git a/submodules/TelegramUI/Components/ChatControllerInteraction/Sources/ChatControllerInteraction.swift b/submodules/TelegramUI/Components/ChatControllerInteraction/Sources/ChatControllerInteraction.swift index 45322ddc2f..bfc7c94301 100644 --- a/submodules/TelegramUI/Components/ChatControllerInteraction/Sources/ChatControllerInteraction.swift +++ b/submodules/TelegramUI/Components/ChatControllerInteraction/Sources/ChatControllerInteraction.swift @@ -109,10 +109,12 @@ public struct NavigateToMessageParams { public struct OpenMessageParams { public var mode: ChatControllerInteractionOpenMessageMode + public var mediaIndex: Int? public var progress: Promise? - public init(mode: ChatControllerInteractionOpenMessageMode, progress: Promise? = nil) { + public init(mode: ChatControllerInteractionOpenMessageMode, mediaIndex: Int? = nil, progress: Promise? = nil) { self.mode = mode + self.mediaIndex = mediaIndex self.progress = progress } } diff --git a/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/Sources/ChatEntityKeyboardInputNode.swift b/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/Sources/ChatEntityKeyboardInputNode.swift index 8bd90fd45c..229fe74a98 100644 --- a/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/Sources/ChatEntityKeyboardInputNode.swift +++ b/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/Sources/ChatEntityKeyboardInputNode.swift @@ -2129,7 +2129,7 @@ public final class ChatEntityKeyboardInputNode: ChatInputNode { let message = Message(stableId: 0, stableVersion: 0, id: MessageId(peerId: PeerId(0), namespace: Namespaces.Message.Local, id: 0), globallyUniqueId: nil, groupingKey: nil, groupInfo: nil, threadId: nil, timestamp: 0, flags: [], tags: [], globalTags: [], localTags: [], customTags: [], forwardInfo: nil, author: nil, text: "", attributes: [], media: [file.media], peers: SimpleDictionary(), associatedMessages: SimpleDictionary(), associatedMessageIds: [], associatedMedia: [:], associatedThreadInfo: nil, associatedStories: [:]) - let gallery = GalleryController(context: strongSelf.context, source: .standaloneMessage(message), streamSingleVideo: true, replaceRootController: { _, _ in + let gallery = GalleryController(context: strongSelf.context, source: .standaloneMessage(message, nil), streamSingleVideo: true, replaceRootController: { _, _ in }, baseNavigationController: nil) gallery.setHintWillBePresentedInPreviewingContext(true) diff --git a/submodules/TelegramUI/Components/Stars/StarsAvatarComponent/BUILD b/submodules/TelegramUI/Components/Stars/StarsAvatarComponent/BUILD new file mode 100644 index 0000000000..4eabbf93fc --- /dev/null +++ b/submodules/TelegramUI/Components/Stars/StarsAvatarComponent/BUILD @@ -0,0 +1,30 @@ +load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library") + +swift_library( + name = "StarsAvatarComponent", + module_name = "StarsAvatarComponent", + 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/TelegramPresentationData", + "//submodules/PhotoResources", + "//submodules/AvatarNode", + "//submodules/AccountContext", + "//submodules/Components/MultilineTextComponent", + "//submodules/Components/BundleIconComponent", + ], + visibility = [ + "//visibility:public", + ], +) diff --git a/submodules/TelegramUI/Components/Stars/StarsAvatarComponent/Sources/StarsAvatarComponent.swift b/submodules/TelegramUI/Components/Stars/StarsAvatarComponent/Sources/StarsAvatarComponent.swift new file mode 100644 index 0000000000..cbcf414ec7 --- /dev/null +++ b/submodules/TelegramUI/Components/Stars/StarsAvatarComponent/Sources/StarsAvatarComponent.swift @@ -0,0 +1,472 @@ +import Foundation +import UIKit +import Display +import SwiftSignalKit +import Postbox +import TelegramCore +import ComponentFlow +import TelegramPresentationData +import PhotoResources +import AvatarNode +import AccountContext +import BundleIconComponent +import MultilineTextComponent + +public final class StarsAvatarComponent: Component { + let context: AccountContext + let theme: PresentationTheme + let peer: StarsContext.State.Transaction.Peer + let photo: TelegramMediaWebFile? + let media: [Media] + + public init(context: AccountContext, theme: PresentationTheme, peer: StarsContext.State.Transaction.Peer, photo: TelegramMediaWebFile?, media: [Media]) { + self.context = context + self.theme = theme + self.peer = peer + self.photo = photo + self.media = media + } + + public static func ==(lhs: StarsAvatarComponent, rhs: StarsAvatarComponent) -> Bool { + if lhs.context !== rhs.context { + return false + } + if lhs.theme !== rhs.theme { + return false + } + if lhs.peer != rhs.peer { + return false + } + if lhs.photo != rhs.photo { + return false + } + if !areMediaArraysEqual(lhs.media, rhs.media) { + return false + } + return true + } + + public final class View: UIView { + private let avatarNode: AvatarNode + private let backgroundView = UIImageView() + private let iconView = UIImageView() + private var imageNode: TransformImageNode? + + private let fetchDisposable = MetaDisposable() + + private var component: StarsAvatarComponent? + private weak var state: EmptyComponentState? + + override init(frame: CGRect) { + self.avatarNode = AvatarNode(font: avatarPlaceholderFont(size: 16.0)) + + super.init(frame: frame) + + self.iconView.contentMode = .scaleAspectFit + + self.addSubnode(self.avatarNode) + self.addSubview(self.backgroundView) + self.addSubview(self.iconView) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + self.fetchDisposable.dispose() + } + + func update(component: StarsAvatarComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + self.component = component + self.state = state + + let size = CGSize(width: 40.0, height: 40.0) + var iconInset: CGFloat = 3.0 + var iconOffset: CGFloat = 0.0 + + var dimensions = size + + switch component.peer { + case let .peer(peer): + if !component.media.isEmpty { + let imageNode: TransformImageNode + if let current = self.imageNode { + imageNode = current + } else { + imageNode = TransformImageNode() + imageNode.contentAnimations = [.subsequentUpdates] + self.addSubview(imageNode.view) + self.imageNode = imageNode + + if let image = component.media.first as? TelegramMediaImage { + if let imageDimensions = largestImageRepresentation(image.representations)?.dimensions { + dimensions = imageDimensions.cgSize.aspectFilled(size) + } + imageNode.setSignal(chatMessagePhotoThumbnail(account: component.context.account, userLocation: .other, photoReference: .standalone(media: image), onlyFullSize: false, blurred: false)) + self.fetchDisposable.set(chatMessagePhotoInteractiveFetched(context: component.context, userLocation: .other, photoReference: .standalone(media: image), displayAtSize: nil, storeToDownloadsPeerId: nil).startStrict()) + } else if let file = component.media.first as? TelegramMediaFile { + if let videoDimensions = file.dimensions { + dimensions = videoDimensions.cgSize.aspectFilled(size) + } + imageNode.setSignal(mediaGridMessageVideo(postbox: component.context.account.postbox, userLocation: .other, videoReference: .standalone(media: file), autoFetchFullSizeThumbnail: true)) + } + } + + imageNode.frame = CGRect(origin: .zero, size: size) + imageNode.asyncLayout()(TransformImageArguments(corners: ImageCorners(radius: 8.0), imageSize: dimensions, boundingSize: size, intrinsicInsets: UIEdgeInsets(), emptyColor: component.theme.list.mediaPlaceholderColor))() + + self.backgroundView.isHidden = true + self.iconView.isHidden = true + self.avatarNode.isHidden = true + } else if let photo = component.photo { + let imageNode: TransformImageNode + if let current = self.imageNode { + imageNode = current + } else { + imageNode = TransformImageNode() + imageNode.contentAnimations = [.subsequentUpdates] + self.addSubview(imageNode.view) + self.imageNode = imageNode + + imageNode.setSignal(chatWebFileImage(account: component.context.account, file: photo)) + self.fetchDisposable.set(chatMessageWebFileInteractiveFetched(account: component.context.account, userLocation: .other, image: photo).startStrict()) + } + + imageNode.frame = CGRect(origin: .zero, size: size) + imageNode.asyncLayout()(TransformImageArguments(corners: ImageCorners(radius: 8.0), imageSize: size, boundingSize: size, intrinsicInsets: UIEdgeInsets(), emptyColor: component.theme.list.mediaPlaceholderColor))() + + self.backgroundView.isHidden = true + self.iconView.isHidden = true + self.avatarNode.isHidden = true + } else { + self.avatarNode.setPeer( + context: component.context, + theme: component.theme, + peer: peer, + synchronousLoad: true + ) + self.backgroundView.isHidden = true + self.iconView.isHidden = true + self.avatarNode.isHidden = false + } + case .appStore: + self.backgroundView.image = generateGradientFilledCircleImage( + diameter: size.width, + colors: [ + UIColor(rgb: 0x2a9ef1).cgColor, + UIColor(rgb: 0x72d5fd).cgColor + ], + direction: .mirroredDiagonal + ) + self.backgroundView.isHidden = false + self.iconView.isHidden = false + self.avatarNode.isHidden = true + self.iconView.image = UIImage(bundleImageName: "Premium/Stars/Apple") + case .playMarket: + self.backgroundView.image = generateGradientFilledCircleImage( + diameter: size.width, + colors: [ + UIColor(rgb: 0x54cb68).cgColor, + UIColor(rgb: 0xa0de7e).cgColor + ], + direction: .mirroredDiagonal + ) + self.backgroundView.isHidden = false + self.iconView.isHidden = false + self.avatarNode.isHidden = true + self.iconView.image = UIImage(bundleImageName: "Premium/Stars/Google") + case .fragment: + self.backgroundView.image = generateFilledCircleImage(diameter: size.width, color: UIColor(rgb: 0x1b1f24)) + self.backgroundView.isHidden = false + self.iconView.isHidden = false + self.avatarNode.isHidden = true + self.iconView.image = UIImage(bundleImageName: "Premium/Stars/Fragment") + iconOffset = 2.0 + case .premiumBot: + iconInset = 7.0 + self.backgroundView.image = generateGradientFilledCircleImage( + diameter: size.width, + colors: [ + UIColor(rgb: 0x6b93ff).cgColor, + UIColor(rgb: 0x6b93ff).cgColor, + UIColor(rgb: 0x8d77ff).cgColor, + UIColor(rgb: 0xb56eec).cgColor, + UIColor(rgb: 0xb56eec).cgColor + ], + direction: .mirroredDiagonal + ) + self.backgroundView.isHidden = false + self.iconView.isHidden = false + self.avatarNode.isHidden = true + self.iconView.image = generateTintedImage(image: UIImage(bundleImageName: "Chat/Input/Media/EntityInputPremiumIcon"), color: .white) + case .unsupported: + iconInset = 7.0 + self.backgroundView.image = generateGradientFilledCircleImage( + diameter: size.width, + colors: [ + UIColor(rgb: 0xb1b1b1).cgColor, + UIColor(rgb: 0xcdcdcd).cgColor + ], + direction: .mirroredDiagonal + ) + self.backgroundView.isHidden = false + self.iconView.isHidden = false + self.avatarNode.isHidden = true + self.iconView.image = generateTintedImage(image: UIImage(bundleImageName: "Chat/Input/Media/EntityInputPremiumIcon"), color: .white) + } + + self.avatarNode.frame = CGRect(origin: .zero, size: size) + self.iconView.frame = CGRect(origin: .zero, size: size).insetBy(dx: iconInset, dy: iconInset).offsetBy(dx: 0.0, dy: iconOffset) + self.backgroundView.frame = CGRect(origin: .zero, size: size) + + return size + } + } + + 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) + } +} + +public final class StarsLabelComponent: CombinedComponent { + let text: NSAttributedString + + public init( + text: NSAttributedString + ) { + self.text = text + } + + public static func ==(lhs: StarsLabelComponent, rhs: StarsLabelComponent) -> Bool { + if lhs.text != rhs.text { + return false + } + return true + } + + public static var body: Body { + let text = Child(MultilineTextComponent.self) + let icon = Child(BundleIconComponent.self) + + return { context in + let component = context.component + + let text = text.update( + component: MultilineTextComponent(text: .plain(component.text)), + availableSize: CGSize(width: 100.0, height: 40.0), + transition: context.transition + ) + + let iconSize = CGSize(width: 20.0, height: 20.0) + let icon = icon.update( + component: BundleIconComponent( + name: "Premium/Stars/StarLarge", + tintColor: nil + ), + availableSize: iconSize, + transition: context.transition + ) + + let spacing: CGFloat = 3.0 + let totalWidth = text.size.width + spacing + iconSize.width + let size = CGSize(width: totalWidth, height: iconSize.height) + + context.add(text + .position(CGPoint(x: text.size.width / 2.0, y: size.height / 2.0)) + ) + context.add(icon + .position(CGPoint(x: totalWidth - iconSize.width / 2.0, y: size.height / 2.0 - UIScreenPixel)) + ) + return size + } + } +} + +public final class StarsMediaComponent: Component { + let context: AccountContext + let theme: PresentationTheme + let peer: StarsContext.State.Transaction.Peer + let photo: TelegramMediaWebFile? + + public init(context: AccountContext, theme: PresentationTheme, peer: StarsContext.State.Transaction.Peer, photo: TelegramMediaWebFile?) { + self.context = context + self.theme = theme + self.peer = peer + self.photo = photo + } + + public static func ==(lhs: StarsMediaComponent, rhs: StarsMediaComponent) -> Bool { + if lhs.context !== rhs.context { + return false + } + if lhs.theme !== rhs.theme { + return false + } + if lhs.peer != rhs.peer { + return false + } + if lhs.photo != rhs.photo { + return false + } + return true + } + + public final class View: UIView { + private let avatarNode: AvatarNode + private let backgroundView = UIImageView() + private let iconView = UIImageView() + private var imageNode: TransformImageNode? + + private let fetchDisposable = MetaDisposable() + + private var component: StarsMediaComponent? + private weak var state: EmptyComponentState? + + override init(frame: CGRect) { + self.avatarNode = AvatarNode(font: avatarPlaceholderFont(size: 16.0)) + + super.init(frame: frame) + + self.iconView.contentMode = .scaleAspectFit + + self.addSubnode(self.avatarNode) + self.addSubview(self.backgroundView) + self.addSubview(self.iconView) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + self.fetchDisposable.dispose() + } + + func update(component: StarsMediaComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + self.component = component + self.state = state + + let size = CGSize(width: 40.0, height: 40.0) + var iconInset: CGFloat = 3.0 + var iconOffset: CGFloat = 0.0 + + switch component.peer { + case let .peer(peer): + if let photo = component.photo { + let imageNode: TransformImageNode + if let current = self.imageNode { + imageNode = current + } else { + imageNode = TransformImageNode() + imageNode.contentAnimations = [.subsequentUpdates] + self.addSubview(imageNode.view) + self.imageNode = imageNode + + imageNode.setSignal(chatWebFileImage(account: component.context.account, file: photo)) + self.fetchDisposable.set(chatMessageWebFileInteractiveFetched(account: component.context.account, userLocation: .other, image: photo).startStrict()) + } + + imageNode.frame = CGRect(origin: .zero, size: size) + imageNode.asyncLayout()(TransformImageArguments(corners: ImageCorners(radius: size.width / 2.0), imageSize: size, boundingSize: size, intrinsicInsets: UIEdgeInsets(), emptyColor: component.theme.list.mediaPlaceholderColor))() + + self.backgroundView.isHidden = true + self.iconView.isHidden = true + self.avatarNode.isHidden = true + } else { + self.avatarNode.setPeer( + context: component.context, + theme: component.theme, + peer: peer, + synchronousLoad: true + ) + self.backgroundView.isHidden = true + self.iconView.isHidden = true + self.avatarNode.isHidden = false + } + case .appStore: + self.backgroundView.image = generateGradientFilledCircleImage( + diameter: size.width, + colors: [ + UIColor(rgb: 0x2a9ef1).cgColor, + UIColor(rgb: 0x72d5fd).cgColor + ], + direction: .mirroredDiagonal + ) + self.backgroundView.isHidden = false + self.iconView.isHidden = false + self.avatarNode.isHidden = true + self.iconView.image = UIImage(bundleImageName: "Premium/Stars/Apple") + case .playMarket: + self.backgroundView.image = generateGradientFilledCircleImage( + diameter: size.width, + colors: [ + UIColor(rgb: 0x54cb68).cgColor, + UIColor(rgb: 0xa0de7e).cgColor + ], + direction: .mirroredDiagonal + ) + self.backgroundView.isHidden = false + self.iconView.isHidden = false + self.avatarNode.isHidden = true + self.iconView.image = UIImage(bundleImageName: "Premium/Stars/Google") + case .fragment: + self.backgroundView.image = generateFilledCircleImage(diameter: size.width, color: UIColor(rgb: 0x1b1f24)) + self.backgroundView.isHidden = false + self.iconView.isHidden = false + self.avatarNode.isHidden = true + self.iconView.image = UIImage(bundleImageName: "Premium/Stars/Fragment") + iconOffset = 2.0 + case .premiumBot: + iconInset = 7.0 + self.backgroundView.image = generateGradientFilledCircleImage( + diameter: size.width, + colors: [ + UIColor(rgb: 0x6b93ff).cgColor, + UIColor(rgb: 0x6b93ff).cgColor, + UIColor(rgb: 0x8d77ff).cgColor, + UIColor(rgb: 0xb56eec).cgColor, + UIColor(rgb: 0xb56eec).cgColor + ], + direction: .mirroredDiagonal + ) + self.backgroundView.isHidden = false + self.iconView.isHidden = false + self.avatarNode.isHidden = true + self.iconView.image = generateTintedImage(image: UIImage(bundleImageName: "Chat/Input/Media/EntityInputPremiumIcon"), color: .white) + case .unsupported: + iconInset = 7.0 + self.backgroundView.image = generateGradientFilledCircleImage( + diameter: size.width, + colors: [ + UIColor(rgb: 0xb1b1b1).cgColor, + UIColor(rgb: 0xcdcdcd).cgColor + ], + direction: .mirroredDiagonal + ) + self.backgroundView.isHidden = false + self.iconView.isHidden = false + self.avatarNode.isHidden = true + self.iconView.image = generateTintedImage(image: UIImage(bundleImageName: "Chat/Input/Media/EntityInputPremiumIcon"), color: .white) + } + + self.avatarNode.frame = CGRect(origin: .zero, size: size) + self.iconView.frame = CGRect(origin: .zero, size: size).insetBy(dx: iconInset, dy: iconInset).offsetBy(dx: 0.0, dy: iconOffset) + self.backgroundView.frame = CGRect(origin: .zero, size: size) + + return size + } + } + + 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) + } +} diff --git a/submodules/TelegramUI/Components/Stars/StarsImageComponent/Sources/StarsImageComponent.swift b/submodules/TelegramUI/Components/Stars/StarsImageComponent/Sources/StarsImageComponent.swift index 367cf39620..26b94ef3a7 100644 --- a/submodules/TelegramUI/Components/Stars/StarsImageComponent/Sources/StarsImageComponent.swift +++ b/submodules/TelegramUI/Components/Stars/StarsImageComponent/Sources/StarsImageComponent.swift @@ -247,25 +247,64 @@ public final class StarsImageComponent: Component { public enum Subject: Equatable { case none case photo(TelegramMediaWebFile) - case extendedMedia(TelegramExtendedMedia) + case media([Media]) + case extendedMedia([TelegramExtendedMedia]) case transactionPeer(StarsContext.State.Transaction.Peer) + + public static func == (lhs: StarsImageComponent.Subject, rhs: StarsImageComponent.Subject) -> Bool { + switch lhs { + case .none: + if case .none = rhs { + return true + } else { + return false + } + case let .photo(lhsPhoto): + if case let .photo(rhsPhoto) = rhs, lhsPhoto == rhsPhoto { + return true + } else { + return false + } + case let .media(lhsMedia): + if case let .media(rhsMedia) = rhs, areMediaArraysEqual(lhsMedia, rhsMedia) { + return true + } else { + return false + } + case let .extendedMedia(lhsExtendedMedia): + if case let .extendedMedia(rhsExtendedMedia) = rhs, lhsExtendedMedia == rhsExtendedMedia { + return true + } else { + return false + } + case let .transactionPeer(lhsPeer): + if case let .transactionPeer(rhsPeer) = rhs, lhsPeer == rhsPeer { + return true + } else { + return false + } + } + } } public let context: AccountContext public let subject: Subject public let theme: PresentationTheme public let diameter: CGFloat + public let backgroundColor: UIColor public init( context: AccountContext, subject: Subject, theme: PresentationTheme, - diameter: CGFloat + diameter: CGFloat, + backgroundColor: UIColor ) { self.context = context self.subject = subject self.theme = theme self.diameter = diameter + self.backgroundColor = backgroundColor } public static func ==(lhs: StarsImageComponent, rhs: StarsImageComponent) -> Bool { @@ -275,9 +314,15 @@ public final class StarsImageComponent: Component { if lhs.subject != rhs.subject { return false } + if lhs.theme !== rhs.theme { + return false + } if lhs.diameter != rhs.diameter { return false } + if lhs.backgroundColor != rhs.backgroundColor { + return false + } return true } @@ -288,11 +333,15 @@ public final class StarsImageComponent: Component { private var largeParticlesView: StarsParticlesView? private var imageNode: TransformImageNode? + private var imageFrameNode: UIView? + private var secondImageNode: TransformImageNode? private var avatarNode: ImageNode? private var iconBackgroundView: UIImageView? private var iconView: UIImageView? private var dustNode: MediaDustNode? + private var countView = ComponentView() + private let fetchDisposable = MetaDisposable() public override init(frame: CGRect) { @@ -355,6 +404,77 @@ public final class StarsImageComponent: Component { imageNode.frame = imageFrame imageNode.asyncLayout()(TransformImageArguments(corners: ImageCorners(radius: imageSize.width / 2.0), imageSize: imageSize, boundingSize: imageSize, intrinsicInsets: UIEdgeInsets(), emptyColor: component.theme.list.mediaPlaceholderColor))() + case let .media(media): + let imageNode: TransformImageNode + var dimensions = imageSize + if let current = self.imageNode { + imageNode = current + } else { + imageNode = TransformImageNode() + imageNode.contentAnimations = [.firstUpdate, .subsequentUpdates] + self.addSubview(imageNode.view) + self.imageNode = imageNode + + if let image = media.first as? TelegramMediaImage { + if let imageDimensions = largestImageRepresentation(image.representations)?.dimensions { + dimensions = imageDimensions.cgSize.aspectFilled(imageSize) + } + imageNode.setSignal(chatMessagePhotoThumbnail(account: component.context.account, userLocation: .other, photoReference: .standalone(media: image), onlyFullSize: false, blurred: false)) + } else if let file = media.first as? TelegramMediaFile { + if let videoDimensions = file.dimensions { + dimensions = videoDimensions.cgSize.aspectFilled(imageSize) + } + imageNode.setSignal(mediaGridMessageVideo(postbox: component.context.account.postbox, userLocation: .other, videoReference: .standalone(media: file), useLargeThumbnail: true, autoFetchFullSizeThumbnail: true)) + } + } + imageNode.frame = imageFrame + imageNode.asyncLayout()(TransformImageArguments(corners: ImageCorners(radius: 16.0), imageSize: dimensions, boundingSize: imageSize, intrinsicInsets: UIEdgeInsets(), emptyColor: component.theme.list.mediaPlaceholderColor))() + + if media.count > 1 { + let secondImageNode: TransformImageNode + let imageFrameNode: UIView + if let current = self.secondImageNode, let currentFrame = self.imageFrameNode { + secondImageNode = current + imageFrameNode = currentFrame + } else { + secondImageNode = TransformImageNode() + secondImageNode.contentAnimations = [.firstUpdate, .subsequentUpdates] + self.insertSubview(secondImageNode.view, belowSubview: imageNode.view) + self.secondImageNode = secondImageNode + + imageFrameNode = UIView() + imageFrameNode.layer.cornerRadius = 17.0 + self.insertSubview(imageFrameNode, belowSubview: imageNode.view) + self.imageFrameNode = imageFrameNode + + if let image = media[1] as? TelegramMediaImage { + if let imageDimensions = largestImageRepresentation(image.representations)?.dimensions { + dimensions = imageDimensions.cgSize.aspectFilled(imageSize) + } + secondImageNode.setSignal(chatMessagePhotoThumbnail(account: component.context.account, userLocation: .other, photoReference: .standalone(media: image), onlyFullSize: false, blurred: false)) + } + } + imageFrameNode.backgroundColor = component.backgroundColor + secondImageNode.asyncLayout()(TransformImageArguments(corners: ImageCorners(radius: 16.0), imageSize: imageSize, boundingSize: imageSize, intrinsicInsets: UIEdgeInsets(), emptyColor: component.theme.list.mediaPlaceholderColor))() + secondImageNode.frame = imageFrame.offsetBy(dx: 6.0, dy: -6.0) + imageFrameNode.frame = imageFrame.insetBy(dx: -2.0, dy: -2.0) + + let countSize = self.countView.update( + transition: .immediate, + component: AnyComponent( + Text(text: "\(media.count)", font: Font.with(size: 30.0, design: .round, weight: .medium), color: .white) + ), + environment: {}, + containerSize: imageFrame.size + ) + let countFrame = CGRect(origin: CGPoint(x: imageFrame.minX + floorToScreenPixels((imageFrame.width - countSize.width) / 2.0), y: imageFrame.minY + floorToScreenPixels((imageFrame.height - countSize.height) / 2.0)), size: countSize) + if let countView = self.countView.view { + if countView.superview == nil { + self.addSubview(countView) + } + countView.frame = countFrame + } + } case let .extendedMedia(extendedMedia): let imageNode: TransformImageNode let dustNode: MediaDustNode @@ -368,12 +488,12 @@ public final class StarsImageComponent: Component { self.imageNode = imageNode let media: TelegramMediaImage - switch extendedMedia { + switch extendedMedia.first { case let .preview(_, immediateThumbnailData, _): let thumbnailMedia = TelegramMediaImage(imageId: MediaId(namespace: 0, id: 0), representations: [], immediateThumbnailData: immediateThumbnailData, reference: nil, partialReference: nil, flags: []) media = thumbnailMedia - case let .full(fullMedia): - media = fullMedia as! TelegramMediaImage + default: + fatalError() } imageNode.setSignal(chatSecretPhoto(account: component.context.account, userLocation: .other, photoReference: .standalone(media: media), ignoreFullSize: true, synchronousLoad: true)) @@ -383,11 +503,63 @@ public final class StarsImageComponent: Component { self.dustNode = dustNode } + if extendedMedia.count > 1 { + let secondImageNode: TransformImageNode + let imageFrameNode: UIView + if let current = self.secondImageNode, let currentFrame = self.imageFrameNode { + secondImageNode = current + imageFrameNode = currentFrame + } else { + secondImageNode = TransformImageNode() + secondImageNode.contentAnimations = [.firstUpdate, .subsequentUpdates] + self.insertSubview(secondImageNode.view, belowSubview: imageNode.view) + self.secondImageNode = secondImageNode + + imageFrameNode = UIView() + imageFrameNode.layer.cornerRadius = 17.0 + self.insertSubview(imageFrameNode, belowSubview: imageNode.view) + self.imageFrameNode = imageFrameNode + + let media: TelegramMediaImage + switch extendedMedia[1] { + case let .preview(_, immediateThumbnailData, _): + let thumbnailMedia = TelegramMediaImage(imageId: MediaId(namespace: 0, id: 0), representations: [], immediateThumbnailData: immediateThumbnailData, reference: nil, partialReference: nil, flags: []) + media = thumbnailMedia + default: + fatalError() + } + + secondImageNode.setSignal(chatSecretPhoto(account: component.context.account, userLocation: .other, photoReference: .standalone(media: media), ignoreFullSize: true, synchronousLoad: true)) + } + imageFrameNode.backgroundColor = component.backgroundColor + secondImageNode.asyncLayout()(TransformImageArguments(corners: ImageCorners(radius: 16.0), imageSize: imageSize, boundingSize: imageSize, intrinsicInsets: UIEdgeInsets(), emptyColor: component.theme.list.mediaPlaceholderColor))() + secondImageNode.frame = imageFrame.offsetBy(dx: 6.0, dy: -6.0) + imageFrameNode.frame = imageFrame.insetBy(dx: -2.0, dy: -2.0) + } + imageNode.frame = imageFrame - imageNode.asyncLayout()(TransformImageArguments(corners: ImageCorners(radius: 12.0), imageSize: imageSize, boundingSize: imageSize, intrinsicInsets: UIEdgeInsets(), emptyColor: component.theme.list.mediaPlaceholderColor))() + imageNode.asyncLayout()(TransformImageArguments(corners: ImageCorners(radius: 16.0), imageSize: imageSize, boundingSize: imageSize, intrinsicInsets: UIEdgeInsets(), emptyColor: component.theme.list.mediaPlaceholderColor))() dustNode.frame = imageFrame dustNode.update(size: imageFrame.size, color: .white, transition: .immediate) + + if extendedMedia.count > 1 { + let countSize = self.countView.update( + transition: .immediate, + component: AnyComponent( + Text(text: "\(extendedMedia.count)", font: Font.with(size: 30.0, design: .round, weight: .medium), color: .white) + ), + environment: {}, + containerSize: imageFrame.size + ) + let countFrame = CGRect(origin: CGPoint(x: imageFrame.minX + floorToScreenPixels((imageFrame.width - countSize.width) / 2.0), y: imageFrame.minY + floorToScreenPixels((imageFrame.height - countSize.height) / 2.0)), size: countSize) + if let countView = self.countView.view { + if countView.superview == nil { + self.addSubview(countView) + } + countView.frame = countFrame + } + } case let .transactionPeer(peer): if case let .peer(peer) = peer { let avatarNode: ImageNode diff --git a/submodules/TelegramUI/Components/Stars/StarsTransactionScreen/BUILD b/submodules/TelegramUI/Components/Stars/StarsTransactionScreen/BUILD new file mode 100644 index 0000000000..8a80a98499 --- /dev/null +++ b/submodules/TelegramUI/Components/Stars/StarsTransactionScreen/BUILD @@ -0,0 +1,39 @@ +load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library") + +swift_library( + name = "StarsTransactionScreen", + module_name = "StarsTransactionScreen", + 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/TelegramStringFormatting", + "//submodules/PresentationDataUtils", + "//submodules/Components/SheetComponent", + "//submodules/UndoUI", + "//submodules/TextFormat", + "//submodules/Components/BundleIconComponent", + "//submodules/Components/SolidRoundedButtonComponent", + "//submodules/AvatarNode", + "//submodules/TelegramUI/Components/Stars/StarsImageComponent", + ], + visibility = [ + "//visibility:public", + ], +) diff --git a/submodules/TelegramUI/Components/Stars/StarsTransactionsScreen/Sources/StarsTransactionScreen.swift b/submodules/TelegramUI/Components/Stars/StarsTransactionScreen/Sources/StarsTransactionScreen.swift similarity index 88% rename from submodules/TelegramUI/Components/Stars/StarsTransactionsScreen/Sources/StarsTransactionScreen.swift rename to submodules/TelegramUI/Components/Stars/StarsTransactionScreen/Sources/StarsTransactionScreen.swift index 641757c5fd..56159cb40b 100644 --- a/submodules/TelegramUI/Components/Stars/StarsTransactionsScreen/Sources/StarsTransactionScreen.swift +++ b/submodules/TelegramUI/Components/Stars/StarsTransactionScreen/Sources/StarsTransactionScreen.swift @@ -2,6 +2,7 @@ import Foundation import UIKit import Display import AsyncDisplayKit +import Postbox import TelegramCore import SwiftSignalKit import AccountContext @@ -29,6 +30,7 @@ private final class StarsTransactionSheetContent: CombinedComponent { let action: () -> Void let cancel: (Bool) -> Void let openPeer: (EnginePeer) -> Void + let openMessage: (EngineMessage.Id) -> Void let copyTransactionId: () -> Void init( @@ -37,6 +39,7 @@ private final class StarsTransactionSheetContent: CombinedComponent { action: @escaping () -> Void, cancel: @escaping (Bool) -> Void, openPeer: @escaping (EnginePeer) -> Void, + openMessage: @escaping (EngineMessage.Id) -> Void, copyTransactionId: @escaping () -> Void ) { self.context = context @@ -44,6 +47,7 @@ private final class StarsTransactionSheetContent: CombinedComponent { self.action = action self.cancel = cancel self.openPeer = openPeer + self.openMessage = openMessage self.copyTransactionId = copyTransactionId } @@ -172,17 +176,23 @@ private final class StarsTransactionSheetContent: CombinedComponent { let transactionId: String? let date: Int32 let via: String? + let messageId: EngineMessage.Id? let toPeer: EnginePeer? let transactionPeer: StarsContext.State.Transaction.Peer? + let media: [Media] let photo: TelegramMediaWebFile? let isRefund: Bool var delayedCloseOnOpenPeer = true switch subject { - case let .transaction(transaction, isAccount): + case let .transaction(transaction, parentPeer): switch transaction.peer { case let .peer(peer): - titleText = transaction.title ?? peer.compactDisplayTitle + if !transaction.media.isEmpty { + titleText = strings.Stars_Transaction_MediaPurchase + } else { + titleText = transaction.title ?? peer.compactDisplayTitle + } via = nil case .appStore: titleText = strings.Stars_Transaction_AppleTopUp_Title @@ -194,7 +204,7 @@ private final class StarsTransactionSheetContent: CombinedComponent { titleText = strings.Stars_Transaction_PremiumBotTopUp_Title via = strings.Stars_Transaction_PremiumBotTopUp_Subtitle case .fragment: - if isAccount { + if parentPeer.id == component.context.account.peerId { titleText = strings.Stars_Transaction_FragmentTopUp_Title via = strings.Stars_Transaction_FragmentTopUp_Subtitle } else { @@ -205,7 +215,49 @@ private final class StarsTransactionSheetContent: CombinedComponent { titleText = strings.Stars_Transaction_Unsupported_Title via = nil } - descriptionText = transaction.description ?? "" + if !transaction.media.isEmpty { + var description: String = "" + var photoCount: Int = 0 + var videoCount: Int = 0 + for media in transaction.media { + if let _ = media as? TelegramMediaFile { + videoCount += 1 + } else { + photoCount += 1 + } + } + //TODO:localize + if photoCount > 0 && videoCount > 0 { + if photoCount > 1 { + description += "\(photoCount) photos**" + } else { + description += "**\(photoCount) photo**" + } + description += " and " + if videoCount > 1 { + description += "**\(videoCount) videos**" + } else { + description += "**\(videoCount) video**" + } + } else if photoCount > 0 { + if photoCount > 1 { + description += "**\(photoCount) photos**" + } else { + description += "**Photo**" + } + } else if videoCount > 0 { + if videoCount > 1 { + description += "**\(videoCount) videos**" + } else { + description += "**Video**" + } + } + descriptionText = description.replacingOccurrences(of: "**", with: "") + } else { + descriptionText = transaction.description ?? "" + } + + messageId = transaction.paidMessageId count = transaction.count transactionId = transaction.id @@ -216,6 +268,7 @@ private final class StarsTransactionSheetContent: CombinedComponent { toPeer = nil } transactionPeer = transaction.peer + media = transaction.media photo = transaction.photo isRefund = transaction.flags.contains(.isRefund) case let .receipt(receipt): @@ -223,6 +276,7 @@ private final class StarsTransactionSheetContent: CombinedComponent { descriptionText = receipt.invoiceMedia.description count = (receipt.invoice.prices.first?.amount ?? receipt.invoiceMedia.totalAmount) * -1 via = nil + messageId = nil transactionId = receipt.transactionId date = receipt.date if let peer = state.peerMap[receipt.botPaymentId] { @@ -231,6 +285,7 @@ private final class StarsTransactionSheetContent: CombinedComponent { toPeer = nil } transactionPeer = nil + media = [] photo = receipt.invoiceMedia.photo isRefund = false delayedCloseOnOpenPeer = false @@ -261,7 +316,9 @@ private final class StarsTransactionSheetContent: CombinedComponent { ) let imageSubject: StarsImageComponent.Subject - if let photo { + if !media.isEmpty { + imageSubject = .media(media) + } else if let photo { imageSubject = .photo(photo) } else if let transactionPeer { imageSubject = .transactionPeer(transactionPeer) @@ -275,7 +332,8 @@ private final class StarsTransactionSheetContent: CombinedComponent { context: component.context, subject: imageSubject, theme: theme, - diameter: 90.0 + diameter: 90.0, + backgroundColor: theme.actionSheet.opaqueItemBackgroundColor ), availableSize: CGSize(width: context.availableSize.width, height: 200.0), transition: .immediate @@ -345,6 +403,41 @@ private final class StarsTransactionSheetContent: CombinedComponent { ) )) } + + if let messageId { + //TODO:localize + let peerName: String + if case let .transaction(_, parentPeer) = component.subject { + if parentPeer.id == component.context.account.peerId { + if let toPeer { + peerName = toPeer.addressName ?? "c/\(toPeer.id.id._internalGetInt64Value())" + } else { + peerName = "" + } + } else { + peerName = parentPeer.addressName ?? "c/\(parentPeer.id.id._internalGetInt64Value())" + } + } else { + peerName = "" + } + tableItems.append(.init( + id: "via", + title: strings.Stars_Transaction_Media, + component: AnyComponent( + Button( + content: AnyComponent( + MultilineTextComponent(text: .plain(NSAttributedString(string: "t.me/\(peerName)/\(messageId.id)", font: tableFont, textColor: tableLinkColor))) + ), + action: { + component.openMessage(messageId) + Queue.mainQueue().after(1.0, { + component.cancel(false) + }) + } + ) + ) + )) + } if let transactionId { tableItems.append(.init( @@ -544,6 +637,7 @@ private final class StarsTransactionSheetComponent: CombinedComponent { let subject: StarsTransactionScreen.Subject let action: () -> Void let openPeer: (EnginePeer) -> Void + let openMessage: (EngineMessage.Id) -> Void let copyTransactionId: () -> Void init( @@ -551,12 +645,14 @@ private final class StarsTransactionSheetComponent: CombinedComponent { subject: StarsTransactionScreen.Subject, action: @escaping () -> Void, openPeer: @escaping (EnginePeer) -> Void, + openMessage: @escaping (EngineMessage.Id) -> Void, copyTransactionId: @escaping () -> Void ) { self.context = context self.subject = subject self.action = action self.openPeer = openPeer + self.openMessage = openMessage self.copyTransactionId = copyTransactionId } @@ -599,6 +695,7 @@ private final class StarsTransactionSheetComponent: CombinedComponent { } }, openPeer: context.component.openPeer, + openMessage: context.component.openMessage, copyTransactionId: context.component.copyTransactionId )), backgroundColor: .color(environment.theme.actionSheet.opaqueItemBackgroundColor), @@ -667,7 +764,7 @@ private final class StarsTransactionSheetComponent: CombinedComponent { public class StarsTransactionScreen: ViewControllerComponentContainer { public enum Subject: Equatable { - case transaction(StarsContext.State.Transaction, Bool) + case transaction(StarsContext.State.Transaction, EnginePeer) case receipt(BotPaymentReceipt) } @@ -685,6 +782,7 @@ public class StarsTransactionScreen: ViewControllerComponentContainer { self.context = context var openPeerImpl: ((EnginePeer) -> Void)? + var openMessageImpl: ((EngineMessage.Id) -> Void)? var copyTransactionIdImpl: (() -> Void)? super.init( context: context, @@ -695,6 +793,9 @@ public class StarsTransactionScreen: ViewControllerComponentContainer { openPeer: { peerId in openPeerImpl?(peerId) }, + openMessage: { messageId in + openMessageImpl?(messageId) + }, copyTransactionId: { copyTransactionIdImpl?() } @@ -724,6 +825,23 @@ public class StarsTransactionScreen: ViewControllerComponentContainer { }) } + openMessageImpl = { [weak self] messageId in + guard let self else { + return + } + let _ = (context.engine.data.get( + TelegramEngine.EngineData.Item.Peer.Peer(id: messageId.peerId) + ) + |> deliverOnMainQueue).start(next: { peer in + guard let peer = peer else { + return + } + if let navigationController = self.navigationController as? NavigationController { + context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: context, chatLocation: .peer(peer), subject: .message(id: .id(messageId), highlight: ChatControllerSubject.MessageHighlight(quote: nil), timecode: nil), keepStack: .always, useExisting: false, purposefulAction: {}, peekData: nil)) + } + }) + } + copyTransactionIdImpl = { [weak self] in guard let self else { return @@ -1210,3 +1328,24 @@ private final class TransactionCellComponent: Component { return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) } } + +private func generateCloseButtonImage(backgroundColor: UIColor, foregroundColor: UIColor) -> UIImage? { + return generateImage(CGSize(width: 30.0, height: 30.0), contextGenerator: { size, context in + context.clear(CGRect(origin: CGPoint(), size: size)) + + context.setFillColor(backgroundColor.cgColor) + context.fillEllipse(in: CGRect(origin: CGPoint(), size: size)) + + context.setLineWidth(2.0) + context.setLineCap(.round) + context.setStrokeColor(foregroundColor.cgColor) + + context.move(to: CGPoint(x: 10.0, y: 10.0)) + context.addLine(to: CGPoint(x: 20.0, y: 20.0)) + context.strokePath() + + context.move(to: CGPoint(x: 20.0, y: 10.0)) + context.addLine(to: CGPoint(x: 10.0, y: 20.0)) + context.strokePath() + }) +} diff --git a/submodules/TelegramUI/Components/Stars/StarsTransactionsScreen/BUILD b/submodules/TelegramUI/Components/Stars/StarsTransactionsScreen/BUILD index 9f1d4d8263..2647ba93b5 100644 --- a/submodules/TelegramUI/Components/Stars/StarsTransactionsScreen/BUILD +++ b/submodules/TelegramUI/Components/Stars/StarsTransactionsScreen/BUILD @@ -45,6 +45,8 @@ swift_library( "//submodules/TelegramUI/Components/PeerManagement/OwnershipTransferController", "//submodules/TelegramUI/Components/ListItemComponentAdaptor", "//submodules/StatisticsUI", + "//submodules/TelegramUI/Components/Stars/StarsWithdrawalScreen", + "//submodules/TelegramUI/Components/Stars/StarsAvatarComponent", ], visibility = [ "//visibility:public", diff --git a/submodules/TelegramUI/Components/Stars/StarsTransactionsScreen/Sources/StarsStatisticsScreen.swift b/submodules/TelegramUI/Components/Stars/StarsTransactionsScreen/Sources/StarsStatisticsScreen.swift index 615942bbb4..09af2d82c9 100644 --- a/submodules/TelegramUI/Components/Stars/StarsTransactionsScreen/Sources/StarsStatisticsScreen.swift +++ b/submodules/TelegramUI/Components/Stars/StarsTransactionsScreen/Sources/StarsStatisticsScreen.swift @@ -20,6 +20,7 @@ import UndoUI import ListItemComponentAdaptor import StatisticsUI import ItemListUI +import StarsWithdrawalScreen final class StarsStatisticsScreenComponent: Component { typealias EnvironmentType = ViewControllerComponentContainer.Environment @@ -644,8 +645,14 @@ public final class StarsStatisticsScreen: ViewControllerComponentContainer { guard let self else { return } - let controller = context.sharedContext.makeStarsTransactionScreen(context: context, transaction: transaction, isAccount: false) - self.push(controller) + let _ = (context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: peerId)) + |> deliverOnMainQueue).start(next: { [weak self] peer in + guard let self, let peer else { + return + } + let controller = context.sharedContext.makeStarsTransactionScreen(context: context, transaction: transaction, peer: peer) + self.push(controller) + }) } withdrawImpl = { [weak self] in @@ -666,7 +673,7 @@ public final class StarsStatisticsScreen: ViewControllerComponentContainer { guard let self, let stats = state.stats else { return } - let controller = StarsWithdrawScreen(context: context, mode: .withdraw(stats), completion: { [weak self] amount in + let controller = self.context.sharedContext.makeStarsWithdrawalScreen(context: context, stats: stats, completion: { [weak self] amount in guard let self else { return } diff --git a/submodules/TelegramUI/Components/Stars/StarsTransactionsScreen/Sources/StarsTransactionsListPanelComponent.swift b/submodules/TelegramUI/Components/Stars/StarsTransactionsScreen/Sources/StarsTransactionsListPanelComponent.swift index d67b546eaa..beb1d5477f 100644 --- a/submodules/TelegramUI/Components/Stars/StarsTransactionsScreen/Sources/StarsTransactionsListPanelComponent.swift +++ b/submodules/TelegramUI/Components/Stars/StarsTransactionsScreen/Sources/StarsTransactionsListPanelComponent.swift @@ -15,6 +15,7 @@ import TelegramStringFormatting import AvatarNode import BundleIconComponent import PhotoResources +import StarsAvatarComponent private extension StarsContext.State.Transaction { var extendedId: String { @@ -207,7 +208,11 @@ final class StarsTransactionsListPanelComponent: Component { var itemDate: String switch item.peer { case let .peer(peer): - if let title = item.title { + if !item.media.isEmpty { + //TODO:localize + itemTitle = "Media Purchase" + itemSubtitle = peer.displayTitle(strings: environment.strings, displayOrder: .firstLast) + } else if let title = item.title { itemTitle = title itemSubtitle = peer.displayTitle(strings: environment.strings, displayOrder: .firstLast) } else { @@ -291,9 +296,9 @@ final class StarsTransactionsListPanelComponent: Component { theme: environment.theme, title: AnyComponent(VStack(titleComponents, alignment: .left, spacing: 2.0)), contentInsets: UIEdgeInsets(top: 9.0, left: environment.containerInsets.left, bottom: 8.0, right: environment.containerInsets.right), - leftIcon: .custom(AnyComponentWithIdentity(id: "avatar", component: AnyComponent(AvatarComponent(context: component.context, theme: environment.theme, peer: item.peer, photo: item.photo))), false), + leftIcon: .custom(AnyComponentWithIdentity(id: "avatar", component: AnyComponent(StarsAvatarComponent(context: component.context, theme: environment.theme, peer: item.peer, photo: item.photo, media: item.media))), false), icon: nil, - accessory: .custom(ListActionItemComponent.CustomAccessory(component: AnyComponentWithIdentity(id: "label", component: AnyComponent(LabelComponent(text: itemLabel))), insets: UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 16.0))), + accessory: .custom(ListActionItemComponent.CustomAccessory(component: AnyComponentWithIdentity(id: "label", component: AnyComponent(StarsLabelComponent(text: itemLabel))), insets: UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 16.0))), action: { [weak self] _ in guard let self, let component = self.component else { return @@ -493,241 +498,3 @@ func cancelContextGestures(view: UIView) { cancelContextGestures(view: subview) } } - -private final class AvatarComponent: Component { - let context: AccountContext - let theme: PresentationTheme - let peer: StarsContext.State.Transaction.Peer - let photo: TelegramMediaWebFile? - - init(context: AccountContext, theme: PresentationTheme, peer: StarsContext.State.Transaction.Peer, photo: TelegramMediaWebFile?) { - self.context = context - self.theme = theme - self.peer = peer - self.photo = photo - } - - static func ==(lhs: AvatarComponent, rhs: AvatarComponent) -> Bool { - if lhs.context !== rhs.context { - return false - } - if lhs.theme !== rhs.theme { - return false - } - if lhs.peer != rhs.peer { - return false - } - if lhs.photo != rhs.photo { - return false - } - return true - } - - final class View: UIView { - private let avatarNode: AvatarNode - private let backgroundView = UIImageView() - private let iconView = UIImageView() - private var imageNode: TransformImageNode? - - private let fetchDisposable = MetaDisposable() - - private var component: AvatarComponent? - private weak var state: EmptyComponentState? - - override init(frame: CGRect) { - self.avatarNode = AvatarNode(font: avatarPlaceholderFont(size: 16.0)) - - super.init(frame: frame) - - self.iconView.contentMode = .scaleAspectFit - - self.addSubnode(self.avatarNode) - self.addSubview(self.backgroundView) - self.addSubview(self.iconView) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - deinit { - self.fetchDisposable.dispose() - } - - func update(component: AvatarComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { - self.component = component - self.state = state - - let size = CGSize(width: 40.0, height: 40.0) - var iconInset: CGFloat = 3.0 - var iconOffset: CGFloat = 0.0 - - switch component.peer { - case let .peer(peer): - if let photo = component.photo { - let imageNode: TransformImageNode - if let current = self.imageNode { - imageNode = current - } else { - imageNode = TransformImageNode() - imageNode.contentAnimations = [.subsequentUpdates] - self.addSubview(imageNode.view) - self.imageNode = imageNode - - imageNode.setSignal(chatWebFileImage(account: component.context.account, file: photo)) - self.fetchDisposable.set(chatMessageWebFileInteractiveFetched(account: component.context.account, userLocation: .other, image: photo).startStrict()) - } - - imageNode.frame = CGRect(origin: .zero, size: size) - imageNode.asyncLayout()(TransformImageArguments(corners: ImageCorners(radius: size.width / 2.0), imageSize: size, boundingSize: size, intrinsicInsets: UIEdgeInsets(), emptyColor: component.theme.list.mediaPlaceholderColor))() - - self.backgroundView.isHidden = true - self.iconView.isHidden = true - self.avatarNode.isHidden = true - } else { - self.avatarNode.setPeer( - context: component.context, - theme: component.theme, - peer: peer, - synchronousLoad: true - ) - self.backgroundView.isHidden = true - self.iconView.isHidden = true - self.avatarNode.isHidden = false - } - case .appStore: - self.backgroundView.image = generateGradientFilledCircleImage( - diameter: size.width, - colors: [ - UIColor(rgb: 0x2a9ef1).cgColor, - UIColor(rgb: 0x72d5fd).cgColor - ], - direction: .mirroredDiagonal - ) - self.backgroundView.isHidden = false - self.iconView.isHidden = false - self.avatarNode.isHidden = true - self.iconView.image = UIImage(bundleImageName: "Premium/Stars/Apple") - case .playMarket: - self.backgroundView.image = generateGradientFilledCircleImage( - diameter: size.width, - colors: [ - UIColor(rgb: 0x54cb68).cgColor, - UIColor(rgb: 0xa0de7e).cgColor - ], - direction: .mirroredDiagonal - ) - self.backgroundView.isHidden = false - self.iconView.isHidden = false - self.avatarNode.isHidden = true - self.iconView.image = UIImage(bundleImageName: "Premium/Stars/Google") - case .fragment: - self.backgroundView.image = generateFilledCircleImage(diameter: size.width, color: UIColor(rgb: 0x1b1f24)) - self.backgroundView.isHidden = false - self.iconView.isHidden = false - self.avatarNode.isHidden = true - self.iconView.image = UIImage(bundleImageName: "Premium/Stars/Fragment") - iconOffset = 2.0 - case .premiumBot: - iconInset = 7.0 - self.backgroundView.image = generateGradientFilledCircleImage( - diameter: size.width, - colors: [ - UIColor(rgb: 0x6b93ff).cgColor, - UIColor(rgb: 0x6b93ff).cgColor, - UIColor(rgb: 0x8d77ff).cgColor, - UIColor(rgb: 0xb56eec).cgColor, - UIColor(rgb: 0xb56eec).cgColor - ], - direction: .mirroredDiagonal - ) - self.backgroundView.isHidden = false - self.iconView.isHidden = false - self.avatarNode.isHidden = true - self.iconView.image = generateTintedImage(image: UIImage(bundleImageName: "Chat/Input/Media/EntityInputPremiumIcon"), color: .white) - case .unsupported: - iconInset = 7.0 - self.backgroundView.image = generateGradientFilledCircleImage( - diameter: size.width, - colors: [ - UIColor(rgb: 0xb1b1b1).cgColor, - UIColor(rgb: 0xcdcdcd).cgColor - ], - direction: .mirroredDiagonal - ) - self.backgroundView.isHidden = false - self.iconView.isHidden = false - self.avatarNode.isHidden = true - self.iconView.image = generateTintedImage(image: UIImage(bundleImageName: "Chat/Input/Media/EntityInputPremiumIcon"), color: .white) - } - - self.avatarNode.frame = CGRect(origin: .zero, size: size) - self.iconView.frame = CGRect(origin: .zero, size: size).insetBy(dx: iconInset, dy: iconInset).offsetBy(dx: 0.0, dy: iconOffset) - self.backgroundView.frame = CGRect(origin: .zero, size: size) - - return size - } - } - - func makeView() -> View { - return View(frame: CGRect()) - } - - 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) - } -} - -private final class LabelComponent: CombinedComponent { - let text: NSAttributedString - - init( - text: NSAttributedString - ) { - self.text = text - } - - static func ==(lhs: LabelComponent, rhs: LabelComponent) -> Bool { - if lhs.text != rhs.text { - return false - } - return true - } - - static var body: Body { - let text = Child(MultilineTextComponent.self) - let icon = Child(BundleIconComponent.self) - - return { context in - let component = context.component - - let text = text.update( - component: MultilineTextComponent(text: .plain(component.text)), - availableSize: CGSize(width: 100.0, height: 40.0), - transition: context.transition - ) - - let iconSize = CGSize(width: 20.0, height: 20.0) - let icon = icon.update( - component: BundleIconComponent( - name: "Premium/Stars/StarLarge", - tintColor: nil - ), - availableSize: iconSize, - transition: context.transition - ) - - let spacing: CGFloat = 3.0 - let totalWidth = text.size.width + spacing + iconSize.width - let size = CGSize(width: totalWidth, height: iconSize.height) - - context.add(text - .position(CGPoint(x: text.size.width / 2.0, y: size.height / 2.0)) - ) - context.add(icon - .position(CGPoint(x: totalWidth - iconSize.width / 2.0, y: size.height / 2.0 - UIScreenPixel)) - ) - return size - } - } -} diff --git a/submodules/TelegramUI/Components/Stars/StarsTransactionsScreen/Sources/StarsTransactionsScreen.swift b/submodules/TelegramUI/Components/Stars/StarsTransactionsScreen/Sources/StarsTransactionsScreen.swift index 0b8589f090..8142f627b2 100644 --- a/submodules/TelegramUI/Components/Stars/StarsTransactionsScreen/Sources/StarsTransactionsScreen.swift +++ b/submodules/TelegramUI/Components/Stars/StarsTransactionsScreen/Sources/StarsTransactionsScreen.swift @@ -723,8 +723,14 @@ public final class StarsTransactionsScreen: ViewControllerComponentContainer { guard let self else { return } - let controller = context.sharedContext.makeStarsTransactionScreen(context: context, transaction: transaction, isAccount: true) - self.push(controller) + let _ = (context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: context.account.peerId)) + |> deliverOnMainQueue).start(next: { [weak self] peer in + guard let self, let peer else { + return + } + let controller = context.sharedContext.makeStarsTransactionScreen(context: context, transaction: transaction, peer: peer) + self.push(controller) + }) } buyImpl = { [weak self] in diff --git a/submodules/TelegramUI/Components/Stars/StarsTransferScreen/Sources/StarsTransferScreen.swift b/submodules/TelegramUI/Components/Stars/StarsTransferScreen/Sources/StarsTransferScreen.swift index 2226804df8..aae8cef5df 100644 --- a/submodules/TelegramUI/Components/Stars/StarsTransferScreen/Sources/StarsTransferScreen.swift +++ b/submodules/TelegramUI/Components/Stars/StarsTransferScreen/Sources/StarsTransferScreen.swift @@ -26,6 +26,7 @@ private final class SheetContent: CombinedComponent { let starsContext: StarsContext let invoice: TelegramMediaInvoice let source: BotPaymentInvoiceSource + let extendedMedia: [TelegramExtendedMedia] let inputData: Signal<(StarsContext.State, BotPaymentForm, EnginePeer?)?, NoError> let dismiss: () -> Void @@ -34,6 +35,7 @@ private final class SheetContent: CombinedComponent { starsContext: StarsContext, invoice: TelegramMediaInvoice, source: BotPaymentInvoiceSource, + extendedMedia: [TelegramExtendedMedia], inputData: Signal<(StarsContext.State, BotPaymentForm, EnginePeer?)?, NoError>, dismiss: @escaping () -> Void ) { @@ -41,6 +43,7 @@ private final class SheetContent: CombinedComponent { self.starsContext = starsContext self.invoice = invoice self.source = source + self.extendedMedia = extendedMedia self.inputData = inputData self.dismiss = dismiss } @@ -52,6 +55,9 @@ private final class SheetContent: CombinedComponent { if lhs.invoice != rhs.invoice { return false } + if lhs.extendedMedia != rhs.extendedMedia { + return false + } return true } @@ -64,7 +70,8 @@ private final class SheetContent: CombinedComponent { private let source: BotPaymentInvoiceSource private let invoice: TelegramMediaInvoice - private(set) var peer: EnginePeer? + private(set) var botPeer: EnginePeer? + private(set) var chatPeer: EnginePeer? private var peerDisposable: Disposable? private(set) var balance: Int64? private(set) var form: BotPaymentForm? @@ -95,14 +102,25 @@ private final class SheetContent: CombinedComponent { super.init() - self.peerDisposable = (inputData - |> deliverOnMainQueue).start(next: { [weak self] inputData in + let chatPeer: Signal + if case let .message(messageId) = source { + chatPeer = context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: messageId.peerId)) + } else { + chatPeer = .single(nil) + } + + self.peerDisposable = (combineLatest( + inputData, + chatPeer + ) + |> deliverOnMainQueue).start(next: { [weak self] inputData, chatPeer in guard let self else { return } self.balance = inputData?.0.balance ?? 0 self.form = inputData?.1 - self.peer = inputData?.2 + self.botPeer = inputData?.2 + self.chatPeer = chatPeer self.updated(transition: .immediate) if self.optionsDisposable == nil, let balance = self.balance, balance < self.invoice.totalAmount { @@ -228,9 +246,9 @@ private final class SheetContent: CombinedComponent { ) let subject: StarsImageComponent.Subject - if let extendedMedia = component.invoice.extendedMedia { - subject = .extendedMedia(extendedMedia) - } else if let peer = state.peer { + if !component.extendedMedia.isEmpty { + subject = .extendedMedia(component.extendedMedia) + } else if let peer = state.botPeer { if let photo = component.invoice.photo { subject = .photo(photo) } else { @@ -244,7 +262,8 @@ private final class SheetContent: CombinedComponent { context: component.context, subject: subject, theme: theme, - diameter: 90.0 + diameter: 90.0, + backgroundColor: theme.list.blocksBackgroundColor ), availableSize: CGSize(width: min(414.0, context.availableSize.width), height: 220.0), transition: context.transition @@ -299,14 +318,52 @@ private final class SheetContent: CombinedComponent { let amount = component.invoice.totalAmount let infoText: String - if let _ = component.invoice.extendedMedia { + if !component.extendedMedia.isEmpty { + //TODO:localize + var description: String = "" + var photoCount: Int = 0 + var videoCount: Int = 0 + for media in component.extendedMedia { + if case let .preview(_, _, videoDuration) = media, videoDuration != nil { + videoCount += 1 + } else { + photoCount += 1 + } + } + if photoCount > 0 && videoCount > 0 { + if photoCount > 1 { + description += "**\(photoCount) photos**" + } else { + description += "**\(photoCount) photo**" + } + description += " and " + if videoCount > 1 { + description += "**\(videoCount) videos**" + } else { + description += "**\(videoCount) video**" + } + } else if photoCount > 0 { + if photoCount > 1 { + description += "**\(photoCount) photos**" + } else { + description += "**photo**" + } + } else if videoCount > 0 { + if videoCount > 1 { + description += "**\(videoCount) videos**" + } else { + description += "**video**" + } + } infoText = strings.Stars_Transfer_UnlockInfo( + description, + state.chatPeer?.compactDisplayTitle ?? "", strings.Stars_Transfer_Info_Stars(Int32(amount)) ).string } else { infoText = strings.Stars_Transfer_Info( component.invoice.title, - state.peer?.compactDisplayTitle ?? "", + state.botPeer?.compactDisplayTitle ?? "", strings.Stars_Transfer_Info_Stars(Int32(amount)) ).string } @@ -386,7 +443,7 @@ private final class SheetContent: CombinedComponent { let accountContext = component.context let starsContext = component.starsContext - let botTitle = state.peer?.compactDisplayTitle ?? "" + let botTitle = state.botPeer?.compactDisplayTitle ?? "" let invoice = component.invoice let button = button.update( component: ButtonComponent( @@ -410,7 +467,7 @@ private final class SheetContent: CombinedComponent { context: accountContext, starsContext: starsContext, options: state?.options ?? [], - peerId: state?.peer?.id, + peerId: state?.botPeer?.id, requiredStars: invoice.totalAmount, completion: { [weak starsContext] stars in starsContext?.add(balance: stars) @@ -484,6 +541,7 @@ private final class StarsTransferSheetComponent: CombinedComponent { private let starsContext: StarsContext private let invoice: TelegramMediaInvoice private let source: BotPaymentInvoiceSource + private let extendedMedia: [TelegramExtendedMedia] private let inputData: Signal<(StarsContext.State, BotPaymentForm, EnginePeer?)?, NoError> init( @@ -491,12 +549,14 @@ private final class StarsTransferSheetComponent: CombinedComponent { starsContext: StarsContext, invoice: TelegramMediaInvoice, source: BotPaymentInvoiceSource, + extendedMedia: [TelegramExtendedMedia], inputData: Signal<(StarsContext.State, BotPaymentForm, EnginePeer?)?, NoError> ) { self.context = context self.starsContext = starsContext self.invoice = invoice self.source = source + self.extendedMedia = extendedMedia self.inputData = inputData } @@ -507,6 +567,9 @@ private final class StarsTransferSheetComponent: CombinedComponent { if lhs.invoice != rhs.invoice { return false } + if lhs.extendedMedia != rhs.extendedMedia { + return false + } return true } @@ -526,6 +589,7 @@ private final class StarsTransferSheetComponent: CombinedComponent { starsContext: context.component.starsContext, invoice: context.component.invoice, source: context.component.source, + extendedMedia: context.component.extendedMedia, inputData: context.component.inputData, dismiss: { animateOut.invoke(Action { _ in @@ -584,6 +648,7 @@ public final class StarsTransferScreen: ViewControllerComponentContainer { starsContext: StarsContext, invoice: TelegramMediaInvoice, source: BotPaymentInvoiceSource, + extendedMedia: [TelegramExtendedMedia], inputData: Signal<(StarsContext.State, BotPaymentForm, EnginePeer?)?, NoError>, completion: @escaping (Bool) -> Void ) { @@ -597,6 +662,7 @@ public final class StarsTransferScreen: ViewControllerComponentContainer { starsContext: starsContext, invoice: invoice, source: source, + extendedMedia: extendedMedia, inputData: inputData ), navigationBarAppearance: .none, diff --git a/submodules/TelegramUI/Components/Stars/StarsWithdrawalScreen/BUILD b/submodules/TelegramUI/Components/Stars/StarsWithdrawalScreen/BUILD new file mode 100644 index 0000000000..05069f6b5c --- /dev/null +++ b/submodules/TelegramUI/Components/Stars/StarsWithdrawalScreen/BUILD @@ -0,0 +1,44 @@ +load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library") + +swift_library( + name = "StarsWithdrawalScreen", + module_name = "StarsWithdrawalScreen", + 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/Premium/PremiumStarComponent", + "//submodules/TelegramUI/Components/ButtonComponent", + "//submodules/Components/BundleIconComponent", + "//submodules/PasswordSetupUI", + "//submodules/TelegramUI/Components/PeerManagement/OwnershipTransferController", + ], + visibility = [ + "//visibility:public", + ], +) diff --git a/submodules/TelegramUI/Components/Stars/StarsTransactionsScreen/Sources/StarsRevenueWithdrawalController.swift b/submodules/TelegramUI/Components/Stars/StarsWithdrawalScreen/Sources/StarsRevenueWithdrawalController.swift similarity index 91% rename from submodules/TelegramUI/Components/Stars/StarsTransactionsScreen/Sources/StarsRevenueWithdrawalController.swift rename to submodules/TelegramUI/Components/Stars/StarsWithdrawalScreen/Sources/StarsRevenueWithdrawalController.swift index 2a0fe89b7d..e56d23f863 100644 --- a/submodules/TelegramUI/Components/Stars/StarsTransactionsScreen/Sources/StarsRevenueWithdrawalController.swift +++ b/submodules/TelegramUI/Components/Stars/StarsWithdrawalScreen/Sources/StarsRevenueWithdrawalController.swift @@ -9,7 +9,7 @@ import PasswordSetupUI import Markdown import OwnershipTransferController -func confirmStarsRevenueWithdrawalController(context: AccountContext, updatedPresentationData: (initial: PresentationData, signal: Signal)? = nil, peerId: EnginePeer.Id, amount: Int64, present: @escaping (ViewController, Any?) -> Void, completion: @escaping (String) -> Void) -> ViewController { +public func confirmStarsRevenueWithdrawalController(context: AccountContext, updatedPresentationData: (initial: PresentationData, signal: Signal)? = nil, peerId: EnginePeer.Id, amount: Int64, present: @escaping (ViewController, Any?) -> Void, completion: @escaping (String) -> Void) -> ViewController { let presentationData = updatedPresentationData?.initial ?? context.sharedContext.currentPresentationData.with { $0 } var dismissImpl: (() -> Void)? @@ -73,7 +73,7 @@ func confirmStarsRevenueWithdrawalController(context: AccountContext, updatedPre } -func starsRevenueWithdrawalController(context: AccountContext, updatedPresentationData: (initial: PresentationData, signal: Signal)? = nil, peerId: EnginePeer.Id, amount: Int64, initialError: RequestStarsRevenueWithdrawalError, present: @escaping (ViewController, Any?) -> Void, completion: @escaping (String) -> Void) -> ViewController { +public func starsRevenueWithdrawalController(context: AccountContext, updatedPresentationData: (initial: PresentationData, signal: Signal)? = nil, peerId: EnginePeer.Id, amount: Int64, initialError: RequestStarsRevenueWithdrawalError, present: @escaping (ViewController, Any?) -> Void, completion: @escaping (String) -> Void) -> ViewController { let presentationData = updatedPresentationData?.initial ?? context.sharedContext.currentPresentationData.with { $0 } let theme = AlertControllerTheme(presentationData: presentationData) diff --git a/submodules/TelegramUI/Components/Stars/StarsTransactionsScreen/Sources/StarsWithdrawScreen.swift b/submodules/TelegramUI/Components/Stars/StarsWithdrawalScreen/Sources/StarsWithdrawalScreen.swift similarity index 96% rename from submodules/TelegramUI/Components/Stars/StarsTransactionsScreen/Sources/StarsWithdrawScreen.swift rename to submodules/TelegramUI/Components/Stars/StarsWithdrawalScreen/Sources/StarsWithdrawalScreen.swift index dfd00ae4ea..d9f5bed1cd 100644 --- a/submodules/TelegramUI/Components/Stars/StarsTransactionsScreen/Sources/StarsWithdrawScreen.swift +++ b/submodules/TelegramUI/Components/Stars/StarsWithdrawalScreen/Sources/StarsWithdrawalScreen.swift @@ -111,13 +111,14 @@ private final class SheetContent: CombinedComponent { let minAmount: Int64? let maxAmount: Int64? + let configuration = StarsWithdrawConfiguration.with(appConfiguration: component.context.currentAppConfiguration.with { $0 }) + switch component.mode { case let .withdraw(status): titleString = environment.strings.Stars_Withdraw_Title amountTitle = environment.strings.Stars_Withdraw_AmountTitle amountPlaceholder = environment.strings.Stars_Withdraw_AmountPlaceholder - let configuration = StarsWithdrawConfiguration.with(appConfiguration: component.context.currentAppConfiguration.with { $0 }) minAmount = configuration.minWithdrawAmount maxAmount = status.balances.availableBalance case .paidMedia: @@ -126,7 +127,7 @@ private final class SheetContent: CombinedComponent { amountPlaceholder = environment.strings.Stars_PaidContent_AmountPlaceholder minAmount = 1 - maxAmount = nil + maxAmount = configuration.maxPaidMediaAmount } let title = title.update( @@ -335,8 +336,11 @@ private final class SheetContent: CombinedComponent { func makeState() -> State { var amount: Int64? - if case let .withdraw(stats) = mode { + switch self.mode { + case let .withdraw(stats): amount = stats.balances.availableBalance + case let .paidMedia(initialValue): + amount = initialValue } return State(context: self.context, amount: amount) } @@ -432,7 +436,7 @@ private final class StarsWithdrawSheetComponent: CombinedComponent { public final class StarsWithdrawScreen: ViewControllerComponentContainer { public enum Mode: Equatable { case withdraw(StarsRevenueStats) - case paidMedia + case paidMedia(Int64?) } private let context: AccountContext @@ -730,18 +734,29 @@ func generateCloseButtonImage(backgroundColor: UIColor, foregroundColor: UIColor private struct StarsWithdrawConfiguration { static var defaultValue: StarsWithdrawConfiguration { - return StarsWithdrawConfiguration(minWithdrawAmount: nil) + return StarsWithdrawConfiguration(minWithdrawAmount: nil, maxPaidMediaAmount: nil) } let minWithdrawAmount: Int64? + let maxPaidMediaAmount: Int64? - fileprivate init(minWithdrawAmount: Int64?) { + fileprivate init(minWithdrawAmount: Int64?, maxPaidMediaAmount: Int64?) { self.minWithdrawAmount = minWithdrawAmount + self.maxPaidMediaAmount = maxPaidMediaAmount } static func with(appConfiguration: AppConfiguration) -> StarsWithdrawConfiguration { - if let data = appConfiguration.data, let minWithdrawAmount = data["stars_revenue_withdrawal_min"] as? Double { - return StarsWithdrawConfiguration(minWithdrawAmount: Int64(minWithdrawAmount)) + if let data = appConfiguration.data { + var minWithdrawAmount: Int64? + if let value = data["stars_revenue_withdrawal_min"] as? Double { + minWithdrawAmount = Int64(value) + } + var maxPaidMediaAmount: Int64? + if let value = data["stars_paid_post_amount_max"] as? Double { + maxPaidMediaAmount = Int64(value) + } + + return StarsWithdrawConfiguration(minWithdrawAmount: minWithdrawAmount, maxPaidMediaAmount: maxPaidMediaAmount) } else { return .defaultValue } diff --git a/submodules/TelegramUI/Components/StickerPickerScreen/Sources/StickerPickerScreen.swift b/submodules/TelegramUI/Components/StickerPickerScreen/Sources/StickerPickerScreen.swift index 4d501b3de1..80ee4b5559 100644 --- a/submodules/TelegramUI/Components/StickerPickerScreen/Sources/StickerPickerScreen.swift +++ b/submodules/TelegramUI/Components/StickerPickerScreen/Sources/StickerPickerScreen.swift @@ -742,7 +742,7 @@ public class StickerPickerScreen: ViewController { let message = Message(stableId: 0, stableVersion: 0, id: MessageId(peerId: PeerId(0), namespace: Namespaces.Message.Local, id: 0), globallyUniqueId: nil, groupingKey: nil, groupInfo: nil, threadId: nil, timestamp: 0, flags: [], tags: [], globalTags: [], localTags: [], customTags: [], forwardInfo: nil, author: nil, text: "", attributes: [], media: [file.media], peers: SimpleDictionary(), associatedMessages: SimpleDictionary(), associatedMessageIds: [], associatedMedia: [:], associatedThreadInfo: nil, associatedStories: [:]) - let gallery = GalleryController(context: context, source: .standaloneMessage(message), streamSingleVideo: true, replaceRootController: { _, _ in + let gallery = GalleryController(context: context, source: .standaloneMessage(message, nil), streamSingleVideo: true, replaceRootController: { _, _ in }, baseNavigationController: nil) gallery.setHintWillBePresentedInPreviewingContext(true) diff --git a/submodules/TelegramUI/Images.xcassets/Media Grid/Lock.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Media Grid/Lock.imageset/Contents.json new file mode 100644 index 0000000000..6e4a78fd51 --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Media Grid/Lock.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "paidlock (3).pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/submodules/TelegramUI/Images.xcassets/Media Grid/Lock.imageset/paidlock (3).pdf b/submodules/TelegramUI/Images.xcassets/Media Grid/Lock.imageset/paidlock (3).pdf new file mode 100644 index 0000000000..84ff75a7be Binary files /dev/null and b/submodules/TelegramUI/Images.xcassets/Media Grid/Lock.imageset/paidlock (3).pdf differ diff --git a/submodules/TelegramUI/Images.xcassets/Premium/Perk/MessageEffects.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Premium/Perk/MessageEffects.imageset/Contents.json new file mode 100644 index 0000000000..5f59e21e04 --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Premium/Perk/MessageEffects.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "msgeffects.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/submodules/TelegramUI/Images.xcassets/Premium/Perk/MessageEffects.imageset/msgeffects.pdf b/submodules/TelegramUI/Images.xcassets/Premium/Perk/MessageEffects.imageset/msgeffects.pdf new file mode 100644 index 0000000000..d16df31a0c Binary files /dev/null and b/submodules/TelegramUI/Images.xcassets/Premium/Perk/MessageEffects.imageset/msgeffects.pdf differ diff --git a/submodules/TelegramUI/Sources/ChatController.swift b/submodules/TelegramUI/Sources/ChatController.swift index 0513e0c8bb..431ad1407f 100644 --- a/submodules/TelegramUI/Sources/ChatController.swift +++ b/submodules/TelegramUI/Sources/ChatController.swift @@ -901,42 +901,6 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } } } - -// #if DEBUG -// if message.text == "#", let telegramImage = message.media.first(where: { $0 is TelegramMediaImage }) as? TelegramMediaImage { -// let invoice = TelegramMediaInvoice(title: "", description: "", photo: nil, receiptMessageId: nil, currency: "XTR", totalAmount: 100, startParam: "", extendedMedia: .preview(dimensions: telegramImage.representations.first?.dimensions ?? PixelDimensions(width: 1, height: 1), immediateThumbnailData: telegramImage.immediateThumbnailData, videoDuration: nil), flags: [], version: 0) -// -// let inputData = Promise() -// inputData.set(.single( -// BotCheckoutController.InputData( -// form: BotPaymentForm(id: 123, canSaveCredentials: false, passwordMissing: false, invoice: BotPaymentInvoice(isTest: false, requestedFields: [], currency: "XTR", prices: [BotPaymentPrice(label: "", amount: 100)], tip: nil, termsInfo: nil), paymentBotId: message.id.peerId, providerId: nil, url: nil, nativeProvider: nil, savedInfo: nil, savedCredentials: [], additionalPaymentMethods: []), -// validatedFormInfo: nil, -// botPeer: nil -// ))) -// if invoice.currency == "XTR", let starsContext = strongSelf.context.starsContext { -// let starsInputData = combineLatest( -// inputData.get(), -// starsContext.state -// ) -// |> map { data, state -> (StarsContext.State, BotPaymentForm, EnginePeer?)? in -// if let data, let state { -// return (state, data.form, data.botPeer) -// } else { -// return nil -// } -// } -// let _ = (starsInputData |> filter { $0 != nil } |> take(1) |> deliverOnMainQueue).start(next: { [weak self] _ in -// guard let strongSelf = self else { -// return -// } -// let controller = strongSelf.context.sharedContext.makeStarsTransferScreen(context: strongSelf.context, starsContext: starsContext, invoice: invoice, source: .message(message.id), inputData: starsInputData, completion: { _ in }) -// strongSelf.push(controller) -// }) -// } -// return true -// } -// #endif - if let paidContent = media as? TelegramMediaPaidContent, let extendedMedia = paidContent.extendedMedia.first { switch extendedMedia { case .preview: @@ -1291,7 +1255,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G standalone = true } - return context.sharedContext.openChatMessage(OpenChatMessageParams(context: context, updatedPresentationData: strongSelf.updatedPresentationData, chatLocation: openChatLocation, chatFilterTag: chatFilterTag, chatLocationContextHolder: strongSelf.chatLocationContextHolder, message: message, standalone: standalone, reverseMessageGalleryOrder: false, mode: mode, navigationController: strongSelf.effectiveNavigationController, dismissInput: { + return context.sharedContext.openChatMessage(OpenChatMessageParams(context: context, updatedPresentationData: strongSelf.updatedPresentationData, chatLocation: openChatLocation, chatFilterTag: chatFilterTag, chatLocationContextHolder: strongSelf.chatLocationContextHolder, message: message, mediaIndex: params.mediaIndex, standalone: standalone, reverseMessageGalleryOrder: false, mode: mode, navigationController: strongSelf.effectiveNavigationController, dismissInput: { self?.chatDisplayNode.dismissInput() }, present: { c, a in self?.present(c, in: .window(.root), with: a, blockInteraction: true) @@ -2670,7 +2634,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G return } let invoice = TelegramMediaInvoice(title: "", description: "", photo: nil, receiptMessageId: nil, currency: "XTR", totalAmount: paidContent.amount, startParam: "", extendedMedia: .preview(dimensions:dimensions, immediateThumbnailData: immediateThumbnailData, videoDuration: nil), flags: [], version: 0) - let controller = strongSelf.context.sharedContext.makeStarsTransferScreen(context: strongSelf.context, starsContext: starsContext, invoice: invoice, source: .message(messageId), inputData: starsInputData, completion: { _ in }) + let controller = strongSelf.context.sharedContext.makeStarsTransferScreen(context: strongSelf.context, starsContext: starsContext, invoice: invoice, source: .message(messageId), extendedMedia: paidContent.extendedMedia, inputData: starsInputData, completion: { _ in }) strongSelf.push(controller) }) } @@ -2711,7 +2675,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G guard let strongSelf = self else { return } - let controller = strongSelf.context.sharedContext.makeStarsTransferScreen(context: strongSelf.context, starsContext: starsContext, invoice: invoice, source: .message(messageId), inputData: starsInputData, completion: { _ in }) + let controller = strongSelf.context.sharedContext.makeStarsTransferScreen(context: strongSelf.context, starsContext: starsContext, invoice: invoice, source: .message(messageId), extendedMedia: [], inputData: starsInputData, completion: { _ in }) strongSelf.push(controller) }) } else { diff --git a/submodules/TelegramUI/Sources/ChatPinnedMessageTitlePanelNode.swift b/submodules/TelegramUI/Sources/ChatPinnedMessageTitlePanelNode.swift index b47e365257..8cf3708fd0 100644 --- a/submodules/TelegramUI/Sources/ChatPinnedMessageTitlePanelNode.swift +++ b/submodules/TelegramUI/Sources/ChatPinnedMessageTitlePanelNode.swift @@ -638,6 +638,30 @@ final class ChatPinnedMessageTitlePanelNode: ChatTitleAccessoryPanelNode { imageDimensions = dimensions.cgSize } break + } else if let paidContent = media as? TelegramMediaPaidContent, let firstMedia = paidContent.extendedMedia.first { + switch firstMedia { + case let .preview(dimensions, immediateThumbnailData, _): + let thumbnailMedia = TelegramMediaImage(imageId: MediaId(namespace: 0, id: 0), representations: [], immediateThumbnailData: immediateThumbnailData, reference: nil, partialReference: nil, flags: []) + if let dimensions { + imageDimensions = dimensions.cgSize + } + updatedMediaReference = .standalone(media: thumbnailMedia) + case let .full(fullMedia): + updatedMediaReference = .message(message: MessageReference(message), media: fullMedia) + if let image = fullMedia as? TelegramMediaImage { + if let representation = largestRepresentationForPhoto(image) { + imageDimensions = representation.dimensions.cgSize + } + break + } else if let file = fullMedia as? TelegramMediaFile { + if !file.isInstantVideo && !file.isSticker, let representation = largestImageRepresentation(file.previewRepresentations) { + imageDimensions = representation.dimensions.cgSize + } else if file.isAnimated, let dimensions = file.dimensions { + imageDimensions = dimensions.cgSize + } + break + } + } } } } @@ -681,7 +705,11 @@ final class ChatPinnedMessageTitlePanelNode: ChatTitleAccessoryPanelNode { if mediaUpdated { if let updatedMediaReference = updatedMediaReference, imageDimensions != nil { if let imageReference = updatedMediaReference.concrete(TelegramMediaImage.self) { - updateImageSignal = chatMessagePhotoThumbnail(account: context.account, userLocation: .peer(message.id.peerId), photoReference: imageReference, blurred: hasSpoiler) + if imageReference.media.representations.isEmpty { + updateImageSignal = chatSecretPhoto(account: context.account, userLocation: .peer(message.id.peerId), photoReference: imageReference, ignoreFullSize: true, synchronousLoad: true) + } else { + updateImageSignal = chatMessagePhotoThumbnail(account: context.account, userLocation: .peer(message.id.peerId), photoReference: imageReference, blurred: hasSpoiler) + } } else if let fileReference = updatedMediaReference.concrete(TelegramMediaFile.self) { if fileReference.media.isAnimatedSticker { let dimensions = fileReference.media.dimensions ?? PixelDimensions(width: 512, height: 512) diff --git a/submodules/TelegramUI/Sources/OpenChatMessage.swift b/submodules/TelegramUI/Sources/OpenChatMessage.swift index cbb4c97017..30f7ee7b64 100644 --- a/submodules/TelegramUI/Sources/OpenChatMessage.swift +++ b/submodules/TelegramUI/Sources/OpenChatMessage.swift @@ -127,7 +127,7 @@ func openChatMessageImpl(_ params: OpenChatMessageParams) -> Bool { return true } - if let mediaData = chatMessageGalleryControllerData(context: params.context, chatLocation: params.chatLocation, chatFilterTag: params.chatFilterTag, chatLocationContextHolder: params.chatLocationContextHolder, message: params.message, navigationController: params.navigationController, standalone: params.standalone, reverseMessageGalleryOrder: params.reverseMessageGalleryOrder, mode: params.mode, source: params.gallerySource, synchronousLoad: false, actionInteraction: params.actionInteraction) { + if let mediaData = chatMessageGalleryControllerData(context: params.context, chatLocation: params.chatLocation, chatFilterTag: params.chatFilterTag, chatLocationContextHolder: params.chatLocationContextHolder, message: params.message, mediaIndex: params.mediaIndex, navigationController: params.navigationController, standalone: params.standalone, reverseMessageGalleryOrder: params.reverseMessageGalleryOrder, mode: params.mode, source: params.gallerySource, synchronousLoad: false, actionInteraction: params.actionInteraction) { switch mediaData { case let .url(url): params.openUrl(url) diff --git a/submodules/TelegramUI/Sources/OpenResolvedUrl.swift b/submodules/TelegramUI/Sources/OpenResolvedUrl.swift index 3395f95322..9bf678e4bf 100644 --- a/submodules/TelegramUI/Sources/OpenResolvedUrl.swift +++ b/submodules/TelegramUI/Sources/OpenResolvedUrl.swift @@ -837,7 +837,7 @@ func openResolvedUrlImpl( } } let _ = (starsInputData |> filter { $0 != nil } |> take(1) |> deliverOnMainQueue).start(next: { _ in - let controller = context.sharedContext.makeStarsTransferScreen(context: context, starsContext: starsContext, invoice: invoice, source: .slug(slug), inputData: starsInputData, completion: { _ in }) + let controller = context.sharedContext.makeStarsTransferScreen(context: context, starsContext: starsContext, invoice: invoice, source: .slug(slug), extendedMedia: [], inputData: starsInputData, completion: { _ in }) navigationController.pushViewController(controller) }) } else { @@ -1072,7 +1072,7 @@ func openResolvedUrlImpl( }, openMessage: { messageId in let _ = (context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: messageId.peerId)) - |> deliverOnMainQueue).startStandalone(next: { peer in + |> deliverOnMainQueue).startStandalone(next: { peer in guard let peer else { return } diff --git a/submodules/TelegramUI/Sources/SharedAccountContext.swift b/submodules/TelegramUI/Sources/SharedAccountContext.swift index ac9f5ca3a2..b18bf299ed 100644 --- a/submodules/TelegramUI/Sources/SharedAccountContext.swift +++ b/submodules/TelegramUI/Sources/SharedAccountContext.swift @@ -67,6 +67,8 @@ import BirthdayPickerScreen import StarsTransactionsScreen import StarsPurchaseScreen import StarsTransferScreen +import StarsTransactionScreen +import StarsWithdrawalScreen private final class AccountUserInterfaceInUseContext { let subscribers = Bag<(Bool) -> Void>() @@ -2072,6 +2074,8 @@ public final class SharedAccountContextImpl: SharedAccountContext { mappedSource = .messageTags case .folderTags: mappedSource = .folderTags + case .messageEffects: + mappedSource = .messageEffects case .animatedEmoji: mappedSource = .animatedEmoji } @@ -2128,6 +2132,8 @@ public final class SharedAccountContextImpl: SharedAccountContext { mappedSubject = .messagePrivacy case .folderTags: mappedSubject = .folderTags + case .messageEffects: + mappedSubject = .messageEffects case .business: mappedSubject = .business buttonText = presentationData.strings.Chat_EmptyStateIntroFooterPremiumActionButton @@ -2628,12 +2634,12 @@ public final class SharedAccountContextImpl: SharedAccountContext { return StarsPurchaseScreen(context: context, starsContext: starsContext, options: options, peerId: peerId, requiredStars: requiredStars, modal: true, completion: completion) } - public func makeStarsTransferScreen(context: AccountContext, starsContext: StarsContext, invoice: TelegramMediaInvoice, source: BotPaymentInvoiceSource, inputData: Signal<(StarsContext.State, BotPaymentForm, EnginePeer?)?, NoError>, completion: @escaping (Bool) -> Void) -> ViewController { - return StarsTransferScreen(context: context, starsContext: starsContext, invoice: invoice, source: source, inputData: inputData, completion: completion) + public func makeStarsTransferScreen(context: AccountContext, starsContext: StarsContext, invoice: TelegramMediaInvoice, source: BotPaymentInvoiceSource, extendedMedia: [TelegramExtendedMedia], inputData: Signal<(StarsContext.State, BotPaymentForm, EnginePeer?)?, NoError>, completion: @escaping (Bool) -> Void) -> ViewController { + return StarsTransferScreen(context: context, starsContext: starsContext, invoice: invoice, source: source, extendedMedia: extendedMedia, inputData: inputData, completion: completion) } - public func makeStarsTransactionScreen(context: AccountContext, transaction: StarsContext.State.Transaction, isAccount: Bool) -> ViewController { - return StarsTransactionScreen(context: context, subject: .transaction(transaction, isAccount), action: {}) + public func makeStarsTransactionScreen(context: AccountContext, transaction: StarsContext.State.Transaction, peer: EnginePeer) -> ViewController { + return StarsTransactionScreen(context: context, subject: .transaction(transaction, peer), action: {}) } public func makeStarsReceiptScreen(context: AccountContext, receipt: BotPaymentReceipt) -> ViewController { @@ -2644,8 +2650,12 @@ public final class SharedAccountContextImpl: SharedAccountContext { return StarsStatisticsScreen(context: context, peerId: peerId, revenueContext: revenueContext) } - public func makeStarsAmountScreen(context: AccountContext, completion: @escaping (Int64) -> Void) -> ViewController { - return StarsWithdrawScreen(context: context, mode: .paidMedia, completion: completion) + public func makeStarsAmountScreen(context: AccountContext, initialValue: Int64?, completion: @escaping (Int64) -> Void) -> ViewController { + return StarsWithdrawScreen(context: context, mode: .paidMedia(initialValue), completion: completion) + } + + public func makeStarsWithdrawalScreen(context: AccountContext, stats: StarsRevenueStats, completion: @escaping (Int64) -> Void) -> ViewController { + return StarsWithdrawScreen(context: context, mode: .withdraw(stats), completion: completion) } } diff --git a/submodules/WebUI/Sources/WebAppController.swift b/submodules/WebUI/Sources/WebAppController.swift index 275ed4cd06..18220879b6 100644 --- a/submodules/WebUI/Sources/WebAppController.swift +++ b/submodules/WebUI/Sources/WebAppController.swift @@ -882,6 +882,7 @@ public final class WebAppController: ViewController, AttachmentContainable { starsContext: starsContext, invoice: invoice, source: .slug(slug), + extendedMedia: [], inputData: starsInputData, completion: { [weak self] paid in guard let self else {