diff --git a/Telegram/Telegram-iOS/en.lproj/Localizable.strings b/Telegram/Telegram-iOS/en.lproj/Localizable.strings index 9268a2b745..a1403901e5 100644 --- a/Telegram/Telegram-iOS/en.lproj/Localizable.strings +++ b/Telegram/Telegram-iOS/en.lproj/Localizable.strings @@ -14690,3 +14690,10 @@ Sorry for the inconvenience."; "Chat.ReplyPanel.ReplyToTodoItem" = "Reply to Checklist Item"; "Chat.Todo.ReplyToItem" = "Reply to Item"; + +"Premium.SubscribeForBiannual" = "Subscribe for %@ / 2 years"; +"Premium.Biannual" = "2 Years"; + +"Premium.PricePer2Years" = "%@/2 years"; + +"Chat.SensitiveContentShort" = "18+"; diff --git a/submodules/Camera/Sources/Camera.swift b/submodules/Camera/Sources/Camera.swift index 0233b29aec..4872952c3d 100644 --- a/submodules/Camera/Sources/Camera.swift +++ b/submodules/Camera/Sources/Camera.swift @@ -760,7 +760,17 @@ public final class Camera { public let metrics: Camera.Metrics - public init(configuration: Camera.Configuration = Configuration(preset: .hd1920x1080, position: .back, audio: true, photo: false, metadata: false), previewView: CameraSimplePreviewView? = nil, secondaryPreviewView: CameraSimplePreviewView? = nil) { + public init( + configuration: Camera.Configuration = Configuration( + preset: .hd1920x1080, + position: .back, + audio: true, + photo: false, + metadata: false + ), + previewView: CameraSimplePreviewView? = nil, + secondaryPreviewView: CameraSimplePreviewView? = nil + ) { Logger.shared.log("Camera", "Init") self.metrics = Camera.Metrics(model: DeviceModel.current) diff --git a/submodules/ChatListUI/Sources/ChatListFilterTabContainerNode.swift b/submodules/ChatListUI/Sources/ChatListFilterTabContainerNode.swift index 4d647eb616..b20fc85916 100644 --- a/submodules/ChatListUI/Sources/ChatListFilterTabContainerNode.swift +++ b/submodules/ChatListUI/Sources/ChatListFilterTabContainerNode.swift @@ -322,7 +322,7 @@ private final class ItemNode: ASDisplayNode { if self.isReordering != isReordering { self.isReordering = isReordering if self.isReordering { - self.startShaking() + self.layer.addReorderingShaking() } else { self.layer.removeAnimation(forKey: "shaking_position") self.layer.removeAnimation(forKey: "shaking_rotation") @@ -414,52 +414,7 @@ private final class ItemNode: ASDisplayNode { transition.updateSublayerTransformScale(node: self.badgeContainerNode, scale: 0.1) } } - - private func startShaking() { - func degreesToRadians(_ x: CGFloat) -> CGFloat { - return .pi * x / 180.0 - } - - let duration: Double = 0.4 - let displacement: CGFloat = 1.0 - let degreesRotation: CGFloat = 2.0 - let negativeDisplacement = -1.0 * displacement - let position = CAKeyframeAnimation.init(keyPath: "position") - position.beginTime = 0.8 - position.duration = duration - position.values = [ - NSValue(cgPoint: CGPoint(x: negativeDisplacement, y: negativeDisplacement)), - NSValue(cgPoint: CGPoint(x: 0, y: 0)), - NSValue(cgPoint: CGPoint(x: negativeDisplacement, y: 0)), - NSValue(cgPoint: CGPoint(x: 0, y: negativeDisplacement)), - NSValue(cgPoint: CGPoint(x: negativeDisplacement, y: negativeDisplacement)) - ] - position.calculationMode = .linear - position.isRemovedOnCompletion = false - position.repeatCount = Float.greatestFiniteMagnitude - position.beginTime = CFTimeInterval(Float(arc4random()).truncatingRemainder(dividingBy: Float(25)) / Float(100)) - position.isAdditive = true - - let transform = CAKeyframeAnimation.init(keyPath: "transform") - transform.beginTime = 2.6 - transform.duration = 0.3 - transform.valueFunction = CAValueFunction(name: CAValueFunctionName.rotateZ) - transform.values = [ - degreesToRadians(-1.0 * degreesRotation), - degreesToRadians(degreesRotation), - degreesToRadians(-1.0 * degreesRotation) - ] - transform.calculationMode = .linear - transform.isRemovedOnCompletion = false - transform.repeatCount = Float.greatestFiniteMagnitude - transform.isAdditive = true - transform.beginTime = CFTimeInterval(Float(arc4random()).truncatingRemainder(dividingBy: Float(25)) / Float(100)) - - self.layer.add(position, forKey: "shaking_position") - self.layer.add(transform, forKey: "shaking_rotation") - } - override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { if let deleteButtonNode = self.deleteButtonNode { if deleteButtonNode.frame.insetBy(dx: -4.0, dy: -4.0).contains(point) { diff --git a/submodules/ComponentFlow/Source/Base/Transition.swift b/submodules/ComponentFlow/Source/Base/Transition.swift index d1fc259ff4..6c40e16d12 100644 --- a/submodules/ComponentFlow/Source/Base/Transition.swift +++ b/submodules/ComponentFlow/Source/Base/Transition.swift @@ -695,6 +695,10 @@ public struct ComponentTransition { } public func setSublayerTransform(layer: CALayer, transform: CATransform3D, completion: ((Bool) -> Void)? = nil) { + if CATransform3DEqualToTransform(layer.sublayerTransform, transform) { + completion?(true) + return + } switch self.animation { case .none: layer.sublayerTransform = transform diff --git a/submodules/ContactsPeerItem/Sources/ContactsPeerItem.swift b/submodules/ContactsPeerItem/Sources/ContactsPeerItem.swift index 594cec55dd..40b0a51afa 100644 --- a/submodules/ContactsPeerItem/Sources/ContactsPeerItem.swift +++ b/submodules/ContactsPeerItem/Sources/ContactsPeerItem.swift @@ -824,7 +824,7 @@ public class ContactsPeerItemNode: ItemListRevealOptionsItemNode { credibilityIcon = .text(color: item.presentationData.theme.chat.message.incoming.scamColor, string: item.presentationData.strings.Message_ScamAccount.uppercased()) } else if peer.isFake { credibilityIcon = .text(color: item.presentationData.theme.chat.message.incoming.scamColor, string: item.presentationData.strings.Message_FakeAccount.uppercased()) - } else if let emojiStatus = peer.emojiStatus { + } else if let emojiStatus = peer.emojiStatus, !item.isAd { credibilityIcon = .animation(content: .customEmoji(fileId: emojiStatus.fileId), size: CGSize(width: 20.0, height: 20.0), placeholderColor: item.presentationData.theme.list.mediaPlaceholderColor, themeColor: item.presentationData.theme.list.itemAccentColor, loopMode: .count(2)) if let color = emojiStatus.color { credibilityParticleColor = UIColor(rgb: UInt32(bitPattern: color)) diff --git a/submodules/ContextUI/BUILD b/submodules/ContextUI/BUILD index f9c89d04e8..c65c236c99 100644 --- a/submodules/ContextUI/BUILD +++ b/submodules/ContextUI/BUILD @@ -30,6 +30,7 @@ swift_library( "//submodules/TelegramUI/Components/TabSelectorComponent", "//submodules/TelegramUI/Components/LottieComponent", "//submodules/TelegramUI/Components/PlainButtonComponent", + "//submodules/Components/MultilineTextComponent", "//submodules/UIKitRuntimeUtils", "//submodules/TelegramUI/Components/EmojiStatusComponent", ], diff --git a/submodules/Display/Source/ShakeAnimation.swift b/submodules/Display/Source/ShakeAnimation.swift index cfb1fe333f..8c212b9b6e 100644 --- a/submodules/Display/Source/ShakeAnimation.swift +++ b/submodules/Display/Source/ShakeAnimation.swift @@ -40,4 +40,49 @@ public extension CALayer { self.add(animation, forKey: "shake") } + + func addReorderingShaking() { + func degreesToRadians(_ x: CGFloat) -> CGFloat { + return .pi * x / 180.0 + } + + let duration: Double = 0.4 + let displacement: CGFloat = 1.0 + let degreesRotation: CGFloat = 2.0 + + let negativeDisplacement = -1.0 * displacement + let position = CAKeyframeAnimation.init(keyPath: "position") + position.beginTime = 0.8 + position.duration = duration + position.values = [ + NSValue(cgPoint: CGPoint(x: negativeDisplacement, y: negativeDisplacement)), + NSValue(cgPoint: CGPoint(x: 0, y: 0)), + NSValue(cgPoint: CGPoint(x: negativeDisplacement, y: 0)), + NSValue(cgPoint: CGPoint(x: 0, y: negativeDisplacement)), + NSValue(cgPoint: CGPoint(x: negativeDisplacement, y: negativeDisplacement)) + ] + position.calculationMode = .linear + position.isRemovedOnCompletion = false + position.repeatCount = Float.greatestFiniteMagnitude + position.beginTime = CFTimeInterval(Float(arc4random()).truncatingRemainder(dividingBy: Float(25)) / Float(100)) + position.isAdditive = true + + let transform = CAKeyframeAnimation.init(keyPath: "transform") + transform.beginTime = 2.6 + transform.duration = 0.3 + transform.valueFunction = CAValueFunction(name: CAValueFunctionName.rotateZ) + transform.values = [ + degreesToRadians(-1.0 * degreesRotation), + degreesToRadians(degreesRotation), + degreesToRadians(-1.0 * degreesRotation) + ] + transform.calculationMode = .linear + transform.isRemovedOnCompletion = false + transform.repeatCount = Float.greatestFiniteMagnitude + transform.isAdditive = true + transform.beginTime = CFTimeInterval(Float(arc4random()).truncatingRemainder(dividingBy: Float(25)) / Float(100)) + + self.add(position, forKey: "shaking_position") + self.add(transform, forKey: "shaking_rotation") + } } diff --git a/submodules/DrawingUI/BUILD b/submodules/DrawingUI/BUILD index 4e444e4071..b2be9d65ae 100644 --- a/submodules/DrawingUI/BUILD +++ b/submodules/DrawingUI/BUILD @@ -98,7 +98,6 @@ swift_library( "//submodules/TelegramUI/Components/LottieComponent", "//submodules/TelegramUI/Components/LottieComponentResourceContent", "//submodules/ImageTransparency", - #"//submodules/GalleryUI", "//submodules/MediaPlayer:UniversalMediaPlayer", "//submodules/TelegramUniversalVideoContent", "//submodules/TelegramUI/Components/CameraButtonComponent", diff --git a/submodules/Pasteboard/Sources/Pasteboard.swift b/submodules/Pasteboard/Sources/Pasteboard.swift index 7ffa9fc9d4..fae2182d67 100644 --- a/submodules/Pasteboard/Sources/Pasteboard.swift +++ b/submodules/Pasteboard/Sources/Pasteboard.swift @@ -45,9 +45,185 @@ private func appSpecificStringWithAppliedEntities(_ text: String, entities: [Mes return data } +private func preprocessLists(attributedString: NSAttributedString) -> NSAttributedString { + let result = NSMutableAttributedString() + var listCounters: [NSTextList: Int] = [:] + + let string = attributedString.string + var currentIndex = 0 + + while currentIndex < string.count { + let nsRange = NSRange(location: currentIndex, length: 1) + let attributes = attributedString.attributes(at: currentIndex, effectiveRange: nil) + + if let paragraphStyle = attributes[.paragraphStyle] as? NSParagraphStyle, + !paragraphStyle.textLists.isEmpty { + let listItemRange = findListItemRange(in: attributedString, startingAt: currentIndex) + let listItemSubstring = attributedString.attributedSubstring(from: listItemRange) + + let listMarker = generateListMarker(for: paragraphStyle, counters: &listCounters) + + let newAttributedString = NSMutableAttributedString() + + let markerString = NSMutableAttributedString(string: listMarker) + if let firstCharFont = attributes[.font] { + markerString.addAttribute(.font, value: firstCharFont, range: NSRange(location: 0, length: listMarker.count)) + } + + let newParagraphStyle = NSMutableParagraphStyle() + newParagraphStyle.alignment = paragraphStyle.alignment + newParagraphStyle.lineSpacing = paragraphStyle.lineSpacing + newParagraphStyle.paragraphSpacing = paragraphStyle.paragraphSpacing + newParagraphStyle.paragraphSpacingBefore = paragraphStyle.paragraphSpacingBefore + newParagraphStyle.headIndent = 0 + newParagraphStyle.tailIndent = paragraphStyle.tailIndent + newParagraphStyle.firstLineHeadIndent = 0 + newParagraphStyle.lineBreakMode = paragraphStyle.lineBreakMode + newParagraphStyle.minimumLineHeight = paragraphStyle.minimumLineHeight + newParagraphStyle.maximumLineHeight = paragraphStyle.maximumLineHeight + newParagraphStyle.baseWritingDirection = paragraphStyle.baseWritingDirection + newParagraphStyle.lineHeightMultiple = paragraphStyle.lineHeightMultiple + newParagraphStyle.hyphenationFactor = paragraphStyle.hyphenationFactor + newParagraphStyle.tabStops = paragraphStyle.tabStops + newParagraphStyle.defaultTabInterval = paragraphStyle.defaultTabInterval + newParagraphStyle.allowsDefaultTighteningForTruncation = paragraphStyle.allowsDefaultTighteningForTruncation + + markerString.addAttribute(.paragraphStyle, value: newParagraphStyle, range: NSRange(location: 0, length: listMarker.count)) + newAttributedString.append(markerString) + + let cleanedListItem = NSMutableAttributedString() + listItemSubstring.enumerateAttributes(in: NSRange(location: 0, length: listItemSubstring.length), options: []) { itemAttributes, itemRange, _ in + let itemSubstring = listItemSubstring.attributedSubstring(from: itemRange) + let cleanedItemString = NSMutableAttributedString(attributedString: itemSubstring) + + if let itemParagraphStyle = itemAttributes[.paragraphStyle] as? NSParagraphStyle, + !itemParagraphStyle.textLists.isEmpty { + cleanedItemString.addAttribute(.paragraphStyle, value: newParagraphStyle, range: NSRange(location: 0, length: cleanedItemString.length)) + } + + cleanedListItem.append(cleanedItemString) + } + newAttributedString.append(cleanedListItem) + result.append(newAttributedString) + currentIndex = listItemRange.location + listItemRange.length + } else { + let charSubstring = attributedString.attributedSubstring(from: nsRange) + result.append(charSubstring) + currentIndex += 1 + } + } + + return result +} + +private func findListItemRange(in attributedString: NSAttributedString, startingAt index: Int) -> NSRange { + let string = attributedString.string + let startIndex = string.index(string.startIndex, offsetBy: index) + + var endIndex = startIndex + while endIndex < string.endIndex { + let character = string[endIndex] + if character == "\n" { + endIndex = string.index(after: endIndex) + break + } + endIndex = string.index(after: endIndex) + } + + let length = string.distance(from: startIndex, to: endIndex) + return NSRange(location: index, length: length) +} + +private func generateListMarker(for paragraphStyle: NSParagraphStyle, counters: inout [NSTextList: Int]) -> String { + guard let textList = paragraphStyle.textLists.first else { return "" } + + if counters[textList] == nil { + counters[textList] = 0 + } + counters[textList]! += 1 + + let currentIndex = counters[textList]! + let format = textList.markerFormat + + let marker = generateMarkerText(format: format.rawValue, index: currentIndex) + + return marker + " " +} + +private func generateMarkerText(format: String, index: Int) -> String { + switch format { + case "{decimal}": + return "\(index)." + case "{lower-alpha}": + return "\(indexToLowerAlpha(index))." + case "{upper-alpha}": + return "\(indexToUpperAlpha(index))." + case "{lower-roman}": + return "\(indexToRoman(index))." + case "{upper-roman}": + return "\(indexToRoman(index).uppercased())." + case "{disc}": + return "•" + case "{circle}": + return "◦" + case "{square}": + return "▪" + case "{hyphen}": + return "-" + case "{\"": + return "-" + default: + if format.contains("decimal") { + return "\(index)." + } else if format.contains("alpha") { + return "\(indexToLowerAlpha(index))." + } else if format.contains("roman") { + return "\(indexToRoman(index))." + } else { + return "•" + } + } +} + +private func indexToLowerAlpha(_ index: Int) -> String { + let alphabet = "abcdefghijklmnopqrstuvwxyz" + let alphabetArray = Array(alphabet) + + if index <= 26 { + return String(alphabetArray[index - 1]) + } else { + let letterIndex = (index - 1) % 26 + let repeatCount = (index - 1) / 26 + 1 + return String(repeating: String(alphabetArray[letterIndex]), count: repeatCount) + } +} + +private func indexToUpperAlpha(_ index: Int) -> String { + return indexToLowerAlpha(index).uppercased() +} + +private func indexToRoman(_ index: Int) -> String { + let romanNumerals = [ + (1000, "m"), (900, "cm"), (500, "d"), (400, "cd"), + (100, "c"), (90, "xc"), (50, "l"), (40, "xl"), + (10, "x"), (9, "ix"), (5, "v"), (4, "iv"), (1, "i") + ] + var result = "" + var number = index + for (value, numeral) in romanNumerals { + while number >= value { + result += numeral + number -= value + } + } + return result +} + private func chatInputStateString(attributedString: NSAttributedString) -> NSAttributedString? { - let string = NSMutableAttributedString(string: attributedString.string) - attributedString.enumerateAttributes(in: NSRange(location: 0, length: attributedString.length), options: [], using: { attributes, range, _ in + let preprocessedString = preprocessLists(attributedString: attributedString) + + let string = NSMutableAttributedString(string: preprocessedString.string) + preprocessedString.enumerateAttributes(in: NSRange(location: 0, length: attributedString.length), options: [], using: { attributes, range, _ in if let value = attributes[.link], let url = (value as? URL)?.absoluteString { string.addAttribute(ChatTextInputAttributes.textUrl, value: ChatTextInputTextUrlAttribute(url: url), range: range) } diff --git a/submodules/PremiumUI/Sources/PremiumIntroScreen.swift b/submodules/PremiumUI/Sources/PremiumIntroScreen.swift index 9f5316aa71..76fba73534 100644 --- a/submodules/PremiumUI/Sources/PremiumIntroScreen.swift +++ b/submodules/PremiumUI/Sources/PremiumIntroScreen.swift @@ -934,7 +934,7 @@ private struct PremiumProduct: Equatable { let storeProduct: InAppPurchaseManager.Product var id: String { - return self.storeProduct.id + return self.option.storeProductId ?? self.storeProduct.id } var months: Int32 { @@ -1551,6 +1551,10 @@ private final class PremiumIntroScreenContentComponent: CombinedComponent { return self.products?.first(where: { $0.id == self.selectedProductId })?.id.hasSuffix(".annual") ?? false } + var isBiannual: Bool { + return self.products?.first(where: { $0.id == self.selectedProductId })?.id.hasSuffix(".biannual") ?? false + } + var canUpgrade: Bool { if let products = self.products, let current = products.first(where: { $0.isCurrent }), let transactionId = current.transactionId { if self.validPurchases.contains(where: { $0.transactionId == transactionId }) { @@ -1970,6 +1974,8 @@ private final class PremiumIntroScreenContentComponent: CombinedComponent { giftTitle = strings.Premium_Monthly } else if product.id.hasSuffix(".semiannual") { giftTitle = strings.Premium_Semiannual + } else if product.id.hasSuffix(".biannual") { + giftTitle = strings.Premium_Biannual } else { giftTitle = strings.Premium_Annual } @@ -1994,7 +2000,10 @@ private final class PremiumIntroScreenContentComponent: CombinedComponent { if discountValue > 0 { subtitle = "**\(defaultPrice)** \(product.price)" accessibilitySubtitle = product.price - if product.months == 12 { + if product.months == 24 { + subtitle = environment.strings.Premium_PricePer2Years(subtitle).string + accessibilitySubtitle = environment.strings.Premium_PricePer2Years(accessibilitySubtitle).string + } else if product.months == 12 { subtitle = environment.strings.Premium_PricePerYear(subtitle).string accessibilitySubtitle = environment.strings.Premium_PricePerYear(accessibilitySubtitle).string } @@ -2174,8 +2183,21 @@ private final class PremiumIntroScreenContentComponent: CombinedComponent { } let isPremium = state?.isPremium == true + let buttonText: String + if isPremium { + buttonText = strings.Common_OK + } else { + if state?.isAnnual == true { + buttonText = strings.Premium_SubscribeForAnnual(state?.price ?? "—").string + } else if state?.isBiannual == true { + buttonText = strings.Premium_SubscribeForBiannual(state?.price ?? "—").string + } else { + buttonText = strings.Premium_SubscribeFor(state?.price ?? "–").string + } + } + var dismissImpl: (() -> Void)? - let controller = PremiumLimitsListScreen(context: accountContext, subject: demoSubject, source: .intro(state?.price), order: state?.configuration.perks, buttonText: isPremium ? strings.Common_OK : (state?.isAnnual == true ? strings.Premium_SubscribeForAnnual(state?.price ?? "—").string : strings.Premium_SubscribeFor(state?.price ?? "–").string), isPremium: isPremium, forceDark: forceDark) + let controller = PremiumLimitsListScreen(context: accountContext, subject: demoSubject, source: .intro(state?.price), order: state?.configuration.perks, buttonText: buttonText, isPremium: isPremium, forceDark: forceDark) controller.action = { [weak state] in dismissImpl?() if state?.isPremium == false { @@ -2404,8 +2426,23 @@ private final class PremiumIntroScreenContentComponent: CombinedComponent { default: fatalError() } + + + let buttonText: String + if isPremium { + buttonText = strings.Common_OK + } else { + if state?.isAnnual == true { + buttonText = strings.Premium_SubscribeForAnnual(state?.price ?? "—").string + } else if state?.isBiannual == true { + buttonText = strings.Premium_SubscribeForBiannual(state?.price ?? "—").string + } else { + buttonText = strings.Premium_SubscribeFor(state?.price ?? "–").string + } + } + var dismissImpl: (() -> Void)? - let controller = PremiumLimitsListScreen(context: accountContext, subject: demoSubject, source: .intro(state?.price), order: state?.configuration.businessPerks, buttonText: isPremium ? strings.Common_OK : (state?.isAnnual == true ? strings.Premium_SubscribeForAnnual(state?.price ?? "—").string : strings.Premium_SubscribeFor(state?.price ?? "–").string), isPremium: isPremium, forceDark: forceDark) + let controller = PremiumLimitsListScreen(context: accountContext, subject: demoSubject, source: .intro(state?.price), order: state?.configuration.businessPerks, buttonText: buttonText, isPremium: isPremium, forceDark: forceDark) controller.action = { [weak state] in dismissImpl?() if state?.isPremium == false { @@ -2965,6 +3002,10 @@ private final class PremiumIntroScreenComponent: CombinedComponent { return self.products?.first(where: { $0.id == self.selectedProductId })?.id.hasSuffix(".annual") ?? false } + var isBiannual: Bool { + return self.products?.first(where: { $0.id == self.selectedProductId })?.id.hasSuffix(".biannual") ?? false + } + var canUpgrade: Bool { if let products = self.products, let current = products.first(where: { $0.isCurrent }), let transactionId = current.transactionId { if self.validPurchases.contains(where: { $0.transactionId == transactionId }) { @@ -3056,6 +3097,12 @@ private final class PremiumIntroScreenComponent: CombinedComponent { } } + //TODO:release + if let product = availableProducts.first(where: { $0.id.hasSuffix(".annual") }) { + let (currency, price) = product.priceCurrencyAndAmount + products.insert(PremiumProduct(option: PremiumPromoConfiguration.PremiumProductOption(isCurrent: false, months: 24, currency: currency, amount: price * 2, botUrl: "", transactionId: nil, availableForUpgrade: true, storeProductId: "org.telegram.telegramPremium.biannual"), storeProduct: product), at: 0) + } + strongSelf.products = products strongSelf.isPremium = forceHasPremium || isPremium strongSelf.otherPeerName = otherPeerName @@ -3719,7 +3766,13 @@ private final class PremiumIntroScreenComponent: CombinedComponent { } else if state.isPremium == true && state.canUpgrade { buttonTitle = state.isAnnual ? environment.strings.Premium_UpgradeForAnnual(state.price ?? "—").string : environment.strings.Premium_UpgradeFor(state.price ?? "—").string } else { - buttonTitle = state.isAnnual ? environment.strings.Premium_SubscribeForAnnual(state.price ?? "—").string : environment.strings.Premium_SubscribeFor(state.price ?? "—").string + if state.isAnnual { + buttonTitle = environment.strings.Premium_SubscribeForAnnual(state.price ?? "—").string + } else if state.isBiannual { + buttonTitle = environment.strings.Premium_SubscribeForBiannual(state.price ?? "—").string + } else { + buttonTitle = environment.strings.Premium_SubscribeFor(state.price ?? "–").string + } } let controller = environment.controller diff --git a/submodules/ShareController/Sources/ShareController.swift b/submodules/ShareController/Sources/ShareController.swift index d8d79b653e..c5622becc0 100644 --- a/submodules/ShareController/Sources/ShareController.swift +++ b/submodules/ShareController/Sources/ShareController.swift @@ -437,10 +437,15 @@ public final class ShareController: ViewController { } } } - - public var openShareAsImage: (([Message]) -> Void)? - + public var shareStory: (() -> Void)? + public var canSendInHighQuality = false { + didSet { + if self.isNodeLoaded { + self.controllerNode.canSendInHighQuality = self.canSendInHighQuality + } + } + } public var debugAction: (() -> Void)? @@ -692,14 +697,46 @@ public final class ShareController: ViewController { mediaParameters = parameters } - self.displayNode = ShareControllerNode(controller: self, environment: self.environment, presentationData: self.presentationData, presetText: self.presetText, defaultAction: self.defaultAction, mediaParameters: mediaParameters, requestLayout: { [weak self] transition in - self?.requestLayout(transition: transition) - }, presentError: { [weak self] title, text in - guard let strongSelf = self else { - return - } - strongSelf.present(standardTextAlertController(theme: AlertControllerTheme(presentationData: strongSelf.presentationData), title: title, text: text, actions: [TextAlertAction(type: .defaultAction, title: strongSelf.presentationData.strings.Common_OK, action: {})]), in: .window(.root)) - }, externalShare: self.externalShare, immediateExternalShare: self.immediateExternalShare, immediatePeerId: self.immediatePeerId, fromForeignApp: self.fromForeignApp, forceTheme: self.forceTheme, fromPublicChannel: fromPublicChannel, segmentedValues: self.segmentedValues, shareStory: self.shareStory, collectibleItemInfo: self.collectibleItemInfo, messageCount: messageCount) + self.displayNode = ShareControllerNode( + controller: self, + environment: self.environment, + presentationData: self.presentationData, + presetText: self.presetText, + defaultAction: self.defaultAction, + mediaParameters: mediaParameters, + requestLayout: { [weak self] transition in + self?.requestLayout( + transition: transition + ) + }, + presentError: { [weak self] title, text in + guard let strongSelf = self else { + return + } + strongSelf.present(standardTextAlertController( + theme: AlertControllerTheme(presentationData: strongSelf.presentationData), + title: title, + text: text, + actions: [TextAlertAction( + type: .defaultAction, + title: strongSelf.presentationData.strings.Common_OK, + action: { + }) + ] + ), in: .window(.root)) + }, + externalShare: self.externalShare, + immediateExternalShare: self.immediateExternalShare, + immediatePeerId: self.immediatePeerId, + fromForeignApp: self.fromForeignApp, + forceTheme: self.forceTheme, + fromPublicChannel: fromPublicChannel, + segmentedValues: self.segmentedValues, + shareStory: self.shareStory, + collectibleItemInfo: self.collectibleItemInfo, + messageCount: messageCount + ) + self.controllerNode.canSendInHighQuality = self.canSendInHighQuality self.controllerNode.completed = self.completed self.controllerNode.enqueued = self.enqueued self.controllerNode.present = { [weak self] c in diff --git a/submodules/ShareController/Sources/ShareControllerNode.swift b/submodules/ShareController/Sources/ShareControllerNode.swift index 7090a6c7f2..f73982e7c3 100644 --- a/submodules/ShareController/Sources/ShareControllerNode.swift +++ b/submodules/ShareController/Sources/ShareControllerNode.swift @@ -329,6 +329,7 @@ final class ShareControllerNode: ViewControllerTracingNode, ASScrollViewDelegate private let collectibleItemInfo: TelegramCollectibleItemInfo? private let mediaParameters: ShareControllerSubject.MediaParameters? private let messageCount: Int + var canSendInHighQuality = false var selectedSegmentedIndex: Int = 0 @@ -389,7 +390,26 @@ final class ShareControllerNode: ViewControllerTracingNode, ASScrollViewDelegate private let showNames = ValuePromise(true) - init(controller: ShareController, environment: ShareControllerEnvironment, presentationData: PresentationData, presetText: String?, defaultAction: ShareControllerAction?, mediaParameters: ShareControllerSubject.MediaParameters?, requestLayout: @escaping (ContainedViewLayoutTransition) -> Void, presentError: @escaping (String?, String) -> Void, externalShare: Bool, immediateExternalShare: Bool, immediatePeerId: PeerId?, fromForeignApp: Bool, forceTheme: PresentationTheme?, fromPublicChannel: Bool, segmentedValues: [ShareControllerSegmentedValue]?, shareStory: (() -> Void)?, collectibleItemInfo: TelegramCollectibleItemInfo?, messageCount: Int) { + init( + controller: ShareController, + environment: ShareControllerEnvironment, + presentationData: PresentationData, + presetText: String?, + defaultAction: ShareControllerAction?, + mediaParameters: ShareControllerSubject.MediaParameters?, + requestLayout: @escaping (ContainedViewLayoutTransition) -> Void, + presentError: @escaping (String?, String) -> Void, + externalShare: Bool, + immediateExternalShare: Bool, + immediatePeerId: PeerId?, + fromForeignApp: Bool, + forceTheme: PresentationTheme?, + fromPublicChannel: Bool, + segmentedValues: [ShareControllerSegmentedValue]?, + shareStory: (() -> Void)?, + collectibleItemInfo: TelegramCollectibleItemInfo?, + messageCount: Int + ) { self.controller = controller self.environment = environment self.presentationData = presentationData @@ -544,6 +564,18 @@ final class ShareControllerNode: ViewControllerTracingNode, ASScrollViewDelegate .separator, ]) } + + if fromForeignApp, strongSelf.canSendInHighQuality { + items.append( + .action(ContextMenuActionItem(text: presentationData.strings.Attachment_SendInHd, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/QualityHd"), color: theme.contextMenu.primaryColor) }, action: { _, f in + f(.default) + if let strongSelf = self { + strongSelf.send(showNames: showNamesValue, silently: true) + } + })) + ) + } + items.append(contentsOf: [ .action(ContextMenuActionItem(text: presentationData.strings.Conversation_SendMessage_SendSilently, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Input/Menu/SilentIcon"), color: theme.contextMenu.primaryColor) }, action: { _, f in f(.default) diff --git a/submodules/SparseItemGrid/Sources/SparseItemGrid.swift b/submodules/SparseItemGrid/Sources/SparseItemGrid.swift index 44c13bb307..b965e89c96 100644 --- a/submodules/SparseItemGrid/Sources/SparseItemGrid.swift +++ b/submodules/SparseItemGrid/Sources/SparseItemGrid.swift @@ -1251,7 +1251,7 @@ public final class SparseItemGrid: ASDisplayNode { if self.isReordering, let contentItem, contentItem.isReorderable { if layer.animation(forKey: "shaking_position") == nil { - startShaking(layer: layer) + layer.addReorderingShaking() } } else { if layer.animation(forKey: "shaking_position") != nil { @@ -2327,51 +2327,6 @@ public final class SparseItemGrid: ASDisplayNode { } } -private func startShaking(layer: CALayer) { - func degreesToRadians(_ x: CGFloat) -> CGFloat { - return .pi * x / 180.0 - } - - let duration: Double = 0.4 - let displacement: CGFloat = 1.0 - let degreesRotation: CGFloat = 2.0 - - let negativeDisplacement = -1.0 * displacement - let position = CAKeyframeAnimation.init(keyPath: "position") - position.beginTime = 0.8 - position.duration = duration - position.values = [ - NSValue(cgPoint: CGPoint(x: negativeDisplacement, y: negativeDisplacement)), - NSValue(cgPoint: CGPoint(x: 0, y: 0)), - NSValue(cgPoint: CGPoint(x: negativeDisplacement, y: 0)), - NSValue(cgPoint: CGPoint(x: 0, y: negativeDisplacement)), - NSValue(cgPoint: CGPoint(x: negativeDisplacement, y: negativeDisplacement)) - ] - position.calculationMode = .linear - position.isRemovedOnCompletion = false - position.repeatCount = Float.greatestFiniteMagnitude - position.beginTime = CFTimeInterval(Float(arc4random()).truncatingRemainder(dividingBy: Float(25)) / Float(100)) - position.isAdditive = true - - let transform = CAKeyframeAnimation.init(keyPath: "transform") - transform.beginTime = 2.6 - transform.duration = 0.3 - transform.valueFunction = CAValueFunction(name: CAValueFunctionName.rotateZ) - transform.values = [ - degreesToRadians(-1.0 * degreesRotation), - degreesToRadians(degreesRotation), - degreesToRadians(-1.0 * degreesRotation) - ] - transform.calculationMode = .linear - transform.isRemovedOnCompletion = false - transform.repeatCount = Float.greatestFiniteMagnitude - transform.isAdditive = true - transform.beginTime = CFTimeInterval(Float(arc4random()).truncatingRemainder(dividingBy: Float(25)) / Float(100)) - - layer.add(position, forKey: "shaking_position") - layer.add(transform, forKey: "shaking_rotation") -} - private final class ReorderGestureRecognizer: UIGestureRecognizer { private let shouldBegin: (CGPoint) -> (allowed: Bool, requiresLongPress: Bool, item: SparseItemGridDisplayItem?) private let willBegin: (CGPoint) -> Void diff --git a/submodules/TelegramApi/Sources/Api0.swift b/submodules/TelegramApi/Sources/Api0.swift index b837fa1af1..0bf257604c 100644 --- a/submodules/TelegramApi/Sources/Api0.swift +++ b/submodules/TelegramApi/Sources/Api0.swift @@ -880,7 +880,7 @@ fileprivate let parsers: [Int32 : (BufferReader) -> Any?] = { dict[1681948327] = { return Api.SavedDialog.parse_monoForumDialog($0) } dict[-1115174036] = { return Api.SavedDialog.parse_savedDialog($0) } dict[-881854424] = { return Api.SavedReactionTag.parse_savedReactionTag($0) } - dict[-539360103] = { return Api.SavedStarGift.parse_savedStarGift($0) } + dict[514213599] = { return Api.SavedStarGift.parse_savedStarGift($0) } dict[-911191137] = { return Api.SearchResultsCalendarPeriod.parse_searchResultsCalendarPeriod($0) } dict[2137295719] = { return Api.SearchResultsPosition.parse_searchResultPosition($0) } dict[871426631] = { return Api.SecureCredentialsEncrypted.parse_secureCredentialsEncrypted($0) } diff --git a/submodules/TelegramApi/Sources/Api23.swift b/submodules/TelegramApi/Sources/Api23.swift index f9353065f0..15fd2c143c 100644 --- a/submodules/TelegramApi/Sources/Api23.swift +++ b/submodules/TelegramApi/Sources/Api23.swift @@ -196,13 +196,13 @@ public extension Api { } public extension Api { enum SavedStarGift: TypeConstructorDescription { - case savedStarGift(flags: Int32, fromId: Api.Peer?, date: Int32, gift: Api.StarGift, message: Api.TextWithEntities?, msgId: Int32?, savedId: Int64?, convertStars: Int64?, upgradeStars: Int64?, canExportAt: Int32?, transferStars: Int64?, canTransferAt: Int32?, canResellAt: Int32?) + case savedStarGift(flags: Int32, fromId: Api.Peer?, date: Int32, gift: Api.StarGift, message: Api.TextWithEntities?, msgId: Int32?, savedId: Int64?, convertStars: Int64?, upgradeStars: Int64?, canExportAt: Int32?, transferStars: Int64?, canTransferAt: Int32?, canResellAt: Int32?, collectionId: [Int32]?) public func serialize(_ buffer: Buffer, _ boxed: Swift.Bool) { switch self { - case .savedStarGift(let flags, let fromId, let date, let gift, let message, let msgId, let savedId, let convertStars, let upgradeStars, let canExportAt, let transferStars, let canTransferAt, let canResellAt): + case .savedStarGift(let flags, let fromId, let date, let gift, let message, let msgId, let savedId, let convertStars, let upgradeStars, let canExportAt, let transferStars, let canTransferAt, let canResellAt, let collectionId): if boxed { - buffer.appendInt32(-539360103) + buffer.appendInt32(514213599) } serializeInt32(flags, buffer: buffer, boxed: false) if Int(flags) & Int(1 << 1) != 0 {fromId!.serialize(buffer, true)} @@ -217,14 +217,19 @@ public extension Api { if Int(flags) & Int(1 << 8) != 0 {serializeInt64(transferStars!, buffer: buffer, boxed: false)} if Int(flags) & Int(1 << 13) != 0 {serializeInt32(canTransferAt!, buffer: buffer, boxed: false)} if Int(flags) & Int(1 << 14) != 0 {serializeInt32(canResellAt!, buffer: buffer, boxed: false)} + if Int(flags) & Int(1 << 15) != 0 {buffer.appendInt32(481674261) + buffer.appendInt32(Int32(collectionId!.count)) + for item in collectionId! { + serializeInt32(item, buffer: buffer, boxed: false) + }} break } } public func descriptionFields() -> (String, [(String, Any)]) { switch self { - case .savedStarGift(let flags, let fromId, let date, let gift, let message, let msgId, let savedId, let convertStars, let upgradeStars, let canExportAt, let transferStars, let canTransferAt, let canResellAt): - return ("savedStarGift", [("flags", flags as Any), ("fromId", fromId as Any), ("date", date as Any), ("gift", gift as Any), ("message", message as Any), ("msgId", msgId as Any), ("savedId", savedId as Any), ("convertStars", convertStars as Any), ("upgradeStars", upgradeStars as Any), ("canExportAt", canExportAt as Any), ("transferStars", transferStars as Any), ("canTransferAt", canTransferAt as Any), ("canResellAt", canResellAt as Any)]) + case .savedStarGift(let flags, let fromId, let date, let gift, let message, let msgId, let savedId, let convertStars, let upgradeStars, let canExportAt, let transferStars, let canTransferAt, let canResellAt, let collectionId): + return ("savedStarGift", [("flags", flags as Any), ("fromId", fromId as Any), ("date", date as Any), ("gift", gift as Any), ("message", message as Any), ("msgId", msgId as Any), ("savedId", savedId as Any), ("convertStars", convertStars as Any), ("upgradeStars", upgradeStars as Any), ("canExportAt", canExportAt as Any), ("transferStars", transferStars as Any), ("canTransferAt", canTransferAt as Any), ("canResellAt", canResellAt as Any), ("collectionId", collectionId as Any)]) } } @@ -261,6 +266,10 @@ public extension Api { if Int(_1!) & Int(1 << 13) != 0 {_12 = reader.readInt32() } var _13: Int32? if Int(_1!) & Int(1 << 14) != 0 {_13 = reader.readInt32() } + var _14: [Int32]? + if Int(_1!) & Int(1 << 15) != 0 {if let _ = reader.readInt32() { + _14 = Api.parseVector(reader, elementSignature: -1471112230, elementType: Int32.self) + } } let _c1 = _1 != nil let _c2 = (Int(_1!) & Int(1 << 1) == 0) || _2 != nil let _c3 = _3 != nil @@ -274,8 +283,9 @@ public extension Api { let _c11 = (Int(_1!) & Int(1 << 8) == 0) || _11 != nil let _c12 = (Int(_1!) & Int(1 << 13) == 0) || _12 != nil let _c13 = (Int(_1!) & Int(1 << 14) == 0) || _13 != nil - if _c1 && _c2 && _c3 && _c4 && _c5 && _c6 && _c7 && _c8 && _c9 && _c10 && _c11 && _c12 && _c13 { - return Api.SavedStarGift.savedStarGift(flags: _1!, fromId: _2, date: _3!, gift: _4!, message: _5, msgId: _6, savedId: _7, convertStars: _8, upgradeStars: _9, canExportAt: _10, transferStars: _11, canTransferAt: _12, canResellAt: _13) + let _c14 = (Int(_1!) & Int(1 << 15) == 0) || _14 != nil + if _c1 && _c2 && _c3 && _c4 && _c5 && _c6 && _c7 && _c8 && _c9 && _c10 && _c11 && _c12 && _c13 && _c14 { + return Api.SavedStarGift.savedStarGift(flags: _1!, fromId: _2, date: _3!, gift: _4!, message: _5, msgId: _6, savedId: _7, convertStars: _8, upgradeStars: _9, canExportAt: _10, transferStars: _11, canTransferAt: _12, canResellAt: _13, collectionId: _14) } else { return nil diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Payments/StarGifts.swift b/submodules/TelegramCore/Sources/TelegramEngine/Payments/StarGifts.swift index 07ccc2de08..7ea5299346 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Payments/StarGifts.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Payments/StarGifts.swift @@ -1087,7 +1087,8 @@ func _internal_upgradeStarGift(account: Account, formId: Int64?, reference: Star upgradeStars: nil, transferStars: transferStars, canTransferDate: canTransferDate, - canResaleDate: canResaleDate + canResaleDate: canResaleDate, + collectionIds: nil )) } } @@ -1728,6 +1729,16 @@ private final class ProfileGiftsContextImpl { } self.gifts = updatedGifts self.pushState() + + let updatedCount = self.count ?? 0 + + let peerId = self.peerId + let collectionId = self.collectionId + self.cacheDisposable.set(self.account.postbox.transaction { transaction in + if let entry = CodableEntry(CachedProfileGifts(gifts: updatedGifts, count: updatedCount, notificationsEnabled: nil)) { + transaction.putItemCacheEntry(id: giftsEntryId(peerId: peerId, collectionId: collectionId), entry: entry) + } + }.start()) } func upgradeStarGift(formId: Int64?, reference: StarGiftReference, keepOriginalInfo: Bool) -> Signal { @@ -1929,6 +1940,7 @@ public final class ProfileGiftsContext { case giftAddress case canTransferDate case canResaleDate + case collectionIds } public let gift: TelegramCore.StarGift @@ -1947,7 +1959,8 @@ public final class ProfileGiftsContext { public let transferStars: Int64? public let canTransferDate: Int32? public let canResaleDate: Int32? - + public let collectionIds: [Int32]? + fileprivate let _fromPeerId: EnginePeer.Id? public enum DecodingError: Error { @@ -1970,7 +1983,8 @@ public final class ProfileGiftsContext { upgradeStars: Int64?, transferStars: Int64?, canTransferDate: Int32?, - canResaleDate: Int32? + canResaleDate: Int32?, + collectionIds: [Int32]? ) { self.gift = gift self.reference = reference @@ -1989,6 +2003,7 @@ public final class ProfileGiftsContext { self.transferStars = transferStars self.canTransferDate = canTransferDate self.canResaleDate = canResaleDate + self.collectionIds = collectionIds } public init(from decoder: Decoder) throws { @@ -2017,6 +2032,7 @@ public final class ProfileGiftsContext { self.transferStars = try container.decodeIfPresent(Int64.self, forKey: .transferStars) self.canTransferDate = try container.decodeIfPresent(Int32.self, forKey: .canTransferDate) self.canResaleDate = try container.decodeIfPresent(Int32.self, forKey: .canResaleDate) + self.collectionIds = try container.decodeIfPresent([Int32].self, forKey: .collectionIds) } public func encode(to encoder: Encoder) throws { @@ -2038,6 +2054,7 @@ public final class ProfileGiftsContext { try container.encodeIfPresent(self.transferStars, forKey: .transferStars) try container.encodeIfPresent(self.canTransferDate, forKey: .canTransferDate) try container.encodeIfPresent(self.canResaleDate, forKey: .canResaleDate) + try container.encodeIfPresent(self.collectionIds, forKey: .collectionIds) } public func withGift(_ gift: TelegramCore.StarGift) -> StarGift { @@ -2057,7 +2074,8 @@ public final class ProfileGiftsContext { upgradeStars: self.upgradeStars, transferStars: self.transferStars, canTransferDate: self.canTransferDate, - canResaleDate: self.canResaleDate + canResaleDate: self.canResaleDate, + collectionIds: self.collectionIds ) } @@ -2078,7 +2096,8 @@ public final class ProfileGiftsContext { upgradeStars: self.upgradeStars, transferStars: self.transferStars, canTransferDate: self.canTransferDate, - canResaleDate: self.canResaleDate + canResaleDate: self.canResaleDate, + collectionIds: self.collectionIds ) } @@ -2099,7 +2118,8 @@ public final class ProfileGiftsContext { upgradeStars: self.upgradeStars, transferStars: self.transferStars, canTransferDate: self.canTransferDate, - canResaleDate: self.canResaleDate + canResaleDate: self.canResaleDate, + collectionIds: self.collectionIds ) } fileprivate func withFromPeer(_ fromPeer: EnginePeer?) -> StarGift { @@ -2119,7 +2139,8 @@ public final class ProfileGiftsContext { upgradeStars: self.upgradeStars, transferStars: self.transferStars, canTransferDate: self.canTransferDate, - canResaleDate: self.canResaleDate + canResaleDate: self.canResaleDate, + collectionIds: self.collectionIds ) } } @@ -2323,7 +2344,7 @@ public final class ProfileGiftsContext { extension ProfileGiftsContext.State.StarGift { init?(apiSavedStarGift: Api.SavedStarGift, peerId: EnginePeer.Id, transaction: Transaction) { switch apiSavedStarGift { - case let .savedStarGift(flags, fromId, date, apiGift, message, msgId, savedId, convertStars, upgradeStars, canExportDate, transferStars, canTransferAt, canResaleAt): + case let .savedStarGift(flags, fromId, date, apiGift, message, msgId, savedId, convertStars, upgradeStars, canExportDate, transferStars, canTransferAt, canResaleAt, collectionIds): guard let gift = StarGift(apiStarGift: apiGift) else { return nil } @@ -2369,6 +2390,7 @@ extension ProfileGiftsContext.State.StarGift { self.transferStars = transferStars self.canTransferDate = canTransferAt self.canResaleDate = canResaleAt + self.collectionIds = collectionIds } } } diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Payments/StarGiftsCollections.swift b/submodules/TelegramCore/Sources/TelegramEngine/Payments/StarGiftsCollections.swift index 153cf1619a..e2244884bf 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Payments/StarGiftsCollections.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Payments/StarGiftsCollections.swift @@ -98,8 +98,8 @@ private func _internal_getStarGiftCollections(postbox: Postbox, network: Network let collections = transaction.retrieveItemCacheEntry(id: entryId(peerId: peerId))?.get(CachedProfileGiftsCollections.self) return (inputPeer, collections?.collections) } - |> mapToSignal { inputPeerAndHash -> Signal<[StarGiftCollection]?, NoError> in - guard let (inputPeer, cachedCollections) = inputPeerAndHash else { + |> mapToSignal { inputPeerAndCollections -> Signal<[StarGiftCollection]?, NoError> in + guard let (inputPeer, cachedCollections) = inputPeerAndCollections else { return .single(nil) } @@ -108,28 +108,28 @@ private func _internal_getStarGiftCollections(postbox: Postbox, network: Network hash = intListSimpleHash(cachedCollections.map { $0.hash }) } - return network.request(Api.functions.payments.getStarGiftCollections(peer: inputPeer, hash: hash)) - |> map(Optional.init) - |> `catch` { _ -> Signal in - return .single(nil) - } - |> mapToSignal { result -> Signal<[StarGiftCollection]?, NoError> in - guard let result else { + return .single(cachedCollections) + |> then( + network.request(Api.functions.payments.getStarGiftCollections(peer: inputPeer, hash: hash)) + |> map(Optional.init) + |> `catch` { _ -> Signal in return .single(nil) } - return postbox.transaction { transaction -> [StarGiftCollection]? in - switch result { - case let .starGiftCollections(collections): - let collections = collections.compactMap { StarGiftCollection(apiStarGiftCollection: $0) } - if let entry = CodableEntry(CachedProfileGiftsCollections(collections: collections)) { - transaction.putItemCacheEntry(id: entryId(peerId: peerId), entry: entry) + |> mapToSignal { result -> Signal<[StarGiftCollection]?, NoError> in + guard let result else { + return .single(nil) + } + return postbox.transaction { transaction -> [StarGiftCollection]? in + switch result { + case let .starGiftCollections(collections): + let collections = collections.compactMap { StarGiftCollection(apiStarGiftCollection: $0) } + return collections + case .starGiftCollectionsNotModified: + return cachedCollections ?? [] } - return collections - case .starGiftCollectionsNotModified: - return cachedCollections ?? [] } } - } + ) } } @@ -341,6 +341,7 @@ public final class ProfileGiftsCollectionsContext { self.collections = collections ?? [] self.isLoading = false self.pushState() + self.updateCache() })) } @@ -354,6 +355,7 @@ public final class ProfileGiftsCollectionsContext { if let collection { self.collections.append(collection) self.pushState() + self.updateCache() } } } @@ -370,6 +372,7 @@ public final class ProfileGiftsCollectionsContext { if let index = self.collections.firstIndex(where: { $0.id == id }) { self.collections[index] = collection self.pushState() + self.updateCache() } } } @@ -392,7 +395,8 @@ public final class ProfileGiftsCollectionsContext { } public func reorderCollections(order: [Int32]) -> Signal { - return _internal_reorderStarGiftCollections(account: self.account, peerId: self.peerId, order: order) + let peerId = self.peerId + return _internal_reorderStarGiftCollections(account: self.account, peerId: peerId, order: order) |> deliverOn(self.queue) |> afterNext { [weak self] collection in guard let self else { @@ -410,6 +414,7 @@ public final class ProfileGiftsCollectionsContext { } self.collections = collections self.pushState() + self.updateCache() } } @@ -423,9 +428,20 @@ public final class ProfileGiftsCollectionsContext { self.giftsContexts.removeValue(forKey: id) self.collections.removeAll(where: { $0.id == id }) self.pushState() + self.updateCache() } } + private func updateCache() { + let peerId = self.peerId + let collections = self.collections + let _ = (self.account.postbox.transaction { transaction in + if let entry = CodableEntry(CachedProfileGiftsCollections(collections: collections)) { + transaction.putItemCacheEntry(id: entryId(peerId: peerId), entry: entry) + } + }).start() + } + private func pushState() { let state = State( collections: self.collections, diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Payments/Stars.swift b/submodules/TelegramCore/Sources/TelegramEngine/Payments/Stars.swift index 1d118f3cdb..c52058a60e 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Payments/Stars.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Payments/Stars.swift @@ -1644,7 +1644,8 @@ func _internal_sendStarsPaymentForm(account: Account, formId: Int64, source: Bot upgradeStars: nil, transferStars: transferStars, canTransferDate: canTransferDate, - canResaleDate: canResaleDate + canResaleDate: canResaleDate, + collectionIds: nil ) } } diff --git a/submodules/TelegramUI/Components/ButtonComponent/Sources/ButtonComponent.swift b/submodules/TelegramUI/Components/ButtonComponent/Sources/ButtonComponent.swift index 099a2aa82b..464a016b74 100644 --- a/submodules/TelegramUI/Components/ButtonComponent/Sources/ButtonComponent.swift +++ b/submodules/TelegramUI/Components/ButtonComponent/Sources/ButtonComponent.swift @@ -380,7 +380,7 @@ public final class ButtonComponent: Component { public init( background: Background, content: AnyComponentWithIdentity, - isEnabled: Bool, + isEnabled: Bool = true, tintWhenDisabled: Bool = true, allowActionWhenDisabled: Bool = false, displaysProgress: Bool = false, diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageInteractiveMediaNode/Sources/ChatMessageInteractiveMediaNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageInteractiveMediaNode/Sources/ChatMessageInteractiveMediaNode.swift index 38cd50bf85..6da7536576 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageInteractiveMediaNode/Sources/ChatMessageInteractiveMediaNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageInteractiveMediaNode/Sources/ChatMessageInteractiveMediaNode.swift @@ -2992,7 +2992,7 @@ public final class ChatMessageInteractiveMediaNode: ASDisplayNode, GalleryItemTr var viewText: String = "" if case .eye = icon { - viewText = strings.Chat_SensitiveContent + viewText = wideLayout ? strings.Chat_SensitiveContent : strings.Chat_SensitiveContentShort extendedMediaOverlayNode.dustNode.revealOnTap = false } else { outer: for attribute in message.attributes { diff --git a/submodules/TelegramUI/Components/Gifts/GiftItemComponent/BUILD b/submodules/TelegramUI/Components/Gifts/GiftItemComponent/BUILD index 429656762c..df16348182 100644 --- a/submodules/TelegramUI/Components/Gifts/GiftItemComponent/BUILD +++ b/submodules/TelegramUI/Components/Gifts/GiftItemComponent/BUILD @@ -28,6 +28,7 @@ swift_library( "//submodules/TextFormat", "//submodules/Markdown", "//submodules/AvatarNode", + "//submodules/CheckNode", "//submodules/TelegramUI/Components/EmojiTextAttachmentView", "//submodules/TelegramUI/Components/Stars/ItemShimmeringLoadingComponent", "//submodules/TelegramUI/Components/PeerInfo/PeerInfoCoverComponent", diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoData.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoData.swift index b1e01b97b6..4736a1e72f 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoData.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoData.swift @@ -388,6 +388,7 @@ final class PeerInfoScreenData { let revenueStatsState: StarsRevenueStats? let revenueStatsContext: StarsRevenueStatsContext? let profileGiftsContext: ProfileGiftsContext? + let profileGiftsCollectionsContext: ProfileGiftsCollectionsContext? let premiumGiftOptions: [PremiumGiftCodeOption] let webAppPermissions: WebAppPermissionsState? @@ -440,6 +441,7 @@ final class PeerInfoScreenData { revenueStatsState: StarsRevenueStats?, revenueStatsContext: StarsRevenueStatsContext?, profileGiftsContext: ProfileGiftsContext?, + profileGiftsCollectionsContext: ProfileGiftsCollectionsContext?, premiumGiftOptions: [PremiumGiftCodeOption], webAppPermissions: WebAppPermissionsState? ) { @@ -480,6 +482,7 @@ final class PeerInfoScreenData { self.revenueStatsState = revenueStatsState self.revenueStatsContext = revenueStatsContext self.profileGiftsContext = profileGiftsContext + self.profileGiftsCollectionsContext = profileGiftsCollectionsContext self.premiumGiftOptions = premiumGiftOptions self.webAppPermissions = webAppPermissions } @@ -1000,13 +1003,14 @@ func peerInfoScreenSettingsData(context: AccountContext, peerId: EnginePeer.Id, revenueStatsState: nil, revenueStatsContext: nil, profileGiftsContext: profileGiftsContext, + profileGiftsCollectionsContext: nil, premiumGiftOptions: [], webAppPermissions: nil ) } } -func peerInfoScreenData(context: AccountContext, peerId: PeerId, strings: PresentationStrings, dateTimeFormat: PresentationDateTimeFormat, isSettings: Bool, isMyProfile: Bool, hintGroupInCommon: PeerId?, existingRequestsContext: PeerInvitationImportersContext?, existingProfileGiftsContext: ProfileGiftsContext?, chatLocation: ChatLocation, chatLocationContextHolder: Atomic, sharedMediaFromForumTopic: (EnginePeer.Id, Int64)?, privacySettings: Signal, forceHasGifts: Bool) -> Signal { +func peerInfoScreenData(context: AccountContext, peerId: PeerId, strings: PresentationStrings, dateTimeFormat: PresentationDateTimeFormat, isSettings: Bool, isMyProfile: Bool, hintGroupInCommon: PeerId?, existingRequestsContext: PeerInvitationImportersContext?, existingProfileGiftsContext: ProfileGiftsContext?, existingProfileGiftsCollectionsContext: ProfileGiftsCollectionsContext?, chatLocation: ChatLocation, chatLocationContextHolder: Atomic, sharedMediaFromForumTopic: (EnginePeer.Id, Int64)?, privacySettings: Signal, forceHasGifts: Bool) -> Signal { return peerInfoScreenInputData(context: context, peerId: peerId, isSettings: isSettings) |> mapToSignal { inputData -> Signal in let wasUpgradedGroup = Atomic(value: nil) @@ -1051,6 +1055,7 @@ func peerInfoScreenData(context: AccountContext, peerId: PeerId, strings: Presen revenueStatsState: nil, revenueStatsContext: nil, profileGiftsContext: nil, + profileGiftsCollectionsContext: nil, premiumGiftOptions: [], webAppPermissions: nil )) @@ -1073,11 +1078,14 @@ func peerInfoScreenData(context: AccountContext, peerId: PeerId, strings: Presen let premiumGiftOptions: Signal<[PremiumGiftCodeOption], NoError> let profileGiftsContext: ProfileGiftsContext? + let profileGiftsCollectionsContext: ProfileGiftsCollectionsContext? if case .user = kind { if isMyProfile || userPeerId != context.account.peerId { profileGiftsContext = existingProfileGiftsContext ?? ProfileGiftsContext(account: context.account, peerId: userPeerId) + profileGiftsCollectionsContext = existingProfileGiftsCollectionsContext ?? ProfileGiftsCollectionsContext(account: context.account, peerId: userPeerId) } else { profileGiftsContext = nil + profileGiftsCollectionsContext = nil } premiumGiftOptions = .single([]) |> then( @@ -1085,6 +1093,7 @@ func peerInfoScreenData(context: AccountContext, peerId: PeerId, strings: Presen ) } else { profileGiftsContext = nil + profileGiftsCollectionsContext = nil premiumGiftOptions = .single([]) } @@ -1511,6 +1520,7 @@ func peerInfoScreenData(context: AccountContext, peerId: PeerId, strings: Presen revenueStatsState: revenueContextAndState.1, revenueStatsContext: revenueContextAndState.0, profileGiftsContext: profileGiftsContext, + profileGiftsCollectionsContext: profileGiftsCollectionsContext, premiumGiftOptions: premiumGiftOptions, webAppPermissions: webAppPermissions ) @@ -1619,6 +1629,7 @@ func peerInfoScreenData(context: AccountContext, peerId: PeerId, strings: Presen } let profileGiftsContext = ProfileGiftsContext(account: context.account, peerId: peerId) + let profileGiftsCollectionsContext = ProfileGiftsCollectionsContext(account: context.account, peerId: peerId) let personalChannel = peerInfoPersonalOrLinkedChannel(context: context, peerId: peerId, isSettings: false) @@ -1743,6 +1754,7 @@ func peerInfoScreenData(context: AccountContext, peerId: PeerId, strings: Presen revenueStatsState: revenueContextAndState.1, revenueStatsContext: revenueContextAndState.0, profileGiftsContext: profileGiftsContext, + profileGiftsCollectionsContext: profileGiftsCollectionsContext, premiumGiftOptions: [], webAppPermissions: nil ) @@ -2076,6 +2088,7 @@ func peerInfoScreenData(context: AccountContext, peerId: PeerId, strings: Presen revenueStatsState: nil, revenueStatsContext: nil, profileGiftsContext: nil, + profileGiftsCollectionsContext: nil, premiumGiftOptions: [], webAppPermissions: nil )) diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoPaneContainerNode.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoPaneContainerNode.swift index 5d04f3a936..adab3a2d00 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoPaneContainerNode.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoPaneContainerNode.swift @@ -576,7 +576,7 @@ private final class PeerInfoPendingPane { } } } - paneNode = PeerInfoGiftsPaneNode(context: context, peerId: peerId, chatControllerInteraction: chatControllerInteraction, profileGifts: data.profileGiftsContext!, canManage: canManage, canGift: canGift) + paneNode = PeerInfoGiftsPaneNode(context: context, peerId: peerId, chatControllerInteraction: chatControllerInteraction, profileGiftsCollections: data.profileGiftsCollectionsContext!, profileGifts: data.profileGiftsContext!, canManage: canManage, canGift: canGift) case .stories, .storyArchive, .botPreview: var canManage = false if let peer = data.peer { diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift index fdc4c5c9d0..ae50927286 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift @@ -4801,7 +4801,7 @@ final class PeerInfoScreenNode: ViewControllerTracingNode, PeerInfoScreenNodePro self.privacySettings.set(.single(nil)) } - screenData = peerInfoScreenData(context: context, peerId: peerId, strings: self.presentationData.strings, dateTimeFormat: self.presentationData.dateTimeFormat, isSettings: self.isSettings, isMyProfile: self.isMyProfile, hintGroupInCommon: hintGroupInCommon, existingRequestsContext: requestsContext, existingProfileGiftsContext: profileGiftsContext, chatLocation: self.chatLocation, chatLocationContextHolder: self.chatLocationContextHolder, sharedMediaFromForumTopic: self.sharedMediaFromForumTopic, privacySettings: self.privacySettings.get(), forceHasGifts: initialPaneKey == .gifts) + screenData = peerInfoScreenData(context: context, peerId: peerId, strings: self.presentationData.strings, dateTimeFormat: self.presentationData.dateTimeFormat, isSettings: self.isSettings, isMyProfile: self.isMyProfile, hintGroupInCommon: hintGroupInCommon, existingRequestsContext: requestsContext, existingProfileGiftsContext: profileGiftsContext, existingProfileGiftsCollectionsContext: nil, chatLocation: self.chatLocation, chatLocationContextHolder: self.chatLocationContextHolder, sharedMediaFromForumTopic: self.sharedMediaFromForumTopic, privacySettings: self.privacySettings.get(), forceHasGifts: initialPaneKey == .gifts) var previousTimestamp: Double? self.headerNode.displayPremiumIntro = { [weak self] sourceView, peerStatus, emojiStatusFileAndPack, white in @@ -8863,6 +8863,73 @@ final class PeerInfoScreenNode: ViewControllerTracingNode, PeerInfoScreenNodePro self.controller?.push(SecretChatKeyController(context: self.context, fingerprint: encryptionKeyFingerprint, peer: EnginePeer(peer))) } + private func openShareLink(url: String) { + let shareController = ShareController(context: self.context, subject: .url(url), updatedPresentationData: self.controller?.updatedPresentationData) + shareController.completed = { [weak self] peerIds in + guard let strongSelf = self else { + return + } + let _ = (strongSelf.context.engine.data.get( + EngineDataList( + peerIds.map(TelegramEngine.EngineData.Item.Peer.Peer.init) + ) + ) + |> deliverOnMainQueue).startStandalone(next: { [weak self] peerList in + guard let strongSelf = self else { + return + } + + let peers = peerList.compactMap { $0 } + let presentationData = strongSelf.context.sharedContext.currentPresentationData.with { $0 } + + let text: String + var savedMessages = false + if peerIds.count == 1, let peerId = peerIds.first, peerId == strongSelf.context.account.peerId { + text = presentationData.strings.UserInfo_LinkForwardTooltip_SavedMessages_One + savedMessages = true + } else { + if peers.count == 1, let peer = peers.first { + let peerName = peer.id == strongSelf.context.account.peerId ? presentationData.strings.DialogList_SavedMessages : peer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder) + text = presentationData.strings.UserInfo_LinkForwardTooltip_Chat_One(peerName).string + } else if peers.count == 2, let firstPeer = peers.first, let secondPeer = peers.last { + let firstPeerName = firstPeer.id == strongSelf.context.account.peerId ? presentationData.strings.DialogList_SavedMessages : firstPeer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder) + let secondPeerName = secondPeer.id == strongSelf.context.account.peerId ? presentationData.strings.DialogList_SavedMessages : secondPeer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder) + text = presentationData.strings.UserInfo_LinkForwardTooltip_TwoChats_One(firstPeerName, secondPeerName).string + } else if let peer = peers.first { + let peerName = peer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder) + text = presentationData.strings.UserInfo_LinkForwardTooltip_ManyChats_One(peerName, "\(peers.count - 1)").string + } else { + text = "" + } + } + + strongSelf.controller?.present(UndoOverlayController(presentationData: presentationData, content: .forward(savedMessages: savedMessages, text: text), elevatedLayout: false, animateInAsReplacement: true, action: { action in + if savedMessages, let self, action == .info { + let _ = (self.context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: self.context.account.peerId)) + |> deliverOnMainQueue).start(next: { [weak self] peer in + guard let self, let peer else { + return + } + guard let navigationController = self.controller?.navigationController as? NavigationController else { + return + } + self.context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: self.context, chatLocation: .peer(peer), forceOpenChat: true)) + }) + } + return false + }), in: .current) + }) + } + shareController.actionCompleted = { [weak self] in + if let strongSelf = self { + let presentationData = strongSelf.context.sharedContext.currentPresentationData.with { $0 } + strongSelf.controller?.present(UndoOverlayController(presentationData: presentationData, content: .linkCopied(title: nil, text: presentationData.strings.Conversation_LinkCopied), elevatedLayout: false, animateInAsReplacement: false, action: { _ in return false }), in: .current) + } + } + self.view.endEditing(true) + self.controller?.present(shareController, in: .window(.root)) + } + private func openShareBot() { let _ = (getUserPeer(engine: self.context.engine, peerId: self.peerId) |> deliverOnMainQueue).startStandalone(next: { [weak self] peer in @@ -8870,70 +8937,7 @@ final class PeerInfoScreenNode: ViewControllerTracingNode, PeerInfoScreenNodePro return } if case let .user(peer) = peer, let username = peer.addressName { - let shareController = ShareController(context: strongSelf.context, subject: .url("https://t.me/\(username)"), updatedPresentationData: strongSelf.controller?.updatedPresentationData) - shareController.completed = { [weak self] peerIds in - guard let strongSelf = self else { - return - } - let _ = (strongSelf.context.engine.data.get( - EngineDataList( - peerIds.map(TelegramEngine.EngineData.Item.Peer.Peer.init) - ) - ) - |> deliverOnMainQueue).startStandalone(next: { [weak self] peerList in - guard let strongSelf = self else { - return - } - - let peers = peerList.compactMap { $0 } - let presentationData = strongSelf.context.sharedContext.currentPresentationData.with { $0 } - - let text: String - var savedMessages = false - if peerIds.count == 1, let peerId = peerIds.first, peerId == strongSelf.context.account.peerId { - text = presentationData.strings.UserInfo_LinkForwardTooltip_SavedMessages_One - savedMessages = true - } else { - if peers.count == 1, let peer = peers.first { - let peerName = peer.id == strongSelf.context.account.peerId ? presentationData.strings.DialogList_SavedMessages : peer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder) - text = presentationData.strings.UserInfo_LinkForwardTooltip_Chat_One(peerName).string - } else if peers.count == 2, let firstPeer = peers.first, let secondPeer = peers.last { - let firstPeerName = firstPeer.id == strongSelf.context.account.peerId ? presentationData.strings.DialogList_SavedMessages : firstPeer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder) - let secondPeerName = secondPeer.id == strongSelf.context.account.peerId ? presentationData.strings.DialogList_SavedMessages : secondPeer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder) - text = presentationData.strings.UserInfo_LinkForwardTooltip_TwoChats_One(firstPeerName, secondPeerName).string - } else if let peer = peers.first { - let peerName = peer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder) - text = presentationData.strings.UserInfo_LinkForwardTooltip_ManyChats_One(peerName, "\(peers.count - 1)").string - } else { - text = "" - } - } - - strongSelf.controller?.present(UndoOverlayController(presentationData: presentationData, content: .forward(savedMessages: savedMessages, text: text), elevatedLayout: false, animateInAsReplacement: true, action: { action in - if savedMessages, let self, action == .info { - let _ = (self.context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: self.context.account.peerId)) - |> deliverOnMainQueue).start(next: { [weak self] peer in - guard let self, let peer else { - return - } - guard let navigationController = self.controller?.navigationController as? NavigationController else { - return - } - self.context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: self.context, chatLocation: .peer(peer), forceOpenChat: true)) - }) - } - return false - }), in: .current) - }) - } - shareController.actionCompleted = { [weak self] in - if let strongSelf = self { - let presentationData = strongSelf.context.sharedContext.currentPresentationData.with { $0 } - strongSelf.controller?.present(UndoOverlayController(presentationData: presentationData, content: .linkCopied(title: nil, text: presentationData.strings.Conversation_LinkCopied), elevatedLayout: false, animateInAsReplacement: false, action: { _ in return false }), in: .current) - } - } - strongSelf.view.endEditing(true) - strongSelf.controller?.present(shareController, in: .window(.root)) + strongSelf.openShareLink(url: "https://t.me/\(username)") } }) } @@ -11326,16 +11330,20 @@ final class PeerInfoScreenNode: ViewControllerTracingNode, PeerInfoScreenNodePro guard let controller = self.controller else { return } - guard let data = self.data, let giftsContext = data.profileGiftsContext else { + guard let data = self.data else { return } + let giftsContext = pane.giftsContext + var hasVisibility = false if let channel = data.peer as? TelegramChannel, channel.hasPermission(.sendSomething) { hasVisibility = true } else if data.peer?.id == self.context.account.peerId { hasVisibility = true } + + let isCollection = giftsContext.collectionId != nil let strings = self.presentationData.strings let items: Signal = giftsContext.state @@ -11347,16 +11355,75 @@ final class PeerInfoScreenNode: ViewControllerTracingNode, PeerInfoScreenNodePro break } } - return (state.filter, state.sorting, hasPinnedGifts) + return (state.filter, state.sorting, hasPinnedGifts || isCollection) } |> distinctUntilChanged(isEqual: { lhs, rhs -> Bool in let filterEquals = lhs.0 == rhs.0 let sortingEquals = lhs.1 == rhs.1 - let hasPinnedGiftsEquals = lhs.2 == rhs.2 - return filterEquals && sortingEquals && hasPinnedGiftsEquals + let canReorderEquals = lhs.2 == rhs.2 + return filterEquals && sortingEquals && canReorderEquals }) - |> map { [weak giftsContext] filter, sorting, hasPinnedGifts -> ContextController.Items in + |> map { [weak self, weak pane, weak giftsContext] filter, sorting, canReorder -> ContextController.Items in var items: [ContextMenuItem] = [] + + if canReorder && hasVisibility { + //TODO:localize + if let pane, case .all = pane.currentCollection { + items.append(.action(ContextMenuActionItem(text: "Add Collection", icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Peer Info/Gifts/AddCollection"), color: theme.contextMenu.primaryColor) + }, action: { [weak pane] _, f in + f(.default) + + if let pane { + pane.createCollection() + } + }))) + } else { + items.append(.action(ContextMenuActionItem(text: "Add Gifts", icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Peer Info/Gifts/AddGift"), color: theme.contextMenu.primaryColor) + }, action: { [weak pane] _, f in + f(.default) + + if let pane, case let .collection(id) = pane.currentCollection { + pane.addGiftsToCollection(id: id) + } + }))) + + items.append(.action(ContextMenuActionItem(text: "Share", icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Forward"), color: theme.contextMenu.primaryColor) + }, action: { [weak self] _, f in + f(.default) + + self?.openShareLink(url: "https://t.me/") + }))) + } + + items.append(.action(ContextMenuActionItem(text: strings.PeerInfo_Gifts_Reorder, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/ReorderItems"), color: theme.contextMenu.primaryColor) + }, action: { [weak pane] _, f in + f(.default) + + if let pane { + pane.beginReordering() + } + }))) + + if let pane, case let .collection(id) = pane.currentCollection { + items.append(.action(ContextMenuActionItem(text: "Delete Collection", textColor: .destructive, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Delete"), color: theme.contextMenu.destructiveColor) + }, action: { [weak pane] _, f in + f(.default) + + if let pane { + pane.deleteCollection(id: id) + } + }))) + } + } + + if !items.isEmpty { + items.append(.separator) + } items.append(.action(ContextMenuActionItem(text: sorting == .date ? strings.PeerInfo_Gifts_SortByValue : strings.PeerInfo_Gifts_SortByDate, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: sorting == .date ? "Peer Info/SortValue" : "Peer Info/SortDate"), color: theme.contextMenu.primaryColor) @@ -11366,16 +11433,6 @@ final class PeerInfoScreenNode: ViewControllerTracingNode, PeerInfoScreenNodePro giftsContext?.updateSorting(sorting == .date ? .value : .date) }))) - if hasPinnedGifts && hasVisibility { - items.append(.action(ContextMenuActionItem(text: strings.PeerInfo_Gifts_Reorder, icon: { theme in - return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/ReorderItems"), color: theme.contextMenu.primaryColor) - }, action: { _, f in - f(.default) - - pane.beginReordering() - }))) - } - items.append(.separator) let toggleFilter: (ProfileGiftsContext.Filters) -> Void = { [weak giftsContext] value in diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/BUILD b/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/BUILD index e106558332..884344333b 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/BUILD +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/BUILD @@ -32,6 +32,7 @@ swift_library( "//submodules/TelegramUIPreferences", "//submodules/CheckNode", "//submodules/AppBundle", + "//submodules/Components/BundleIconComponent", "//submodules/TelegramUI/Components/ChatControllerInteraction", "//submodules/InvisibleInkDustNode", "//submodules/MediaPickerUI", @@ -56,6 +57,7 @@ swift_library( "//submodules/TelegramUI/Components/LottieComponent", "//submodules/TelegramUI/Components/BottomButtonPanelComponent", "//submodules/PromptUI", + "//submodules/TelegramUI/Components/EmojiTextAttachmentView", ], visibility = [ "//visibility:public", diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/AddGiftsScreen.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/AddGiftsScreen.swift new file mode 100644 index 0000000000..200ddf2b3a --- /dev/null +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/AddGiftsScreen.swift @@ -0,0 +1,510 @@ +import Foundation +import UIKit +import Display +import AsyncDisplayKit +import SwiftSignalKit +import Postbox +import TelegramCore +import TelegramPresentationData +import TelegramUIPreferences +import PresentationDataUtils +import AccountContext +import ComponentFlow +import ViewControllerComponent +import BundleIconComponent +import MultilineTextComponent +import ButtonComponent +import BlurredBackgroundComponent +import ContextUI + +final class AddGiftsScreenComponent: Component { + typealias EnvironmentType = ViewControllerComponentContainer.Environment + + let context: AccountContext + let peerId: EnginePeer.Id + let collectionId: Int32 + let profileGifts: ProfileGiftsContext + + init( + context: AccountContext, + peerId: EnginePeer.Id, + collectionId: Int32, + profileGifts: ProfileGiftsContext + ) { + self.context = context + self.peerId = peerId + self.collectionId = collectionId + self.profileGifts = profileGifts + } + + static func ==(lhs: AddGiftsScreenComponent, rhs: AddGiftsScreenComponent) -> Bool { + return true + } + + private final class ScrollView: UIScrollView { + override func touchesShouldCancel(in view: UIView) -> Bool { + return true + } + } + + final class View: UIView, UIScrollViewDelegate { + private let backgroundView: UIView + private let scrollView: ScrollView + + private var giftsListView: GiftsListView? + + private let buttonBackground = ComponentView() + private let buttonSeparator = SimpleLayer() + private let button = ComponentView() + + private var isUpdating: Bool = false + + private var component: AddGiftsScreenComponent? + private(set) weak var state: EmptyComponentState? + private var environment: EnvironmentType? + + override init(frame: CGRect) { + self.backgroundView = UIView() + + self.scrollView = ScrollView() + self.scrollView.showsVerticalScrollIndicator = true + self.scrollView.showsHorizontalScrollIndicator = false + self.scrollView.scrollsToTop = false + self.scrollView.delaysContentTouches = false + self.scrollView.canCancelContentTouches = true + self.scrollView.contentInsetAdjustmentBehavior = .never + if #available(iOS 13.0, *) { + self.scrollView.automaticallyAdjustsScrollIndicatorInsets = false + } + self.scrollView.alwaysBounceVertical = true + + super.init(frame: frame) + + self.addSubview(self.backgroundView) + + self.scrollView.delegate = self + self.addSubview(self.scrollView) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + } + + func scrollToTop() { + self.scrollView.setContentOffset(CGPoint(), animated: true) + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + self.updateScrolling(transition: .immediate) + } + + private func updateScrolling(transition: ComponentTransition) { + guard let environment = self.environment, let giftsListView = self.giftsListView else { + return + } + let visibleBounds = self.scrollView.bounds.insetBy(dx: 0.0, dy: -10.0) + let contentHeight = giftsListView.updateScrolling(topInset: environment.navigationHeight + 10.0, visibleBounds: visibleBounds, transition: transition) + + var contentSize = CGSize(width: self.scrollView.bounds.width, height: contentHeight) + contentSize.height += environment.safeInsets.bottom + contentSize.height = max(contentSize.height, self.scrollView.bounds.size.height) + transition.setFrame(view: giftsListView, frame: CGRect(origin: CGPoint(), size: contentSize)) + + if self.scrollView.contentSize != contentSize { + self.scrollView.contentSize = contentSize + } + } + + func update(component: AddGiftsScreenComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + self.isUpdating = true + defer { + self.isUpdating = false + } + + let giftsListView: GiftsListView + if let current = self.giftsListView { + giftsListView = current + } else { + giftsListView = GiftsListView(context: component.context, peerId: component.peerId, profileGifts: component.profileGifts, giftsCollections: nil, canSelect: true, ignoreCollection: component.collectionId) + giftsListView.selectionUpdated = { [weak self] in + guard let self else { + return + } + self.state?.updated(transition: .spring(duration: 0.4)) + } + self.scrollView.addSubview(giftsListView) + self.giftsListView = giftsListView + } + + let environment = environment[EnvironmentType.self].value + self.environment = environment + + self.component = component + self.state = state + + let sideInset: CGFloat = 16.0 + environment.safeInsets.left + let buttonHeight: CGFloat = 50.0 + let bottomPanelPadding: CGFloat = 12.0 + let bottomInset: CGFloat = environment.safeInsets.bottom > 0.0 ? environment.safeInsets.bottom + 5.0 : bottomPanelPadding + let bottomPanelHeight = bottomPanelPadding + buttonHeight + bottomInset + + let bottomPanelOffset: CGFloat = giftsListView.selectedItems.count > 0 ? 0.0 : bottomPanelHeight + + //TODO:localize + var buttonString = "" + + if giftsListView.selectedItems.count > 1 { + buttonString = "Add \(giftsListView.selectedItems.count) Gifts" + } else { + buttonString = "Add 1 Gift" + } + + let bottomPanelSize = self.buttonBackground.update( + transition: transition, + component: AnyComponent(BlurredBackgroundComponent( + color: environment.theme.rootController.tabBar.backgroundColor + )), + environment: {}, + containerSize: CGSize(width: availableSize.width, height: bottomPanelHeight) + ) + self.buttonSeparator.backgroundColor = environment.theme.rootController.tabBar.separatorColor.cgColor + + if let view = self.buttonBackground.view { + if view.superview == nil { + self.addSubview(view) + self.layer.addSublayer(self.buttonSeparator) + } + transition.setFrame(view: view, frame: CGRect(origin: CGPoint(x: 0.0, y: availableSize.height - bottomPanelSize.height + bottomPanelOffset), size: bottomPanelSize)) + transition.setFrame(layer: self.buttonSeparator, frame: CGRect(origin: CGPoint(x: 0.0, y: availableSize.height - bottomPanelSize.height + bottomPanelOffset), size: CGSize(width: availableSize.width, height: UIScreenPixel))) + } + + let buttonAttributedString = NSMutableAttributedString(string: buttonString, font: Font.semibold(17.0), textColor: environment.theme.list.itemCheckColors.foregroundColor, paragraphAlignment: .center) + let buttonSize = self.button.update( + transition: transition, + component: AnyComponent(ButtonComponent( + background: ButtonComponent.Background( + color: environment.theme.list.itemCheckColors.fillColor, + foreground: environment.theme.list.itemCheckColors.foregroundColor, + pressedColor: environment.theme.list.itemCheckColors.fillColor.withMultipliedAlpha(0.9), + cornerRadius: 10.0 + ), + content: AnyComponentWithIdentity( + id: AnyHashable(buttonAttributedString.string), + component: AnyComponent(MultilineTextComponent(text: .plain(buttonAttributedString))) + ), + action: { [weak self] in + guard let self, let controller = self.environment?.controller() as? AddGiftsScreen, let giftsListView = self.giftsListView else { + return + } + controller.completion(giftsListView.selectedItems) + controller.dismiss(animated: true) + } + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: buttonHeight) + ) + if let buttonView = self.button.view { + if buttonView.superview == nil { + self.addSubview(buttonView) + } + transition.setFrame(view: buttonView, frame: CGRect(origin: CGPoint(x: floor((availableSize.width - buttonSize.width) / 2.0), y: availableSize.height - bottomPanelHeight + bottomPanelPadding + bottomPanelOffset), size: buttonSize)) + } + + let visibleBounds = self.scrollView.bounds.insetBy(dx: 0.0, dy: -10.0) + let presentationData = component.context.sharedContext.currentPresentationData.with({ $0 }) + let _ = giftsListView.update(size: availableSize, sideInset: 0.0, bottomInset: max(environment.safeInsets.bottom, bottomPanelHeight), deviceMetrics: environment.deviceMetrics, visibleHeight: availableSize.height, isScrollingLockedAtTop: false, expandProgress: 0.0, presentationData: presentationData, synchronous: false, visibleBounds: visibleBounds, transition: transition.containedViewLayoutTransition) + + transition.setFrame(view: self.backgroundView, frame: CGRect(origin: .zero, size: availableSize)) + self.backgroundView.backgroundColor = environment.theme.list.blocksBackgroundColor + + transition.setFrame(view: self.scrollView, frame: CGRect(origin: .zero, size: availableSize)) + + self.updateScrolling(transition: transition) + + return availableSize + } + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + let r = super.hitTest(point, with: event) + return r + } + } + + func makeView() -> View { + return View() + } + + func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } +} + +public final class AddGiftsScreen: ViewControllerComponentContainer { + private let context: AccountContext + private let peerId: EnginePeer.Id + private let collectionId: Int32 + fileprivate let completion: ([ProfileGiftsContext.State.StarGift]) -> Void + + private let profileGifts: ProfileGiftsContext + + private let filterButton: FilterHeaderButton + + public init( + context: AccountContext, + peerId: EnginePeer.Id, + collectionId: Int32, + completion: @escaping ([ProfileGiftsContext.State.StarGift]) -> Void + ) { + self.context = context + self.peerId = peerId + self.collectionId = collectionId + self.completion = completion + + self.profileGifts = ProfileGiftsContext(account: context.account, peerId: peerId) + + let presentationData = context.sharedContext.currentPresentationData.with { $0 } + self.filterButton = FilterHeaderButton(presentationData: presentationData) + + super.init(context: context, component: AddGiftsScreenComponent( + context: context, + peerId: peerId, + collectionId: collectionId, + profileGifts: self.profileGifts + ), navigationBarAppearance: .default, theme: .default, updatedPresentationData: nil) + + + //TODO:localize + self.title = "Add Gifts" + self.navigationPresentation = .modal + + self.scrollToTop = { [weak self] in + guard let self, let componentView = self.node.hostView.componentView as? AddGiftsScreenComponent.View else { + return + } + componentView.scrollToTop() + } + + self.filterButton.contextAction = { [weak self] sourceNode, gesture in + self?.presentContextMenu(sourceView: sourceNode.view, gesture: gesture) + } + self.filterButton.addTarget(self, action: #selector(self.filterPressed), forControlEvents: .touchUpInside) + + self.navigationItem.leftBarButtonItem = UIBarButtonItem(title: presentationData.strings.Common_Cancel, style: .plain, target: self, action: #selector(self.cancelPressed)) + self.navigationItem.rightBarButtonItem = UIBarButtonItem(customDisplayNode: self.filterButton) + } + + required public init(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func presentContextMenu(sourceView: UIView, gesture: ContextGesture?) { + let giftsContext = self.profileGifts + + let presentationData = self.context.sharedContext.currentPresentationData.with { $0 } + let strings = presentationData.strings + let items: Signal = giftsContext.state + |> map { state in + var hasPinnedGifts = false + for gift in state.gifts { + if gift.pinnedToTop { + hasPinnedGifts = true + break + } + } + return (state.filter, state.sorting, hasPinnedGifts) + } + |> distinctUntilChanged(isEqual: { lhs, rhs -> Bool in + let filterEquals = lhs.0 == rhs.0 + let sortingEquals = lhs.1 == rhs.1 + let hasPinnedGiftsEquals = lhs.2 == rhs.2 + return filterEquals && sortingEquals && hasPinnedGiftsEquals + }) + |> map { [weak giftsContext] filter, sorting, hasPinnedGifts -> ContextController.Items in + var items: [ContextMenuItem] = [] + + items.append(.action(ContextMenuActionItem(text: sorting == .date ? strings.PeerInfo_Gifts_SortByValue : strings.PeerInfo_Gifts_SortByDate, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: sorting == .date ? "Peer Info/SortValue" : "Peer Info/SortDate"), color: theme.contextMenu.primaryColor) + }, action: { [weak giftsContext] _, f in + f(.default) + + giftsContext?.updateSorting(sorting == .date ? .value : .date) + }))) + + items.append(.separator) + + let toggleFilter: (ProfileGiftsContext.Filters) -> Void = { [weak giftsContext] value in + var updatedFilter = filter + if updatedFilter.contains(value) { + updatedFilter.remove(value) + } else { + updatedFilter.insert(value) + } + if !updatedFilter.contains(.unlimited) && !updatedFilter.contains(.limited) && !updatedFilter.contains(.unique) { + updatedFilter.insert(.unlimited) + } + if !updatedFilter.contains(.displayed) && !updatedFilter.contains(.hidden) { + if value == .displayed { + updatedFilter.insert(.hidden) + } else { + updatedFilter.insert(.displayed) + } + } + giftsContext?.updateFilter(updatedFilter) + } + + let switchToFilter: (ProfileGiftsContext.Filters) -> Void = { [weak giftsContext] value in + var updatedFilter = filter + updatedFilter.remove(.unlimited) + updatedFilter.remove(.limited) + updatedFilter.remove(.unique) + updatedFilter.insert(value) + giftsContext?.updateFilter(updatedFilter) + } + + let switchToVisiblityFilter: (ProfileGiftsContext.Filters) -> Void = { [weak giftsContext] value in + var updatedFilter = filter + updatedFilter.remove(.hidden) + updatedFilter.remove(.displayed) + updatedFilter.insert(value) + giftsContext?.updateFilter(updatedFilter) + } + + items.append(.action(ContextMenuActionItem(text: strings.PeerInfo_Gifts_Unlimited, icon: { theme in + return filter.contains(.unlimited) ? generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Check"), color: theme.contextMenu.primaryColor) : nil + }, action: { _, f in + toggleFilter(.unlimited) + }, longPressAction: { _, f in + switchToFilter(.unlimited) + }))) + items.append(.action(ContextMenuActionItem(text: strings.PeerInfo_Gifts_Limited, icon: { theme in + return filter.contains(.limited) ? generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Check"), color: theme.contextMenu.primaryColor) : nil + }, action: { _, f in + toggleFilter(.limited) + }, longPressAction: { _, f in + switchToFilter(.limited) + }))) + items.append(.action(ContextMenuActionItem(text: strings.PeerInfo_Gifts_Unique, icon: { theme in + return filter.contains(.unique) ? generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Check"), color: theme.contextMenu.primaryColor) : nil + }, action: { _, f in + toggleFilter(.unique) + }, longPressAction: { _, f in + switchToFilter(.unique) + }))) + + items.append(.separator) + + items.append(.action(ContextMenuActionItem(text: strings.PeerInfo_Gifts_Displayed, icon: { theme in + return filter.contains(.displayed) ? generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Check"), color: theme.contextMenu.primaryColor) : nil + }, action: { _, f in + toggleFilter(.displayed) + }, longPressAction: { _, f in + switchToVisiblityFilter(.displayed) + }))) + items.append(.action(ContextMenuActionItem(text: strings.PeerInfo_Gifts_Hidden, icon: { theme in + return filter.contains(.hidden) ? generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Check"), color: theme.contextMenu.primaryColor) : nil + }, action: { _, f in + toggleFilter(.hidden) + }, longPressAction: { _, f in + switchToVisiblityFilter(.hidden) + }))) + + return ContextController.Items(content: .list(items)) + } + + let contextController = ContextController(presentationData: presentationData, source: .reference(HeaderContextReferenceContentSource(controller: self, sourceView: sourceView)), items: items, gesture: gesture) + self.presentInGlobalOverlay(contextController) + } + + deinit { + } + + @objc private func cancelPressed() { + self.dismiss() + } + + @objc private func filterPressed() { + self.filterButton.contextAction?(self.filterButton.containerNode, nil) + } +} + +private final class FilterHeaderButton: HighlightableButtonNode { + let referenceNode: ContextReferenceContentNode + let containerNode: ContextControllerSourceNode + private let icon = ComponentView() + + var contextAction: ((ASDisplayNode, ContextGesture?) -> Void)? + + init(presentationData: PresentationData) { + self.referenceNode = ContextReferenceContentNode() + self.containerNode = ContextControllerSourceNode() + self.containerNode.animateScale = false + + super.init() + + self.containerNode.addSubnode(self.referenceNode) + self.addSubnode(self.containerNode) + + self.containerNode.shouldBegin = { [weak self] location in + guard let strongSelf = self, let _ = strongSelf.contextAction else { + return false + } + return true + } + self.containerNode.activated = { [weak self] gesture, _ in + guard let strongSelf = self else { + return + } + strongSelf.contextAction?(strongSelf.containerNode, gesture) + } + + self.update(theme: presentationData.theme, strings: presentationData.strings) + } + + func update(theme: PresentationTheme, strings: PresentationStrings) { + let iconSize = self.icon.update( + transition: .immediate, + component: AnyComponent( + BundleIconComponent( + name: "Peer Info/SortIcon", + tintColor: theme.rootController.navigationBar.accentTextColor + ) + ), + environment: {}, + containerSize: CGSize(width: 30.0, height: 30.0) + ) + if let view = self.icon.view { + if view.superview == nil { + view.isUserInteractionEnabled = false + self.referenceNode.view.addSubview(view) + } + view.frame = CGRect(origin: CGPoint(x: 14.0, y: 7.0), size: iconSize) + } + + self.containerNode.frame = CGRect(origin: CGPoint(), size: CGSize(width: 44.0, height: 44.0)) + self.referenceNode.frame = self.containerNode.bounds + } + + override func calculateSizeThatFits(_ constrainedSize: CGSize) -> CGSize { + return CGSize(width: 44.0, height: 44.0) + } + + func onLayout() { + } +} + +private final class HeaderContextReferenceContentSource: ContextReferenceContentSource { + private let controller: ViewController + private let sourceView: UIView + + init(controller: ViewController, sourceView: UIView) { + self.controller = controller + self.sourceView = sourceView + } + + func transitionInfo() -> ContextControllerReferenceViewInfo? { + return ContextControllerReferenceViewInfo(referenceView: self.sourceView, contentAreaInScreenSpace: UIScreen.main.bounds) + } +} diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/GiftsListView.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/GiftsListView.swift new file mode 100644 index 0000000000..50eda36c1c --- /dev/null +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/GiftsListView.swift @@ -0,0 +1,1238 @@ +import AsyncDisplayKit +import UIKit +import Display +import ComponentFlow +import TelegramCore +import SwiftSignalKit +import Postbox +import TelegramPresentationData +import AccountContext +import ContextUI +import PhotoResources +import TelegramUIPreferences +import TelegramStringFormatting +import ItemListPeerItem +import ItemListPeerActionItem +import MergeLists +import ItemListUI +import MultilineTextComponent +import BalancedTextComponent +import Markdown +import PeerInfoPaneNode +import GiftItemComponent +import PlainButtonComponent +import GiftViewScreen +import SolidRoundedButtonNode +import UndoUI +import LottieComponent +import ButtonComponent +import ContextUI + +final class GiftsListView: UIView { + private let context: AccountContext + private let peerId: PeerId + let profileGifts: ProfileGiftsContext + private let giftsCollections: ProfileGiftsCollectionsContext? + + private let canSelect: Bool + private let ignoreCollection: Int32? + + private var dataDisposable: Disposable? + + weak var parentController: ViewController? + + private var footerText: ComponentView? + + private let emptyResultsClippingView = UIView() + private let emptyResultsAnimation = ComponentView() + private let emptyResultsTitle = ComponentView() + private let emptyResultsText = ComponentView() + private let emptyResultsAction = ComponentView() + + private var currentParams: (size: CGSize, sideInset: CGFloat, bottomInset: CGFloat, deviceMetrics: DeviceMetrics, visibleHeight: CGFloat, isScrollingLockedAtTop: Bool, expandProgress: CGFloat, presentationData: PresentationData)? + private var visibleBounds: CGRect? + private var topInset: CGFloat? + + private var theme: PresentationTheme? + private let presentationDataPromise = Promise() + + private let ready = Promise() + private var didSetReady: Bool = false + var isReady: Signal { + return self.ready.get() + } + + private let statusPromise = Promise(nil) + var status: Signal { + self.statusPromise.get() + } + + private var starsProducts: [ProfileGiftsContext.State.StarGift]? + private var starsItems: [AnyHashable: (StarGiftReference?, ComponentView)] = [:] + + private(set) var resultsAreEmpty = false + private var filteredResultsAreEmpty = false + + var onContentUpdated: () -> Void = { } + + private(set) var selectedItemIds = Set() + private var selectedItemsMap: [AnyHashable: ProfileGiftsContext.State.StarGift] = [:] + var selectionUpdated: () -> Void = { } + + var selectedItems: [ProfileGiftsContext.State.StarGift] { + var gifts: [ProfileGiftsContext.State.StarGift] = [] + var existingIds = Set() + if let currentGifts = self.profileGifts.currentState?.gifts { + for gift in currentGifts { + if let itemId = gift.reference?.stringValue { + if self.selectedItemIds.contains(itemId) { + gifts.append(gift) + existingIds.insert(itemId) + } + } + } + } + for itemId in self.selectedItemIds { + if !existingIds.contains(itemId), let item = self.selectedItemsMap[itemId] { + gifts.append(item) + } + } + return gifts + } + + private(set) var pinnedReferences: [StarGiftReference] = [] + private var isReordering: Bool = false + private var reorderingItem: (id: AnyHashable, initialPosition: CGPoint, position: CGPoint)? + private var reorderedReferences: [StarGiftReference]? { + didSet { + self.reorderedReferencesPromise.set(self.reorderedReferences) + } + } + private var reorderedReferencesPromise = ValuePromise<[StarGiftReference]?>(nil) + + private var reorderedPinnedReferences: Set? { + didSet { + self.reorderedPinnedReferencesPromise.set(self.reorderedPinnedReferences) + } + } + private var reorderedPinnedReferencesPromise = ValuePromise?>(nil) + + private var reorderRecognizer: ReorderGestureRecognizer? + + let maxPinnedCount: Int + + var contextAction: ((ProfileGiftsContext.State.StarGift, UIView, ContextGesture) -> Void)? + var addToCollection: (() -> Void)? + + init(context: AccountContext, peerId: PeerId, profileGifts: ProfileGiftsContext, giftsCollections: ProfileGiftsCollectionsContext?, canSelect: Bool, ignoreCollection: Int32? = nil) { + self.context = context + self.peerId = peerId + self.profileGifts = profileGifts + self.giftsCollections = giftsCollections + self.canSelect = canSelect + self.ignoreCollection = ignoreCollection + + if let value = context.currentAppConfiguration.with({ $0 }).data?["stargifts_pinned_to_top_limit"] as? Double { + self.maxPinnedCount = Int(value) + } else { + self.maxPinnedCount = 6 + } + + super.init(frame: .zero) + + self.dataDisposable = combineLatest( + queue: Queue.mainQueue(), + profileGifts.state, + self.reorderedReferencesPromise.get() + ).startStrict(next: { [weak self] state, reorderedReferences in + guard let self else { + return + } + let isFirstTime = self.starsProducts == nil + let presentationData = self.context.sharedContext.currentPresentationData.with { $0 } + self.statusPromise.set(.single(PeerInfoStatusData(text: presentationData.strings.SharedMedia_GiftCount(state.count ?? 0), isActivity: true, key: .gifts))) + + if self.isReordering { + var stateItems: [ProfileGiftsContext.State.StarGift] = state.gifts + if let reorderedReferences { + var fixedStateItems: [ProfileGiftsContext.State.StarGift] = [] + + var seenIds = Set() + for reference in reorderedReferences { + if let index = stateItems.firstIndex(where: { $0.reference == reference }) { + seenIds.insert(reference) + var item = stateItems[index] + if self.reorderedPinnedReferences?.contains(reference) == true, !item.pinnedToTop { + item = item.withPinnedToTop(true) + } + fixedStateItems.append(item) + } + } + + for item in stateItems { + if let reference = item.reference, !seenIds.contains(reference) { + var item = item + if self.reorderedPinnedReferences?.contains(reference) == true, !item.pinnedToTop { + item = item.withPinnedToTop(true) + } + fixedStateItems.append(item) + } + } + stateItems = fixedStateItems + } + self.starsProducts = stateItems + self.pinnedReferences = Array(stateItems.filter { $0.pinnedToTop }.compactMap { $0.reference }) + } else { + self.starsProducts = state.filteredGifts + self.pinnedReferences = Array(state.gifts.filter { $0.pinnedToTop }.compactMap { $0.reference }) + } + + self.resultsAreEmpty = state.filter == .All && state.gifts.isEmpty && state.dataState != .loading + self.filteredResultsAreEmpty = state.filter != .All && state.filteredGifts.isEmpty + + if !self.didSetReady { + self.didSetReady = true + self.ready.set(.single(true)) + } + + let _ = self.updateScrolling(transition: isFirstTime ? .immediate : .easeInOut(duration: 0.25)) + + Queue.mainQueue().justDispatch { + self.onContentUpdated() + } + }) + + self.emptyResultsClippingView.clipsToBounds = true + self.emptyResultsClippingView.isHidden = true + self.addSubview(self.emptyResultsClippingView) + + let reorderRecognizer = ReorderGestureRecognizer( + shouldBegin: { [weak self] point in + guard let self, let (id, item) = self.item(at: point) else { + return (allowed: false, requiresLongPress: false, id: nil, item: nil) + } + return (allowed: true, requiresLongPress: false, id: id, item: item) + }, + willBegin: { point in + }, + began: { [weak self] item in + guard let self else { + return + } + self.setReorderingItem(item: item) + }, + ended: { [weak self] in + guard let self else { + return + } + self.setReorderingItem(item: nil) + }, + moved: { [weak self] distance in + guard let self else { + return + } + self.moveReorderingItem(distance: distance) + }, + isActiveUpdated: { _ in + } + ) + self.reorderRecognizer = reorderRecognizer + self.addGestureRecognizer(reorderRecognizer) + reorderRecognizer.isEnabled = false + } + + required init?(coder: NSCoder) { + preconditionFailure() + } + + deinit { + self.dataDisposable?.dispose() + } + + func item(at point: CGPoint) -> (AnyHashable, ComponentView)? { + for (id, visibleItem) in self.starsItems { + if let view = visibleItem.1.view, view.frame.contains(point), let reference = visibleItem.0, self.isCollection || self.pinnedReferences.contains(reference) { + return (id, visibleItem.1) + } + } + return nil + } + + func beginReordering() { + self.profileGifts.updateFilter(.All) + self.profileGifts.updateSorting(.date) + + if let parentController = self.parentController as? PeerInfoScreen { + parentController.togglePaneIsReordering(isReordering: true) + } else { + self.updateIsReordering(isReordering: true, animated: true) + } + } + + func endReordering() { + if let parentController = self.parentController as? PeerInfoScreen { + parentController.togglePaneIsReordering(isReordering: false) + } else { + self.updateIsReordering(isReordering: false, animated: true) + } + } + + func updateIsReordering(isReordering: Bool, animated: Bool) { + if self.isReordering != isReordering { + self.isReordering = isReordering + + self.reorderRecognizer?.isEnabled = isReordering + + if !isReordering, let _ = self.reorderedReferences, let starsProducts = self.starsProducts { + if let collectionId = self.profileGifts.collectionId { + var orderedReferences: [StarGiftReference] = [] + for gift in starsProducts { + if let reference = gift.reference { + orderedReferences.append(reference) + } + } + let _ = self.giftsCollections?.reorderGifts(id: collectionId, gifts: orderedReferences).start() + } else { + var pinnedReferences: [StarGiftReference] = [] + for gift in starsProducts.prefix(self.maxPinnedCount) { + if gift.pinnedToTop, let reference = gift.reference { + pinnedReferences.append(reference) + } + } + self.profileGifts.updatePinnedToTopStarGifts(references: pinnedReferences) + } + + Queue.mainQueue().after(1.0) { + self.reorderedReferences = nil + self.reorderedPinnedReferences = nil + } + } + + self.updateScrolling(transition: animated ? .spring(duration: 0.4) : .immediate) + } + } + + func setReorderingItem(item: AnyHashable?) { + var mappedItem: (AnyHashable, ComponentView)? + for (id, visibleItem) in self.starsItems { + if id == item { + mappedItem = (id, visibleItem.1) + break + } + } + + if self.reorderingItem?.id != mappedItem?.0 { + if let (id, visibleItem) = mappedItem, let view = visibleItem.view { + self.addSubview(view) + self.reorderingItem = (id, view.center, view.center) + } else { + self.reorderingItem = nil + } + self.updateScrolling(transition: item == nil ? .spring(duration: 0.3) : .immediate) + } + } + + func moveReorderingItem(distance: CGPoint) { + if let (id, initialPosition, _) = self.reorderingItem { + let targetPosition = CGPoint(x: initialPosition.x + distance.x, y: initialPosition.y + distance.y) + self.reorderingItem = (id, initialPosition, targetPosition) + self.updateScrolling(transition: .immediate) + + if let starsProducts = self.starsProducts, let visibleReorderingItem = self.starsItems[id] { + for (_, visibleItem) in self.starsItems { + if visibleItem.1 === visibleReorderingItem.1 { + continue + } + if let view = visibleItem.1.view, view.frame.contains(targetPosition), let reorderItemReference = self.starsItems[id]?.0 { + if let targetIndex = starsProducts.firstIndex(where: { $0.reference == visibleItem.0 }) { + self.reorderIfPossible(reference: reorderItemReference, toIndex: targetIndex) + } + break + } + } + } + } + } + + private var isCollection: Bool { + return self.profileGifts.collectionId != nil + } + + private func reorderIfPossible(reference: StarGiftReference, toIndex: Int) { + if let items = self.starsProducts { + var toIndex = toIndex + + let maxPinnedIndex: Int? + if self.isCollection { + maxPinnedIndex = items.count - 1 + } else { + maxPinnedIndex = items.lastIndex(where: { $0.pinnedToTop }) + } + if let maxPinnedIndex { + toIndex = min(toIndex, maxPinnedIndex) + } else { + return + } + + var ids = items.compactMap { item -> StarGiftReference? in + return item.reference + } + + if let fromIndex = ids.firstIndex(of: reference) { + if fromIndex < toIndex { + ids.insert(reference, at: toIndex + 1) + ids.remove(at: fromIndex) + } else if fromIndex > toIndex { + ids.remove(at: fromIndex) + ids.insert(reference, at: toIndex) + } + } + if self.reorderedReferences != ids { + self.reorderedReferences = ids + + HapticFeedback().tap() + } + } + } + + func loadMore() { + self.profileGifts.loadMore() + } + + @discardableResult + private func updateScrolling(interactive: Bool = false, transition: ComponentTransition) -> CGFloat { + guard let topInset = self.topInset, let visibleBounds = self.visibleBounds else { + return 0.0 + } + return self.updateScrolling(interactive: interactive, topInset: topInset, visibleBounds: visibleBounds, transition: transition) + } + + func updateScrolling(interactive: Bool = false, topInset: CGFloat, visibleBounds: CGRect, transition: ComponentTransition) -> CGFloat { + self.topInset = topInset + self.visibleBounds = visibleBounds + + guard let starsProducts = self.starsProducts, let params = self.currentParams else { + return 0.0 + } + + let optionSpacing: CGFloat = 10.0 + let itemsSideInset = params.sideInset + 16.0 + + let defaultItemsInRow: Int + if params.size.width > params.size.height || params.size.width > 480.0 { + if case .tablet = params.deviceMetrics.type { + defaultItemsInRow = 4 + } else { + defaultItemsInRow = 5 + } + } else { + defaultItemsInRow = 3 + } + let itemsInRow = max(1, min(starsProducts.count, defaultItemsInRow)) + let defaultOptionWidth = (params.size.width - itemsSideInset * 2.0 - optionSpacing * CGFloat(defaultItemsInRow - 1)) / CGFloat(defaultItemsInRow) + let optionWidth = (params.size.width - itemsSideInset * 2.0 - optionSpacing * CGFloat(itemsInRow - 1)) / CGFloat(itemsInRow) + + let starsOptionSize = CGSize(width: optionWidth, height: defaultOptionWidth) + + var validIds: [AnyHashable] = [] + var itemFrame = CGRect(origin: CGPoint(x: itemsSideInset, y: topInset), size: starsOptionSize) + + var index: Int32 = 0 + for product in starsProducts { + var isVisible = false + if visibleBounds.intersects(itemFrame) { + isVisible = true + } + + if isVisible { + let info: String + switch product.gift { + case let .generic(gift): + info = "g_\(gift.id)" + case let .unique(gift): + info = "u_\(gift.id)" + } + let stableId = product.reference?.stringValue ?? "\(index)" + let id = "\(stableId)_\(info)" + let itemId = AnyHashable(id) + validIds.append(itemId) + + var itemTransition = transition + let visibleItem: ComponentView + if let (_, current) = self.starsItems[itemId] { + visibleItem = current + } else { + visibleItem = ComponentView() + self.starsItems[itemId] = (product.reference, visibleItem) + itemTransition = .immediate + } + + var ribbonText: String? + var ribbonColor: GiftItemComponent.Ribbon.Color = .blue + var ribbonFont: GiftItemComponent.Ribbon.Font = .generic + var ribbonOutline: UIColor? + + let peer: GiftItemComponent.Peer? + let subject: GiftItemComponent.Subject + var resellPrice: Int64? + + switch product.gift { + case let .generic(gift): + subject = .starGift(gift: gift, price: "# \(gift.price)") + peer = product.fromPeer.flatMap { .peer($0) } ?? .anonymous + + if let availability = gift.availability { + ribbonText = params.presentationData.strings.PeerInfo_Gifts_OneOf(compactNumericCountString(Int(availability.total), decimalSeparator: params.presentationData.dateTimeFormat.decimalSeparator)).string + } else { + ribbonText = nil + } + case let .unique(gift): + subject = .uniqueGift(gift: gift, price: nil) + peer = nil + resellPrice = gift.resellStars + + if let _ = resellPrice { + ribbonText = params.presentationData.strings.PeerInfo_Gifts_Sale + ribbonFont = .larger + ribbonColor = .green + ribbonOutline = params.presentationData.theme.list.blocksBackgroundColor + } else { + if product.pinnedToTop || self.canSelect || self.isCollection { + ribbonFont = .monospaced + ribbonText = "#\(gift.number)" + } else { + ribbonText = params.presentationData.strings.PeerInfo_Gifts_OneOf(compactNumericCountString(Int(gift.availability.issued), decimalSeparator: params.presentationData.dateTimeFormat.decimalSeparator)).string + } + for attribute in gift.attributes { + if case let .backdrop(_, _, innerColor, outerColor, _, _, _) = attribute { + ribbonColor = .custom(outerColor, innerColor) + break + } + } + } + } + + let itemReferenceId = product.reference?.stringValue ?? "" + + var isAdded = false + if let ignoreCollection = self.ignoreCollection, let collectionIds = product.collectionIds, collectionIds.contains(ignoreCollection) { + isAdded = true + } + + var itemAlpha: CGFloat = 1.0 + if isAdded { + itemAlpha = 0.3 + } + + let _ = visibleItem.update( + transition: itemTransition, + component: AnyComponent( + GiftItemComponent( + context: self.context, + theme: params.presentationData.theme, + strings: params.presentationData.strings, + peer: peer, + subject: subject, + ribbon: ribbonText.flatMap { GiftItemComponent.Ribbon(text: $0, font: ribbonFont, color: ribbonColor, outline: ribbonOutline) }, + resellPrice: resellPrice, + isHidden: !product.savedToProfile, + isSelected: self.selectedItemIds.contains(itemReferenceId), + isPinned: !self.canSelect && product.pinnedToTop, + isEditing: self.isReordering && !self.isCollection, + mode: self.canSelect && !isAdded ? .select : .profile, + action: { [weak self] in + guard let self, !isAdded, let presentationData = self.currentParams?.presentationData else { + return + } + if self.canSelect { + if self.selectedItemIds.contains(itemReferenceId) { + self.selectedItemIds.remove(itemReferenceId) + } else { + self.selectedItemIds.insert(itemReferenceId) + self.selectedItemsMap[itemReferenceId] = product + } + self.selectionUpdated() + self.updateScrolling(transition: .easeInOut(duration: 0.25)) + } else if self.isReordering { + if case .unique = product.gift, !product.pinnedToTop, let reference = product.reference, let items = self.starsProducts { + if self.pinnedReferences.count >= self.maxPinnedCount { + self.parentController?.present(UndoOverlayController(presentationData: presentationData, content: .info(title: nil, text: presentationData.strings.PeerInfo_Gifts_ToastPinLimit_Text(Int32(self.maxPinnedCount)), timeout: nil, customUndoText: nil), elevatedLayout: true, animateInAsReplacement: false, action: { _ in return false }), in: .window(.root)) + return + } + + var reorderedPinnedReferences = Set() + if let current = self.reorderedPinnedReferences { + reorderedPinnedReferences = current + } + reorderedPinnedReferences.insert(reference) + self.reorderedPinnedReferences = reorderedPinnedReferences + + if let maxPinnedIndex = items.lastIndex(where: { $0.pinnedToTop }) { + var reorderedReferences: [StarGiftReference] + if let current = self.reorderedReferences { + reorderedReferences = current + } else { + let ids = items.compactMap { item -> StarGiftReference? in + return item.reference + } + reorderedReferences = ids + } + reorderedReferences.removeAll(where: { $0 == reference }) + reorderedReferences.insert(reference, at: maxPinnedIndex + 1) + self.reorderedReferences = reorderedReferences + } + } + } else { + let allSubjects: [GiftViewScreen.Subject] = (self.starsProducts ?? []).map { .profileGift(self.peerId, $0) } + let index = self.starsProducts?.firstIndex(where: { $0 == product }) ?? 0 + + var dismissImpl: (() -> Void)? + let controller = GiftViewScreen( + context: self.context, + subject: .profileGift(self.peerId, product), + allSubjects: allSubjects, + index: index, + updateSavedToProfile: { [weak self] reference, added in + guard let self else { + return + } + self.profileGifts.updateStarGiftAddedToProfile(reference: reference, added: added) + }, + convertToStars: { [weak self] in + guard let self, let reference = product.reference else { + return + } + self.profileGifts.convertStarGift(reference: reference) + }, + transferGift: { [weak self] prepaid, peerId in + guard let self, let reference = product.reference else { + return .complete() + } + return self.profileGifts.transferStarGift(prepaid: prepaid, reference: reference, peerId: peerId) + }, + upgradeGift: { [weak self] formId, keepOriginalInfo in + guard let self, let reference = product.reference else { + return .never() + } + return self.profileGifts.upgradeStarGift(formId: formId, reference: reference, keepOriginalInfo: keepOriginalInfo) + }, + buyGift: { [weak self] slug, peerId, price in + guard let self else { + return .never() + } + return self.profileGifts.buyStarGift(slug: slug, peerId: peerId, price: price) + }, + updateResellStars: { [weak self] price in + guard let self, let reference = product.reference else { + return .never() + } + return self.profileGifts.updateStarGiftResellPrice(reference: reference, price: price) + }, + togglePinnedToTop: { [weak self] pinnedToTop in + guard let self else { + return false + } + if let reference = product.reference { + if pinnedToTop && self.pinnedReferences.count >= self.maxPinnedCount { +// self.displayUnpinScreen(gift: product, completion: { + dismissImpl?() +// }) + return false + } + self.profileGifts.updateStarGiftPinnedToTop(reference: reference, pinnedToTop: pinnedToTop) + + var title = "" + if case let .unique(uniqueGift) = product.gift { + title = "\(uniqueGift.title) #\(presentationStringsFormattedNumber(uniqueGift.number, params.presentationData.dateTimeFormat.groupingSeparator))" + } + + if pinnedToTop { + Queue.mainQueue().after(0.35) { + let toastTitle = params.presentationData.strings.PeerInfo_Gifts_ToastPinned_TitleNew(title).string + let toastText = params.presentationData.strings.PeerInfo_Gifts_ToastPinned_Text + self.parentController?.present(UndoOverlayController(presentationData: params.presentationData, content: .universal(animation: "anim_toastpin", scale: 0.06, colors: [:], title: toastTitle, text: toastText, customUndoText: nil, timeout: 5), elevatedLayout: true, animateInAsReplacement: false, action: { _ in return false }), in: .window(.root)) + } + } + } + return true + }, + shareStory: { [weak self] uniqueGift in + guard let self, let parentController = self.parentController else { + return + } + Queue.mainQueue().after(0.15) { + let controller = self.context.sharedContext.makeStorySharingScreen(context: self.context, subject: .gift(uniqueGift), parentController: parentController) + parentController.push(controller) + } + } + ) + dismissImpl = { [weak controller] in + controller?.dismissAnimated() + } + self.parentController?.push(controller) + } + }, + contextAction: self.isReordering || self.canSelect ? nil : { [weak self] view, gesture in + guard let self else { + return + } + self.contextAction?(product, view, gesture) + } + ) + ), + environment: {}, + containerSize: starsOptionSize + ) + if let itemView = visibleItem.view { + if itemView.superview == nil { + self.addSubview(itemView) + + if !transition.animation.isImmediate { + itemView.layer.animateScale(from: 0.01, to: 1.0, duration: 0.25) + itemView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25) + } + } + var itemFrame = itemFrame + var isReordering = false + if let reorderingItem = self.reorderingItem, itemId == reorderingItem.id { + itemFrame = itemFrame.size.centered(around: reorderingItem.position) + isReordering = true + } + if self.isReordering, itemView.layer.animation(forKey: "position") != nil && !isReordering { + } else { + itemTransition.setFrame(view: itemView, frame: itemFrame) + } + + itemTransition.setAlpha(view: itemView, alpha: itemAlpha) + if itemAlpha < 1.0 { + itemView.layer.allowsGroupOpacity = true + } + + if self.isReordering && (product.pinnedToTop || self.isCollection) { + if itemView.layer.animation(forKey: "shaking_position") == nil { + itemView.layer.addReorderingShaking() + } + } else { + if itemView.layer.animation(forKey: "shaking_position") != nil { + itemView.layer.removeAnimation(forKey: "shaking_position") + itemView.layer.removeAnimation(forKey: "shaking_rotation") + } + } + } + } + itemFrame.origin.x += itemFrame.width + optionSpacing + if itemFrame.maxX > params.size.width { + itemFrame.origin.x = itemsSideInset + itemFrame.origin.y += starsOptionSize.height + optionSpacing + } + index += 1 + } + + var removeIds: [AnyHashable] = [] + for (id, item) in self.starsItems { + if !validIds.contains(id) { + removeIds.append(id) + if let itemView = item.1.view { + if !transition.animation.isImmediate { + itemView.layer.animateScale(from: 1.0, to: 0.01, duration: 0.25, removeOnCompletion: false) + itemView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, removeOnCompletion: false, completion: { _ in + itemView.removeFromSuperview() + }) + } else { + itemView.removeFromSuperview() + } + } + } + } + for id in removeIds { + self.starsItems.removeValue(forKey: id) + } + + var contentHeight = ceil(CGFloat(starsProducts.count) / CGFloat(defaultItemsInRow)) * (starsOptionSize.height + optionSpacing) - optionSpacing + topInset + 16.0 + + let size = params.size + let sideInset = params.sideInset + let bottomInset = params.bottomInset + let presentationData = params.presentationData + + self.theme = presentationData.theme + + let textFont = Font.regular(13.0) + let boldTextFont = Font.semibold(13.0) + let textColor = presentationData.theme.list.itemSecondaryTextColor + let linkColor = presentationData.theme.list.itemAccentColor + let markdownAttributes = MarkdownAttributes(body: MarkdownAttributeSet(font: textFont, textColor: textColor), bold: MarkdownAttributeSet(font: boldTextFont, textColor: textColor), link: MarkdownAttributeSet(font: boldTextFont, textColor: linkColor), linkAttribute: { _ in + return nil + }) + + let buttonSideInset = sideInset + 16.0 + let buttonSize = CGSize(width: size.width - buttonSideInset * 2.0, height: 50.0) + let effectiveBottomInset = max(8.0, bottomInset) + let bottomPanelHeight = effectiveBottomInset + buttonSize.height + 8.0 + let visibleHeight = params.visibleHeight + + let panelTransition = ComponentTransition.immediate + let fadeTransition = ComponentTransition.easeInOut(duration: 0.25) + if self.resultsAreEmpty && self.isCollection { + let sideInset: CGFloat = 44.0 + let topInset: CGFloat = 52.0 + let emptyTextSpacing: CGFloat = 18.0 + + self.emptyResultsClippingView.isHidden = false + + panelTransition.setFrame(view: self.emptyResultsClippingView, frame: CGRect(origin: CGPoint(x: 0.0, y: 48.0), size: params.size)) + panelTransition.setBounds(view: self.emptyResultsClippingView, bounds: CGRect(origin: CGPoint(x: 0.0, y: 48.0), size: params.size)) + + //TODO:localize + let emptyResultsTitleSize = self.emptyResultsTitle.update( + transition: .immediate, + component: AnyComponent( + MultilineTextComponent( + text: .plain(NSAttributedString(string: "Organize Your Gifts", font: Font.semibold(17.0), textColor: presentationData.theme.list.itemPrimaryTextColor)), + horizontalAlignment: .center + ) + ), + environment: {}, + containerSize: CGSize(width: params.size.width - sideInset * 2.0, height: params.size.height) + ) + let emptyResultsTextSize = self.emptyResultsText.update( + transition: .immediate, + component: AnyComponent( + MultilineTextComponent( + text: .plain(NSAttributedString(string: "Add some gifts to this collection.", font: Font.regular(15.0), textColor: presentationData.theme.list.itemSecondaryTextColor)), + horizontalAlignment: .center + ) + ), + environment: {}, + containerSize: CGSize(width: params.size.width - sideInset * 2.0, height: params.size.height) + ) + let buttonAttributedString = NSAttributedString(string: "Add to Collection", font: Font.semibold(17.0), textColor: .white, paragraphAlignment: .center) + let emptyResultsActionSize = self.emptyResultsAction.update( + transition: .immediate, + component: AnyComponent( + ButtonComponent( + background: ButtonComponent.Background( + color: presentationData.theme.list.itemCheckColors.fillColor, + foreground: presentationData.theme.list.itemCheckColors.foregroundColor, + pressedColor: presentationData.theme.list.itemCheckColors.fillColor.withMultipliedAlpha(0.8) + ), + content: AnyComponentWithIdentity( + id: AnyHashable(buttonAttributedString.string), + component: AnyComponent(MultilineTextComponent(text: .plain(buttonAttributedString))) + ), + isEnabled: true, + action: { [weak self] in + self?.addToCollection?() + } + ) + ), + environment: {}, + containerSize: CGSize(width: 240.0, height: 50.0) + ) + + let emptyTotalHeight = emptyResultsTitleSize.height + emptyTextSpacing + emptyResultsTextSize.height + emptyTextSpacing + emptyResultsActionSize.height + let emptyTitleY = topInset + floorToScreenPixels((visibleHeight - topInset - bottomInset - emptyTotalHeight) / 2.0) + + let emptyResultsTitleFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((params.size.width - emptyResultsTitleSize.width) / 2.0), y: emptyTitleY), size: emptyResultsTitleSize) + let emptyResultsTextFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((params.size.width - emptyResultsTextSize.width) / 2.0), y: emptyResultsTitleFrame.maxY + emptyTextSpacing), size: emptyResultsTextSize) + let emptyResultsActionFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((params.size.width - emptyResultsActionSize.width) / 2.0), y: emptyResultsTextFrame.maxY + emptyTextSpacing), size: emptyResultsActionSize) + + if let view = self.emptyResultsTitle.view { + if view.superview == nil { + view.alpha = 0.0 + fadeTransition.setAlpha(view: view, alpha: 1.0) + self.emptyResultsClippingView.addSubview(view) + } + view.bounds = CGRect(origin: .zero, size: emptyResultsTitleFrame.size) + panelTransition.setPosition(view: view, position: emptyResultsTitleFrame.center) + } + if let view = self.emptyResultsText.view { + if view.superview == nil { + view.alpha = 0.0 + fadeTransition.setAlpha(view: view, alpha: 1.0) + self.emptyResultsClippingView.addSubview(view) + } + view.bounds = CGRect(origin: .zero, size: emptyResultsTextFrame.size) + panelTransition.setPosition(view: view, position: emptyResultsTextFrame.center) + } + if let view = self.emptyResultsAction.view { + if view.superview == nil { + view.alpha = 0.0 + fadeTransition.setAlpha(view: view, alpha: 1.0) + self.emptyResultsClippingView.addSubview(view) + } + view.bounds = CGRect(origin: .zero, size: emptyResultsActionFrame.size) + panelTransition.setPosition(view: view, position: emptyResultsActionFrame.center) + } + } else if self.filteredResultsAreEmpty { + let sideInset: CGFloat = 44.0 + let emptyAnimationHeight = 148.0 + let topInset: CGFloat = 0.0 + let bottomInset: CGFloat = bottomPanelHeight + let emptyAnimationSpacing: CGFloat = 20.0 + let emptyTextSpacing: CGFloat = 18.0 + + self.emptyResultsClippingView.isHidden = false + + panelTransition.setFrame(view: self.emptyResultsClippingView, frame: CGRect(origin: CGPoint(x: 0.0, y: 48.0), size: params.size)) + panelTransition.setBounds(view: self.emptyResultsClippingView, bounds: CGRect(origin: CGPoint(x: 0.0, y: 48.0), size: params.size)) + + let emptyResultsTitleSize = self.emptyResultsTitle.update( + transition: .immediate, + component: AnyComponent( + MultilineTextComponent( + text: .plain(NSAttributedString(string: presentationData.strings.PeerInfo_Gifts_NoResults, font: Font.semibold(17.0), textColor: presentationData.theme.list.itemPrimaryTextColor)), + horizontalAlignment: .center + ) + ), + environment: {}, + containerSize: params.size + ) + let emptyResultsActionSize = self.emptyResultsAction.update( + transition: .immediate, + component: AnyComponent( + PlainButtonComponent( + content: AnyComponent( + MultilineTextComponent( + text: .plain(NSAttributedString(string: presentationData.strings.PeerInfo_Gifts_NoResults_ViewAll, font: Font.regular(17.0), textColor: presentationData.theme.list.itemAccentColor)), + horizontalAlignment: .center, + maximumNumberOfLines: 0 + ) + ), + effectAlignment: .center, + action: { [weak self] in + guard let self else { + return + } + self.profileGifts.updateFilter(.All) + }, + animateScale: false + ) + ), + environment: {}, + containerSize: CGSize(width: params.size.width - sideInset * 2.0, height: visibleHeight) + ) + let emptyResultsAnimationSize = self.emptyResultsAnimation.update( + transition: .immediate, + component: AnyComponent(LottieComponent( + content: LottieComponent.AppBundleContent(name: "ChatListNoResults") + )), + environment: {}, + containerSize: CGSize(width: emptyAnimationHeight, height: emptyAnimationHeight) + ) + + let emptyTotalHeight = emptyAnimationHeight + emptyAnimationSpacing + emptyResultsTitleSize.height + emptyResultsActionSize.height + emptyTextSpacing + let emptyAnimationY = topInset + floorToScreenPixels((visibleHeight - topInset - bottomInset - emptyTotalHeight) / 2.0) + + let emptyResultsAnimationFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((params.size.width - emptyResultsAnimationSize.width) / 2.0), y: emptyAnimationY), size: emptyResultsAnimationSize) + + let emptyResultsTitleFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((params.size.width - emptyResultsTitleSize.width) / 2.0), y: emptyResultsAnimationFrame.maxY + emptyAnimationSpacing), size: emptyResultsTitleSize) + + let emptyResultsActionFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((params.size.width - emptyResultsActionSize.width) / 2.0), y: emptyResultsTitleFrame.maxY + emptyTextSpacing), size: emptyResultsActionSize) + + if let view = self.emptyResultsAnimation.view as? LottieComponent.View { + if view.superview == nil { + view.alpha = 0.0 + fadeTransition.setAlpha(view: view, alpha: 1.0) + self.emptyResultsClippingView.addSubview(view) + view.playOnce() + } + view.bounds = CGRect(origin: .zero, size: emptyResultsAnimationFrame.size) + panelTransition.setPosition(view: view, position: emptyResultsAnimationFrame.center) + } + if let view = self.emptyResultsTitle.view { + if view.superview == nil { + view.alpha = 0.0 + fadeTransition.setAlpha(view: view, alpha: 1.0) + self.emptyResultsClippingView.addSubview(view) + } + view.bounds = CGRect(origin: .zero, size: emptyResultsTitleFrame.size) + panelTransition.setPosition(view: view, position: emptyResultsTitleFrame.center) + } + if let view = self.emptyResultsAction.view { + if view.superview == nil { + view.alpha = 0.0 + fadeTransition.setAlpha(view: view, alpha: 1.0) + self.emptyResultsClippingView.addSubview(view) + } + view.bounds = CGRect(origin: .zero, size: emptyResultsActionFrame.size) + panelTransition.setPosition(view: view, position: emptyResultsActionFrame.center) + } + } else { + if let view = self.emptyResultsAnimation.view { + fadeTransition.setAlpha(view: view, alpha: 0.0, completion: { _ in + view.removeFromSuperview() + }) + } + if let view = self.emptyResultsTitle.view { + fadeTransition.setAlpha(view: view, alpha: 0.0, completion: { _ in + self.emptyResultsClippingView.isHidden = true + view.removeFromSuperview() + }) + } + if let view = self.emptyResultsText.view { + fadeTransition.setAlpha(view: view, alpha: 0.0, completion: { _ in + view.removeFromSuperview() + }) + } + if let view = self.emptyResultsAction.view { + fadeTransition.setAlpha(view: view, alpha: 0.0, completion: { _ in + view.removeFromSuperview() + }) + } + } + + fadeTransition.setAlpha(view: self.emptyResultsClippingView, alpha: visibleHeight < 300.0 ? 0.0 : 1.0) + + if self.peerId == self.context.account.peerId, !self.canSelect && !self.filteredResultsAreEmpty && self.profileGifts.collectionId == nil { + let footerText: ComponentView + if let current = self.footerText { + footerText = current + } else { + footerText = ComponentView() + self.footerText = footerText + } + let footerTextSize = footerText.update( + transition: .immediate, + component: AnyComponent( + BalancedTextComponent( + text: .markdown(text: presentationData.strings.PeerInfo_Gifts_Info, attributes: markdownAttributes), + horizontalAlignment: .center, + maximumNumberOfLines: 0, + lineSpacing: 0.2 + ) + ), + environment: {}, + containerSize: CGSize(width: size.width - 32.0, height: 200.0) + ) + if let view = footerText.view { + if view.superview == nil { + self.addSubview(view) + } + transition.setFrame(view: view, frame: CGRect(origin: CGPoint(x: floor((size.width - footerTextSize.width) / 2.0), y: contentHeight), size: footerTextSize)) + } + contentHeight += footerTextSize.height + } + + return contentHeight + } + + func update(size: CGSize, sideInset: CGFloat, bottomInset: CGFloat, deviceMetrics: DeviceMetrics, visibleHeight: CGFloat, isScrollingLockedAtTop: Bool, expandProgress: CGFloat, presentationData: PresentationData, synchronous: Bool, visibleBounds: CGRect, transition: ContainedViewLayoutTransition) -> CGFloat { + self.currentParams = (size, sideInset, bottomInset, deviceMetrics, visibleHeight, isScrollingLockedAtTop, expandProgress, presentationData) + self.presentationDataPromise.set(.single(presentationData)) + + return self.updateScrolling(topInset: self.topInset ?? 0.0, visibleBounds: visibleBounds, transition: ComponentTransition(transition)) + } +} + +private extension StarGiftReference { + var stringValue: String { + switch self { + case let .message(messageId): + return "m_\(messageId.id)" + case let .peer(peerId, id): + return "p_\(peerId.toInt64())_\(id)" + case let .slug(slug): + return "s_\(slug)" + } + } +} + + +private final class ReorderGestureRecognizer: UIGestureRecognizer { + private let shouldBegin: (CGPoint) -> (allowed: Bool, requiresLongPress: Bool, id: AnyHashable?, item: ComponentView?) + private let willBegin: (CGPoint) -> Void + private let began: (AnyHashable) -> Void + private let ended: () -> Void + private let moved: (CGPoint) -> Void + private let isActiveUpdated: (Bool) -> Void + + private var initialLocation: CGPoint? + private var longTapTimer: SwiftSignalKit.Timer? + private var longPressTimer: SwiftSignalKit.Timer? + + private var id: AnyHashable? + private var itemView: ComponentView? + + init(shouldBegin: @escaping (CGPoint) -> (allowed: Bool, requiresLongPress: Bool, id: AnyHashable?, item: ComponentView?), willBegin: @escaping (CGPoint) -> Void, began: @escaping (AnyHashable) -> Void, ended: @escaping () -> Void, moved: @escaping (CGPoint) -> Void, isActiveUpdated: @escaping (Bool) -> Void) { + self.shouldBegin = shouldBegin + self.willBegin = willBegin + self.began = began + self.ended = ended + self.moved = moved + self.isActiveUpdated = isActiveUpdated + + super.init(target: nil, action: nil) + } + + deinit { + self.longTapTimer?.invalidate() + self.longPressTimer?.invalidate() + } + + private func startLongTapTimer() { + self.longTapTimer?.invalidate() + let longTapTimer = SwiftSignalKit.Timer(timeout: 0.25, repeat: false, completion: { [weak self] in + self?.longTapTimerFired() + }, queue: Queue.mainQueue()) + self.longTapTimer = longTapTimer + longTapTimer.start() + } + + private func stopLongTapTimer() { + self.itemView = nil + self.longTapTimer?.invalidate() + self.longTapTimer = nil + } + + private func startLongPressTimer() { + self.longPressTimer?.invalidate() + let longPressTimer = SwiftSignalKit.Timer(timeout: 0.6, repeat: false, completion: { [weak self] in + self?.longPressTimerFired() + }, queue: Queue.mainQueue()) + self.longPressTimer = longPressTimer + longPressTimer.start() + } + + private func stopLongPressTimer() { + self.itemView = nil + self.longPressTimer?.invalidate() + self.longPressTimer = nil + } + + override func reset() { + super.reset() + + self.itemView = nil + self.stopLongTapTimer() + self.stopLongPressTimer() + self.initialLocation = nil + + self.isActiveUpdated(false) + } + + private func longTapTimerFired() { + guard let location = self.initialLocation else { + return + } + + self.longTapTimer?.invalidate() + self.longTapTimer = nil + + self.willBegin(location) + } + + private func longPressTimerFired() { + guard let _ = self.initialLocation else { + return + } + + self.isActiveUpdated(true) + self.state = .began + self.longPressTimer?.invalidate() + self.longPressTimer = nil + self.longTapTimer?.invalidate() + self.longTapTimer = nil + if let id = self.id { + self.began(id) + } + self.isActiveUpdated(true) + } + + override func touchesBegan(_ touches: Set, with event: UIEvent) { + super.touchesBegan(touches, with: event) + + if self.numberOfTouches > 1 { + self.isActiveUpdated(false) + self.state = .failed + self.ended() + return + } + + if self.state == .possible { + if let location = touches.first?.location(in: self.view) { + let (allowed, requiresLongPress, id, itemView) = self.shouldBegin(location) + if allowed { + self.isActiveUpdated(true) + + self.id = id + self.itemView = itemView + self.initialLocation = location + if requiresLongPress { + self.startLongTapTimer() + self.startLongPressTimer() + } else { + self.state = .began + if let id = self.id { + self.began(id) + } + } + } else { + self.isActiveUpdated(false) + self.state = .failed + } + } else { + self.isActiveUpdated(false) + self.state = .failed + } + } + } + + override func touchesEnded(_ touches: Set, with event: UIEvent) { + super.touchesEnded(touches, with: event) + + self.initialLocation = nil + + self.stopLongTapTimer() + if self.longPressTimer != nil { + self.stopLongPressTimer() + self.isActiveUpdated(false) + self.state = .failed + } + if self.state == .began || self.state == .changed { + self.isActiveUpdated(false) + self.ended() + self.state = .failed + } + } + + override func touchesCancelled(_ touches: Set, with event: UIEvent) { + super.touchesCancelled(touches, with: event) + + self.initialLocation = nil + + self.stopLongTapTimer() + if self.longPressTimer != nil { + self.isActiveUpdated(false) + self.stopLongPressTimer() + self.state = .failed + } + if self.state == .began || self.state == .changed { + self.isActiveUpdated(false) + self.ended() + self.state = .failed + } + } + + override func touchesMoved(_ touches: Set, with event: UIEvent) { + super.touchesMoved(touches, with: event) + + if (self.state == .began || self.state == .changed), let initialLocation = self.initialLocation, let location = touches.first?.location(in: self.view) { + self.state = .changed + let offset = CGPoint(x: location.x - initialLocation.x, y: location.y - initialLocation.y) + self.moved(offset) + } else if let touch = touches.first, let initialTapLocation = self.initialLocation, self.longPressTimer != nil { + let touchLocation = touch.location(in: self.view) + let dX = touchLocation.x - initialTapLocation.x + let dY = touchLocation.y - initialTapLocation.y + + if dX * dX + dY * dY > 3.0 * 3.0 { + self.stopLongTapTimer() + self.stopLongPressTimer() + self.initialLocation = nil + self.isActiveUpdated(false) + self.state = .failed + } + } + } +} diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoGiftsPaneNode.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoGiftsPaneNode.swift index 7588e832cc..8919270716 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoGiftsPaneNode.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoGiftsPaneNode.swift @@ -6,6 +6,7 @@ import TelegramCore import SwiftSignalKit import Postbox import TelegramPresentationData +import PresentationDataUtils import AccountContext import ContextUI import PhotoResources @@ -23,32 +24,73 @@ import PeerInfoPaneNode import GiftItemComponent import PlainButtonComponent import GiftViewScreen -import SolidRoundedButtonNode +import ButtonComponent import UndoUI import CheckComponent import LottieComponent import ContextUI +import TabSelectorComponent +import BundleIconComponent +import EmojiTextAttachmentView +import TextFormat +import PromptUI public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScrollViewDelegate { + public enum GiftCollection: Equatable { + case all + case collection(Int32) + case create + + init(rawValue: Int32) { + switch rawValue { + case 0: + self = .all + case -1: + self = .create + default: + self = .collection(rawValue) + } + } + + public var rawValue: Int32 { + switch self { + case .all: + return 0 + case .create: + return -1 + case let .collection(id): + return id + } + } + } + private let context: AccountContext private let peerId: PeerId + private let profileGiftsCollections: ProfileGiftsCollectionsContext private let profileGifts: ProfileGiftsContext private let canManage: Bool private let canGift: Bool - - private var dataDisposable: Disposable? + private var resultsAreEmpty = false private let chatControllerInteraction: ChatControllerInteraction - public weak var parentController: ViewController? + public weak var parentController: ViewController? { + didSet { + self.giftsListView.parentController = self.parentController + } + } private let backgroundNode: ASDisplayNode private let scrollNode: ASScrollNode + private var giftsListView: GiftsListView + + private let tabSelector = ComponentView() + public private(set) var currentCollection: GiftCollection = .all private var footerText: ComponentView? private var panelBackground: NavigationBackgroundNode? private var panelSeparator: ASDisplayNode? - private var panelButton: SolidRoundedButtonNode? + private var panelButton: ComponentView? private var panelCheck: ComponentView? private let emptyResultsClippingView = UIView() @@ -56,11 +98,16 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr private let emptyResultsTitle = ComponentView() private let emptyResultsAction = ComponentView() - private var currentParams: (size: CGSize, sideInset: CGFloat, bottomInset: CGFloat, deviceMetrics: DeviceMetrics, visibleHeight: CGFloat, isScrollingLockedAtTop: Bool, expandProgress: CGFloat, presentationData: PresentationData)? + private var currentParams: (size: CGSize, topInset: CGFloat, sideInset: CGFloat, bottomInset: CGFloat, deviceMetrics: DeviceMetrics, visibleHeight: CGFloat, isScrollingLockedAtTop: Bool, expandProgress: CGFloat, navigationHeight: CGFloat, presentationData: PresentationData)? private var theme: PresentationTheme? private let presentationDataPromise = Promise() + private var collectionsDisposable: Disposable? + private var collections: [StarGiftCollection]? + private var reorderedCollectionIds: [Int32]? + private var isReordering = false + private let ready = Promise() private var didSetReady: Bool = false public var isReady: Signal { @@ -76,287 +123,159 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr public var tabBarOffset: CGFloat { return 0.0 } - - private var starsProducts: [ProfileGiftsContext.State.StarGift]? - private var starsItems: [AnyHashable: (StarGiftReference?, ComponentView)] = [:] - private var resultsAreFiltered = false - private var resultsAreEmpty = false - private var pinnedReferences: [StarGiftReference] = [] - private var isReordering: Bool = false - private var reorderingItem: (id: AnyHashable, initialPosition: CGPoint, position: CGPoint)? - private var reorderedReferences: [StarGiftReference]? { - didSet { - self.reorderedReferencesPromise.set(self.reorderedReferences) - } + public var giftsContext: ProfileGiftsContext { + return self.giftsListView.profileGifts } - private var reorderedReferencesPromise = ValuePromise<[StarGiftReference]?>(nil) - private var reorderedPinnedReferences: Set? { - didSet { - self.reorderedPinnedReferencesPromise.set(self.reorderedPinnedReferences) - } - } - private var reorderedPinnedReferencesPromise = ValuePromise?>(nil) + private let collectionsMaxCount: Int - private var reorderRecognizer: ReorderGestureRecognizer? - - private let maxPinnedCount: Int - - public init(context: AccountContext, peerId: PeerId, chatControllerInteraction: ChatControllerInteraction, profileGifts: ProfileGiftsContext, canManage: Bool, canGift: Bool) { + public init(context: AccountContext, peerId: PeerId, chatControllerInteraction: ChatControllerInteraction, profileGiftsCollections: ProfileGiftsCollectionsContext, profileGifts: ProfileGiftsContext, canManage: Bool, canGift: Bool) { self.context = context self.peerId = peerId self.chatControllerInteraction = chatControllerInteraction + self.profileGiftsCollections = profileGiftsCollections self.profileGifts = profileGifts self.canManage = canManage self.canGift = canGift - self.backgroundNode = ASDisplayNode() - self.scrollNode = ASScrollNode() - - if let value = context.currentAppConfiguration.with({ $0 }).data?["stargifts_pinned_to_top_limit"] as? Double { - self.maxPinnedCount = Int(value) + if let value = context.currentAppConfiguration.with({ $0 }).data?["stargifts_collections_limit"] as? Double { + self.collectionsMaxCount = Int(value) } else { - self.maxPinnedCount = 6 + self.collectionsMaxCount = 6 } + self.backgroundNode = ASDisplayNode() + self.scrollNode = ASScrollNode() + self.giftsListView = GiftsListView(context: context, peerId: peerId, profileGifts: profileGifts, giftsCollections: profileGiftsCollections, canSelect: false) + super.init() self.addSubnode(self.backgroundNode) self.addSubnode(self.scrollNode) - - self.dataDisposable = combineLatest( - queue: Queue.mainQueue(), - profileGifts.state, - self.reorderedReferencesPromise.get() - ).startStrict(next: { [weak self] state, reorderedReferences in + + self.statusPromise.set(self.giftsListView.status) + self.ready.set(self.giftsListView.isReady) + + self.giftsListView.contextAction = { [weak self] gift, view, gesture in guard let self else { return } - let isFirstTime = self.starsProducts == nil - let presentationData = self.context.sharedContext.currentPresentationData.with { $0 } - self.statusPromise.set(.single(PeerInfoStatusData(text: presentationData.strings.SharedMedia_GiftCount(state.count ?? 0), isActivity: true, key: .gifts))) - - if self.isReordering { - var stateItems: [ProfileGiftsContext.State.StarGift] = state.gifts - if let reorderedReferences { - var fixedStateItems: [ProfileGiftsContext.State.StarGift] = [] - - var seenIds = Set() - for reference in reorderedReferences { - if let index = stateItems.firstIndex(where: { $0.reference == reference }) { - seenIds.insert(reference) - var item = stateItems[index] - if self.reorderedPinnedReferences?.contains(reference) == true, !item.pinnedToTop { - item = item.withPinnedToTop(true) - } - fixedStateItems.append(item) - } - } - - for item in stateItems { - if let reference = item.reference, !seenIds.contains(reference) { - var item = item - if self.reorderedPinnedReferences?.contains(reference) == true, !item.pinnedToTop { - item = item.withPinnedToTop(true) - } - fixedStateItems.append(item) - } - } - stateItems = fixedStateItems - } - self.starsProducts = stateItems - self.pinnedReferences = Array(stateItems.filter { $0.pinnedToTop }.compactMap { $0.reference }) - } else { - self.starsProducts = state.filteredGifts - self.pinnedReferences = Array(state.gifts.filter { $0.pinnedToTop }.compactMap { $0.reference }) - } - - self.resultsAreFiltered = state.filter != .All - self.resultsAreEmpty = state.filter != .All && state.filteredGifts.isEmpty + self.contextAction(gift: gift, view: view, gesture: gesture) + } - if !self.didSetReady { - self.didSetReady = true - self.ready.set(.single(true)) + self.collectionsDisposable = (profileGiftsCollections.state + |> deliverOnMainQueue).start(next: { [weak self] state in + guard let self else { + return } - - self.updateScrolling(transition: isFirstTime ? .immediate : .easeInOut(duration: 0.25)) + self.collections = state.collections + self.updateScrolling(transition: .easeInOut(duration: 0.2)) }) } deinit { - self.dataDisposable?.dispose() + self.collectionsDisposable?.dispose() } - + public override func didLoad() { super.didLoad() self.scrollNode.view.contentInsetAdjustmentBehavior = .never self.scrollNode.view.delegate = self - self.emptyResultsClippingView.clipsToBounds = true - self.scrollNode.view.addSubview(self.emptyResultsClippingView) - - let reorderRecognizer = ReorderGestureRecognizer( - shouldBegin: { [weak self] point in - guard let self, let (id, item) = self.item(at: point) else { - return (allowed: false, requiresLongPress: false, id: nil, item: nil) - } - return (allowed: true, requiresLongPress: false, id: id, item: item) - }, - willBegin: { point in - }, - began: { [weak self] item in - guard let self else { - return - } - self.setReorderingItem(item: item) - }, - ended: { [weak self] in - guard let self else { - return - } - self.setReorderingItem(item: nil) - }, - moved: { [weak self] distance in - guard let self else { - return - } - self.moveReorderingItem(distance: distance) - }, - isActiveUpdated: { _ in - } - ) - self.reorderRecognizer = reorderRecognizer - self.view.addGestureRecognizer(reorderRecognizer) - reorderRecognizer.isEnabled = false + self.scrollNode.view.insertSubview(self.giftsListView, at: 0) } private func item(at point: CGPoint) -> (AnyHashable, ComponentView)? { - let localPoint = self.scrollNode.view.convert(point, from: self.view) - for (id, visibleItem) in self.starsItems { - if let view = visibleItem.1.view, view.frame.contains(localPoint), let reference = visibleItem.0, self.pinnedReferences.contains(reference) { - return (id, visibleItem.1) - } + return self.giftsListView.item(at: self.giftsListView.convert(point, from: self.view)) + } + + public func createCollection(gifts: [ProfileGiftsContext.State.StarGift] = []) { + guard let params = self.currentParams else { + return } - return nil + if let collections = self.collections, collections.count >= self.collectionsMaxCount { + let alertController = textAlertController(context: self.context, title: "Limit Reached", text: "Please remove one of the existing collections to add a new one.", actions: [TextAlertAction(type: .defaultAction, title: params.presentationData.strings.Common_OK, action: {})]) + self.parentController?.present(alertController, in: .window(.root)) + return + } + + //TODO:localize + let promptController = promptController(sharedContext: self.context.sharedContext, updatedPresentationData: nil, text: "Create a New Collection", titleFont: .bold, subtitle: "Choose a name for your collection and start adding your gifts there.", value: "", placeholder: "Title", characterLimit: 20, displayCharacterLimit: true, apply: { [weak self] value in + guard let self, let value else { + return + } + let _ = self.profileGiftsCollections.createCollection(title: value, starGifts: gifts).start(next: { [weak self] collection in + guard let self else { + return + } + if let collection { + self.setCurrentCollection(collection: .collection(collection.id)) + } + }) + }) + self.parentController?.present(promptController, in: .window(.root)) + } + + public func deleteCollection(id: Int32) { + self.setCurrentCollection(collection: .all) + let _ = self.profileGiftsCollections.deleteCollection(id: id).start() + } + + public func addGiftsToCollection(id: Int32) { + let screen = AddGiftsScreen(context: self.context, peerId: self.peerId, collectionId: id, completion: { [weak self] gifts in + guard let self else { + return + } + let _ = self.profileGiftsCollections.addGifts(id: id, gifts: gifts).start() + }) + self.parentController?.push(screen) + } + + public func renameCollection(id: Int32) { + guard let collection = self.collections?.first(where: { $0.id == id }) else { + return + } + + let promptController = promptController(sharedContext: self.context.sharedContext, updatedPresentationData: nil, text: "Rename Collection", titleFont: .bold, value: collection.title, placeholder: "Title", characterLimit: 20, displayCharacterLimit: true, apply: { [weak self] value in + guard let self, let value else { + return + } + let _ = self.profileGiftsCollections.renameCollection(id: id, title: value).start() + }) + self.parentController?.present(promptController, in: .window(.root)) } public func beginReordering() { - self.profileGifts.updateFilter(.All) - self.profileGifts.updateSorting(.date) - - if let parentController = self.parentController as? PeerInfoScreen { - parentController.togglePaneIsReordering(isReordering: true) - } else { - self.updateIsReordering(isReordering: true, animated: true) - } + self.giftsListView.beginReordering() } public func endReordering() { - if let parentController = self.parentController as? PeerInfoScreen { - parentController.togglePaneIsReordering(isReordering: false) - } else { - self.updateIsReordering(isReordering: false, animated: true) - } + self.giftsListView.endReordering() } public func updateIsReordering(isReordering: Bool, animated: Bool) { if self.isReordering != isReordering { self.isReordering = isReordering - self.reorderRecognizer?.isEnabled = isReordering - - if !isReordering, let _ = self.reorderedReferences, let starsProducts = self.starsProducts { - var pinnedReferences: [StarGiftReference] = [] - for gift in starsProducts.prefix(self.maxPinnedCount) { - if gift.pinnedToTop, let reference = gift.reference { - pinnedReferences.append(reference) + if let collections = self.collections { + if isReordering { + var collectionIds: [Int32] = [] + for collection in collections { + collectionIds.append(collection.id) } - } - self.profileGifts.updatePinnedToTopStarGifts(references: pinnedReferences) - - Queue.mainQueue().after(1.0) { - self.reorderedReferences = nil - self.reorderedPinnedReferences = nil + self.reorderedCollectionIds = collectionIds + } else if let reorderedCollectionIds = self.reorderedCollectionIds { + let _ = self.profileGiftsCollections.reorderCollections(order: reorderedCollectionIds).start() + Queue.mainQueue().after(1.0, { + self.reorderedCollectionIds = nil + }) } } - - self.updateScrolling(transition: animated ? .spring(duration: 0.4) : .immediate) - } - } - - func setReorderingItem(item: AnyHashable?) { - var mappedItem: (AnyHashable, ComponentView)? - for (id, visibleItem) in self.starsItems { - if id == item { - mappedItem = (id, visibleItem.1) - break - } - } - - if self.reorderingItem?.id != mappedItem?.0 { - if let (id, visibleItem) = mappedItem, let view = visibleItem.view { - self.scrollNode.view.addSubview(view) - self.reorderingItem = (id, view.center, view.center) - } else { - self.reorderingItem = nil - } - self.updateScrolling(transition: item == nil ? .spring(duration: 0.3) : .immediate) - } - } - - func moveReorderingItem(distance: CGPoint) { - if let (id, initialPosition, _) = self.reorderingItem { - let targetPosition = CGPoint(x: initialPosition.x + distance.x, y: initialPosition.y + distance.y) - self.reorderingItem = (id, initialPosition, targetPosition) - self.updateScrolling(transition: .immediate) - - if let starsProducts = self.starsProducts, let visibleReorderingItem = self.starsItems[id] { - for (_, visibleItem) in self.starsItems { - if visibleItem.1 === visibleReorderingItem.1 { - continue - } - if let view = visibleItem.1.view, view.frame.contains(targetPosition), let reorderItemReference = self.starsItems[id]?.0 { - if let targetIndex = starsProducts.firstIndex(where: { $0.reference == visibleItem.0 }) { - self.reorderIfPossible(reference: reorderItemReference, toIndex: targetIndex) - } - break - } - } - } - } - } - - private func reorderIfPossible(reference: StarGiftReference, toIndex: Int) { - if let items = self.starsProducts { - var toIndex = toIndex - - let maxPinnedIndex = items.lastIndex(where: { $0.pinnedToTop }) - if let maxPinnedIndex { - toIndex = min(toIndex, maxPinnedIndex) - } else { - return - } - - var ids = items.compactMap { item -> StarGiftReference? in - return item.reference - } - - if let fromIndex = ids.firstIndex(of: reference) { - if fromIndex < toIndex { - ids.insert(reference, at: toIndex + 1) - ids.remove(at: fromIndex) - } else if fromIndex > toIndex { - ids.remove(at: fromIndex) - ids.insert(reference, at: toIndex) - } - } - if self.reorderedReferences != ids { - self.reorderedReferences = ids - - HapticFeedback().tap() - } + + self.giftsListView.updateIsReordering(isReordering: isReordering, animated: animated) + self.updateScrolling(transition: .easeInOut(duration: 0.2)) } } @@ -397,7 +316,7 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr } } - var updatedPinnedGifts = self.pinnedReferences + var updatedPinnedGifts = self.giftsListView.pinnedReferences if let index = updatedPinnedGifts.firstIndex(of: unpinnedReference), let reference = gift.reference { updatedPinnedGifts[index] = reference } @@ -419,323 +338,290 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr self.parentController?.push(controller) } - private var notify = false - func updateScrolling(interactive: Bool = false, transition: ComponentTransition) { - if let starsProducts = self.starsProducts, let params = self.currentParams { - let optionSpacing: CGFloat = 10.0 - let itemsSideInset = params.sideInset + 16.0 - - let defaultItemsInRow: Int - if params.size.width > params.size.height || params.size.width > 480.0 { - if case .tablet = params.deviceMetrics.type { - defaultItemsInRow = 4 - } else { - defaultItemsInRow = 5 + func setCurrentCollection(collection: GiftCollection) { + guard self.currentCollection != collection else { + return + } + var animateRight = false + if case let .collection(currentId) = self.currentCollection { + if case let .collection(nextId) = collection { + if let currentIndex = self.collections?.firstIndex(where: { $0.id == currentId }), let nextIndex = self.collections?.firstIndex(where: { $0.id == nextId }) { + animateRight = nextIndex > currentIndex } - } else { - defaultItemsInRow = 3 } - let itemsInRow = max(1, min(starsProducts.count, defaultItemsInRow)) - let defaultOptionWidth = (params.size.width - itemsSideInset * 2.0 - optionSpacing * CGFloat(defaultItemsInRow - 1)) / CGFloat(defaultItemsInRow) - let optionWidth = (params.size.width - itemsSideInset * 2.0 - optionSpacing * CGFloat(itemsInRow - 1)) / CGFloat(itemsInRow) - - let starsOptionSize = CGSize(width: optionWidth, height: defaultOptionWidth) - + } else { + animateRight = true + } + + let previousGiftsListView = self.giftsListView + + let profileGifts: ProfileGiftsContext + switch collection { + case let .collection(id): + profileGifts = self.profileGiftsCollections.giftsContextForCollection(id: id) + if case .ready = profileGifts.currentState?.dataState { + profileGifts.reload() + } + default: + profileGifts = self.profileGifts + } + + self.giftsListView = GiftsListView(context: self.context, peerId: self.peerId, profileGifts: profileGifts, giftsCollections: self.profileGiftsCollections, canSelect: false) + self.giftsListView.addToCollection = { [weak self] in + guard let self else { + return + } + if case let .collection(id) = collection { + self.addGiftsToCollection(id: id) + } + } + self.giftsListView.onContentUpdated = { [weak self] in + guard let self else { + return + } + if case .collection = collection { + self.resultsAreEmpty = self.giftsListView.resultsAreEmpty + } else { + self.resultsAreEmpty = false + } + if let params = self.currentParams { + self.update(size: params.size, topInset: params.topInset, sideInset: params.sideInset, bottomInset: params.bottomInset, deviceMetrics: params.deviceMetrics, visibleHeight: params.visibleHeight, isScrollingLockedAtTop: params.isScrollingLockedAtTop, expandProgress: params.expandProgress, navigationHeight: params.navigationHeight, presentationData: params.presentationData, synchronous: true, transition: .immediate) + } + } + self.giftsListView.parentController = self.parentController + self.giftsListView.contextAction = { [weak self] gift, view, gesture in + guard let self else { + return + } + self.contextAction(gift: gift, view: view, gesture: gesture) + } + self.giftsListView.frame = previousGiftsListView.frame + + self.scrollNode.view.insertSubview(self.giftsListView, aboveSubview: previousGiftsListView) + + let multiplier = animateRight ? 1.0 : -1.0 + + previousGiftsListView.layer.animatePosition(from: .zero, to: CGPoint(x: previousGiftsListView.frame.width * multiplier * -1.0, y: 0.0), duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, additive: true, completion: { _ in + previousGiftsListView.removeFromSuperview() + }) + self.giftsListView.layer.animatePosition(from: CGPoint(x: previousGiftsListView.frame.width * multiplier, y: 0.0), to: .zero, duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, additive: true) + + self.currentCollection = collection + self.updateScrolling(transition: .spring(duration: 0.25)) + + if let params = self.currentParams { let visibleBounds = self.scrollNode.bounds.insetBy(dx: 0.0, dy: -10.0) + let _ = self.giftsListView.update(size: params.size, sideInset: params.sideInset, bottomInset: params.bottomInset, deviceMetrics: params.deviceMetrics, visibleHeight: params.visibleHeight, isScrollingLockedAtTop: params.isScrollingLockedAtTop, expandProgress: params.expandProgress, presentationData: params.presentationData, synchronous: true, visibleBounds: visibleBounds, transition: .immediate) + } + } + + func openCollectionContextMenu(id: Int32, sourceNode: ASDisplayNode, gesture: ContextGesture?) { + guard let params = self.currentParams, let sourceNode = sourceNode as? ContextExtractedContentContainingNode else { + return + } + + var items: [ContextMenuItem] = [] + //TODO:localize + items.append(.action(ContextMenuActionItem(text: "Add Gifts", icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Peer Info/Gifts/AddGift"), color: theme.actionSheet.primaryTextColor) + }, action: { [weak self] _, f in + guard let self else { + return + } + f(.default) - let topInset: CGFloat = 60.0 + self.setCurrentCollection(collection: .collection(id)) + self.addGiftsToCollection(id: id) + }))) + + items.append(.action(ContextMenuActionItem(text: "Rename", icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Edit"), color: theme.actionSheet.primaryTextColor) + }, action: { [weak self] _, f in + guard let self else { + return + } + f(.default) - var validIds: [AnyHashable] = [] - var itemFrame = CGRect(origin: CGPoint(x: itemsSideInset, y: topInset), size: starsOptionSize) + self.renameCollection(id: id) + }))) + + items.append(.action(ContextMenuActionItem(text: "Share", icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Forward"), color: theme.actionSheet.primaryTextColor) + }, action: { [weak self] _, f in + guard let self else { + return + } + f(.default) - var index: Int32 = 0 - for product in starsProducts { - var isVisible = false - if visibleBounds.intersects(itemFrame) { - isVisible = true + let _ = self + }))) + + items.append(.action(ContextMenuActionItem(text: "Reorder", icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/ReorderItems"), color: theme.actionSheet.primaryTextColor) + }, action: { [weak self] c, f in + c?.dismiss(completion: { [weak self] in + guard let self else { + return + } + self.beginReordering() + }) + }))) + + items.append(.action(ContextMenuActionItem(text: "Delete Collection", textColor: .destructive, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Delete"), color: theme.contextMenu.destructiveColor) + }, action: { [weak self] _, f in + guard let self else { + return + } + f(.default) + + self.deleteCollection(id: id) + }))) + + let contextController = ContextController( + presentationData: params.presentationData, + source: .extracted(GiftsExtractedContentSource(sourceNode: sourceNode)), + items: .single(ContextController.Items(content: .list(items))), + recognizer: nil, + gesture: gesture + ) + self.parentController?.presentInGlobalOverlay(contextController) + } + + + + func updateScrolling(interactive: Bool = false, transition: ComponentTransition) { + if let params = self.currentParams { + let visibleBounds = self.scrollNode.bounds.insetBy(dx: 0.0, dy: -10.0) + + var topInset: CGFloat = 60.0 + + if let collections = self.collections, !collections.isEmpty { + var tabSelectorItems: [TabSelectorComponent.Item] = [] + tabSelectorItems.append(TabSelectorComponent.Item( + id: AnyHashable(GiftCollection.all.rawValue), + title: "All Gifts" + )) + + var effectiveCollections: [StarGiftCollection] = collections + if let reorderedCollectionIds = self.reorderedCollectionIds { + var collectionMap: [Int32: StarGiftCollection] = [:] + for collection in collections { + collectionMap[collection.id] = collection + } + var reorderedCollections: [StarGiftCollection] = [] + for id in reorderedCollectionIds { + if let collection = collectionMap[id] { + reorderedCollections.append(collection) + } + } + effectiveCollections = reorderedCollections } - if isVisible { - let info: String - switch product.gift { - case let .generic(gift): - info = "g_\(gift.id)" - case let .unique(gift): - info = "u_\(gift.id)" - } - let stableId = product.reference?.stringValue ?? "\(index)" - let id = "\(stableId)_\(info)" - let itemId = AnyHashable(id) - validIds.append(itemId) - - var itemTransition = transition - let visibleItem: ComponentView - if let (_, current) = self.starsItems[itemId] { - visibleItem = current - } else { - visibleItem = ComponentView() - self.starsItems[itemId] = (product.reference, visibleItem) - itemTransition = .immediate - } - - var ribbonText: String? - var ribbonColor: GiftItemComponent.Ribbon.Color = .blue - var ribbonFont: GiftItemComponent.Ribbon.Font = .generic - var ribbonOutline: UIColor? - - let peer: GiftItemComponent.Peer? - let subject: GiftItemComponent.Subject - var resellPrice: Int64? - - switch product.gift { - case let .generic(gift): - subject = .starGift(gift: gift, price: "# \(gift.price)") - peer = product.fromPeer.flatMap { .peer($0) } ?? .anonymous - - if let availability = gift.availability { - ribbonText = params.presentationData.strings.PeerInfo_Gifts_OneOf(compactNumericCountString(Int(availability.total), decimalSeparator: params.presentationData.dateTimeFormat.decimalSeparator)).string - } else { - ribbonText = nil - } - case let .unique(gift): - subject = .uniqueGift(gift: gift, price: nil) - peer = nil - resellPrice = gift.resellStars - - if let _ = resellPrice { - ribbonText = params.presentationData.strings.PeerInfo_Gifts_Sale - ribbonFont = .larger - ribbonColor = .green - ribbonOutline = params.presentationData.theme.list.blocksBackgroundColor - } else { - if product.pinnedToTop { - ribbonFont = .monospaced - ribbonText = "#\(gift.number)" - } else { - ribbonText = params.presentationData.strings.PeerInfo_Gifts_OneOf(compactNumericCountString(Int(gift.availability.issued), decimalSeparator: params.presentationData.dateTimeFormat.decimalSeparator)).string - } - for attribute in gift.attributes { - if case let .backdrop(_, _, innerColor, outerColor, _, _, _) = attribute { - ribbonColor = .custom(outerColor, innerColor) - break - } - } - } - } - - let _ = visibleItem.update( - transition: itemTransition, - component: AnyComponent( - GiftItemComponent( + for collection in effectiveCollections { + tabSelectorItems.append(TabSelectorComponent.Item( + id: AnyHashable(GiftCollection.collection(collection.id).rawValue), + content: .component(AnyComponent( + CollectionTabItemComponent( context: self.context, - theme: params.presentationData.theme, - strings: params.presentationData.strings, - peer: peer, - subject: subject, - ribbon: ribbonText.flatMap { GiftItemComponent.Ribbon(text: $0, font: ribbonFont, color: ribbonColor, outline: ribbonOutline) }, - resellPrice: resellPrice, - isHidden: !product.savedToProfile, - isPinned: product.pinnedToTop, - isEditing: self.isReordering, - mode: .profile, - action: { [weak self] in - guard let self, let presentationData = self.currentParams?.presentationData else { - return - } - if self.isReordering { - if case .unique = product.gift, !product.pinnedToTop, let reference = product.reference, let items = self.starsProducts { - if self.pinnedReferences.count >= self.maxPinnedCount { - self.parentController?.present(UndoOverlayController(presentationData: presentationData, content: .info(title: nil, text: presentationData.strings.PeerInfo_Gifts_ToastPinLimit_Text(Int32(self.maxPinnedCount)), timeout: nil, customUndoText: nil), elevatedLayout: true, animateInAsReplacement: false, action: { _ in return false }), in: .window(.root)) - return - } - - var reorderedPinnedReferences = Set() - if let current = self.reorderedPinnedReferences { - reorderedPinnedReferences = current - } - reorderedPinnedReferences.insert(reference) - self.reorderedPinnedReferences = reorderedPinnedReferences - - if let maxPinnedIndex = items.lastIndex(where: { $0.pinnedToTop }) { - var reorderedReferences: [StarGiftReference] - if let current = self.reorderedReferences { - reorderedReferences = current - } else { - let ids = items.compactMap { item -> StarGiftReference? in - return item.reference - } - reorderedReferences = ids - } - reorderedReferences.removeAll(where: { $0 == reference }) - reorderedReferences.insert(reference, at: maxPinnedIndex + 1) - self.reorderedReferences = reorderedReferences - } - } - } else { - let allSubjects: [GiftViewScreen.Subject] = (self.starsProducts ?? []).map { .profileGift(self.peerId, $0) } - let index = self.starsProducts?.firstIndex(where: { $0 == product }) ?? 0 - - var dismissImpl: (() -> Void)? - let controller = GiftViewScreen( - context: self.context, - subject: .profileGift(self.peerId, product), - allSubjects: allSubjects, - index: index, - updateSavedToProfile: { [weak self] reference, added in - guard let self else { - return - } - self.profileGifts.updateStarGiftAddedToProfile(reference: reference, added: added) - }, - convertToStars: { [weak self] in - guard let self, let reference = product.reference else { - return - } - self.profileGifts.convertStarGift(reference: reference) - }, - transferGift: { [weak self] prepaid, peerId in - guard let self, let reference = product.reference else { - return .complete() - } - return self.profileGifts.transferStarGift(prepaid: prepaid, reference: reference, peerId: peerId) - }, - upgradeGift: { [weak self] formId, keepOriginalInfo in - guard let self, let reference = product.reference else { - return .never() - } - return self.profileGifts.upgradeStarGift(formId: formId, reference: reference, keepOriginalInfo: keepOriginalInfo) - }, - buyGift: { [weak self] slug, peerId, price in - guard let self else { - return .never() - } - return self.profileGifts.buyStarGift(slug: slug, peerId: peerId, price: price) - }, - updateResellStars: { [weak self] price in - guard let self, let reference = product.reference else { - return .never() - } - return self.profileGifts.updateStarGiftResellPrice(reference: reference, price: price) - }, - togglePinnedToTop: { [weak self] pinnedToTop in - guard let self else { - return false - } - if let reference = product.reference { - if pinnedToTop && self.pinnedReferences.count >= self.maxPinnedCount { - self.displayUnpinScreen(gift: product, completion: { - dismissImpl?() - }) - return false - } - self.profileGifts.updateStarGiftPinnedToTop(reference: reference, pinnedToTop: pinnedToTop) - - var title = "" - if case let .unique(uniqueGift) = product.gift { - title = "\(uniqueGift.title) #\(presentationStringsFormattedNumber(uniqueGift.number, params.presentationData.dateTimeFormat.groupingSeparator))" - } - - if pinnedToTop { - let _ = self.scrollToTop() - Queue.mainQueue().after(0.35) { - let toastTitle = params.presentationData.strings.PeerInfo_Gifts_ToastPinned_TitleNew(title).string - let toastText = params.presentationData.strings.PeerInfo_Gifts_ToastPinned_Text - self.parentController?.present(UndoOverlayController(presentationData: params.presentationData, content: .universal(animation: "anim_toastpin", scale: 0.06, colors: [:], title: toastTitle, text: toastText, customUndoText: nil, timeout: 5), elevatedLayout: true, animateInAsReplacement: false, action: { _ in return false }), in: .window(.root)) - } - } - } - return true - }, - shareStory: { [weak self] uniqueGift in - guard let self, let parentController = self.parentController else { - return - } - Queue.mainQueue().after(0.15) { - let controller = self.context.sharedContext.makeStorySharingScreen(context: self.context, subject: .gift(uniqueGift), parentController: parentController) - parentController.push(controller) - } - } - ) - dismissImpl = { [weak controller] in - controller?.dismissAnimated() - } - self.parentController?.push(controller) - } - }, - contextAction: self.isReordering ? nil : { [weak self] view, gesture in - guard let self else { - return - } - self.contextAction(gift: product, view: view, gesture: gesture) - } + icon: collection.icon.flatMap { .collection($0) }, + title: collection.title, + theme: params.presentationData.theme ) + )), + isReorderable: collections.count > 1, + contextAction: { [weak self] sourceNode, gesture in + guard let self else { + return + } + self.openCollectionContextMenu(id: collection.id, sourceNode: sourceNode, gesture: gesture) + } + )) + } + + tabSelectorItems.append(TabSelectorComponent.Item( + id: AnyHashable(GiftCollection.create.rawValue), + content: .component(AnyComponent( + CollectionTabItemComponent( + context: self.context, + icon: .add, + title: "Add Collection", + theme: params.presentationData.theme + ) + )), + isReorderable: false + )) + + let tabSelectorSize = self.tabSelector.update( + transition: transition, + component: AnyComponent(TabSelectorComponent( + context: self.context, + colors: TabSelectorComponent.Colors( + foreground: params.presentationData.theme.list.itemSecondaryTextColor, + selection: params.presentationData.theme.list.itemSecondaryTextColor.withMultipliedAlpha(0.15), + simple: true ), - environment: {}, - containerSize: starsOptionSize - ) - if let itemView = visibleItem.view { - if itemView.superview == nil { - self.scrollNode.view.addSubview(itemView) + theme: params.presentationData.theme, + items: tabSelectorItems, + selectedId: AnyHashable(self.currentCollection.rawValue), + reorderItem: self.isReordering ? { [weak self] fromId, toId in + guard let self, var reorderedCollectionIds = self.reorderedCollectionIds else { + return + } + guard let sourceId = fromId.base as? Int32 else { + return + } + guard let targetId = toId.base as? Int32 else { + return + } + guard let sourceIndex = reorderedCollectionIds.firstIndex(of: sourceId), let targetIndex = reorderedCollectionIds.firstIndex(of: targetId) else { + return + } + reorderedCollectionIds[sourceIndex] = targetId + reorderedCollectionIds[targetIndex] = sourceId + self.reorderedCollectionIds = reorderedCollectionIds + + self.updateScrolling(transition: .easeInOut(duration: 0.2)) + } : nil, + setSelectedId: { [weak self] id in + guard let self, let idValue = id.base as? Int32 else { + return + } - if !transition.animation.isImmediate { - itemView.layer.animateScale(from: 0.01, to: 1.0, duration: 0.25) - itemView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25) + let giftCollection = GiftCollection(rawValue: idValue) + if case .create = giftCollection { + self.createCollection() + } else { + self.setCurrentCollection(collection: giftCollection) } } - var itemFrame = itemFrame - var isReordering = false - if let reorderingItem = self.reorderingItem, itemId == reorderingItem.id { - itemFrame = itemFrame.size.centered(around: reorderingItem.position) - isReordering = true - } - if self.isReordering, itemView.layer.animation(forKey: "position") != nil && !isReordering { - } else { - itemTransition.setFrame(view: itemView, frame: itemFrame) - } + )), + environment: {}, + containerSize: CGSize(width: params.size.width - 10.0 * 2.0, height: 50.0) + ) + if let tabSelectorView = self.tabSelector.view { + if tabSelectorView.superview == nil { + tabSelectorView.alpha = 1.0 + self.scrollNode.view.addSubview(tabSelectorView) - if self.isReordering && product.pinnedToTop { - if itemView.layer.animation(forKey: "shaking_position") == nil { - startShaking(layer: itemView.layer) - } - } else { - if itemView.layer.animation(forKey: "shaking_position") != nil { - itemView.layer.removeAnimation(forKey: "shaking_position") - itemView.layer.removeAnimation(forKey: "shaking_rotation") - } + if !transition.animation.isImmediate { + tabSelectorView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25) } } + transition.setFrame(view: tabSelectorView, frame: CGRect(origin: CGPoint(x: floor((params.size.width - tabSelectorSize.width) / 2.0), y: 60.0), size: tabSelectorSize)) + + topInset += tabSelectorSize.height + 14.0 } - itemFrame.origin.x += itemFrame.width + optionSpacing - if itemFrame.maxX > params.size.width { - itemFrame.origin.x = itemsSideInset - itemFrame.origin.y += starsOptionSize.height + optionSpacing - } - index += 1 + } else if let tabSelectorView = self.tabSelector.view { + tabSelectorView.alpha = 0.0 + tabSelectorView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, completion: { _ in + tabSelectorView.removeFromSuperview() + }) } - var removeIds: [AnyHashable] = [] - for (id, item) in self.starsItems { - if !validIds.contains(id) { - removeIds.append(id) - if let itemView = item.1.view { - if !transition.animation.isImmediate { - itemView.layer.animateScale(from: 1.0, to: 0.01, duration: 0.25, removeOnCompletion: false) - itemView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, removeOnCompletion: false, completion: { _ in - itemView.removeFromSuperview() - }) - } else { - itemView.removeFromSuperview() - } - } - } - } - for id in removeIds { - self.starsItems.removeValue(forKey: id) - } + var contentHeight = self.giftsListView.updateScrolling(topInset: topInset, visibleBounds: visibleBounds, transition: transition) var bottomScrollInset: CGFloat = 0.0 - var contentHeight = ceil(CGFloat(starsProducts.count) / CGFloat(defaultItemsInRow)) * (starsOptionSize.height + optionSpacing) - optionSpacing + topInset + 16.0 - let size = params.size let sideInset = params.sideInset let bottomInset = params.bottomInset @@ -746,13 +632,13 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr let panelBackground: NavigationBackgroundNode let panelSeparator: ASDisplayNode - let panelButton: SolidRoundedButtonNode - var panelAlpha = params.expandProgress - if !self.canGift { - panelAlpha = 0.0 + var panelVisibility = params.expandProgress < 1.0 ? 0.0 : 1.0 + if !self.canGift || self.resultsAreEmpty { + panelVisibility = 0.0 } + let panelTransition: ComponentTransition = .immediate if let current = self.panelBackground { panelBackground = current } else { @@ -765,52 +651,75 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr panelSeparator = current } else { panelSeparator = ASDisplayNode() - self.addSubnode(panelSeparator) + panelBackground.addSubnode(panelSeparator) self.panelSeparator = panelSeparator } - + + let panelButton: ComponentView if let current = self.panelButton { panelButton = current } else { - panelButton = SolidRoundedButtonNode(theme: SolidRoundedButtonTheme(theme: presentationData.theme), height: 50.0, cornerRadius: 10.0) - self.view.addSubview(panelButton.view) + panelButton = ComponentView() self.panelButton = panelButton + } - panelButton.title = self.peerId == self.context.account.peerId ? params.presentationData.strings.PeerInfo_Gifts_Send : params.presentationData.strings.PeerInfo_Gifts_SendGift - - panelButton.pressed = { [weak self] in - self?.buttonPressed() + let buttonSideInset = sideInset + 16.0 + + //TODO:localize + let buttonTitle: String + if self.peerId == self.context.account.peerId { + if case .all = self.currentCollection { + buttonTitle = params.presentationData.strings.PeerInfo_Gifts_Send + } else { + buttonTitle = "Add Gifts" } - } - - if themeUpdated { - panelBackground.updateColor(color: presentationData.theme.rootController.tabBar.backgroundColor, transition: .immediate) - panelSeparator.backgroundColor = presentationData.theme.rootController.tabBar.separatorColor - panelButton.updateTheme(SolidRoundedButtonTheme(theme: presentationData.theme)) + } else { + buttonTitle = params.presentationData.strings.PeerInfo_Gifts_SendGift } - let textFont = Font.regular(13.0) - let boldTextFont = Font.semibold(13.0) - let textColor = presentationData.theme.list.itemSecondaryTextColor - let linkColor = presentationData.theme.list.itemAccentColor - let markdownAttributes = MarkdownAttributes(body: MarkdownAttributeSet(font: textFont, textColor: textColor), bold: MarkdownAttributeSet(font: boldTextFont, textColor: textColor), link: MarkdownAttributeSet(font: boldTextFont, textColor: linkColor), linkAttribute: { _ in - return nil - }) + let buttonAttributedString = NSAttributedString(string: buttonTitle, font: Font.semibold(17.0), textColor: .white, paragraphAlignment: .center) + let panelButtonSize = panelButton.update( + transition: transition, + component: AnyComponent( + ButtonComponent( + background: ButtonComponent.Background( + color: presentationData.theme.list.itemCheckColors.fillColor, + foreground: presentationData.theme.list.itemCheckColors.foregroundColor, + pressedColor: presentationData.theme.list.itemCheckColors.fillColor.withMultipliedAlpha(0.8) + ), + content: AnyComponentWithIdentity( + id: AnyHashable(buttonAttributedString.string), + component: AnyComponent(MultilineTextComponent(text: .plain(buttonAttributedString))) + ), + isEnabled: true, + action: { [weak self] in + self?.buttonPressed() + } + ) + ), + environment: {}, + containerSize: CGSize(width: size.width - buttonSideInset * 2.0, height: 50.0) + ) var scrollOffset: CGFloat = max(0.0, size.height - params.visibleHeight) - let buttonSideInset = sideInset + 16.0 - let buttonSize = CGSize(width: size.width - buttonSideInset * 2.0, height: 50.0) let effectiveBottomInset = max(8.0, bottomInset) - var bottomPanelHeight = effectiveBottomInset + buttonSize.height + 8.0 + var bottomPanelHeight = effectiveBottomInset + panelButtonSize.height + 8.0 if params.visibleHeight < 110.0 { scrollOffset -= bottomPanelHeight } - let panelTransition = ComponentTransition.immediate - panelTransition.setFrame(view: panelButton.view, frame: CGRect(origin: CGPoint(x: buttonSideInset, y: size.height - effectiveBottomInset - buttonSize.height - scrollOffset), size: buttonSize)) - panelTransition.setAlpha(view: panelButton.view, alpha: panelAlpha) - let _ = panelButton.updateLayout(width: buttonSize.width, transition: .immediate) + if let panelButtonView = panelButton.view { + if panelButtonView.superview == nil { + panelBackground.view.addSubview(panelButtonView) + } + panelButtonView.frame = CGRect(origin: CGPoint(x: buttonSideInset, y: 8.0), size: panelButtonSize) + } + + if themeUpdated { + panelBackground.updateColor(color: presentationData.theme.rootController.tabBar.backgroundColor, transition: .immediate) + panelSeparator.backgroundColor = presentationData.theme.rootController.tabBar.separatorColor + } if self.canManage { bottomPanelHeight -= 9.0 @@ -873,172 +782,27 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr ) ), environment: {}, - containerSize: buttonSize + containerSize: panelButtonSize ) if let panelCheckView = panelCheck.view { if panelCheckView.superview == nil { - self.view.addSubview(panelCheckView) + panelBackground.view.addSubview(panelCheckView) } - panelCheckView.frame = CGRect(origin: CGPoint(x: floor((size.width - panelCheckSize.width) / 2.0), y: size.height - effectiveBottomInset - panelCheckSize.height - 11.0 - scrollOffset), size: panelCheckSize) - panelTransition.setAlpha(view: panelCheckView, alpha: panelAlpha) + panelCheckView.frame = CGRect(origin: CGPoint(x: floor((size.width - panelCheckSize.width) / 2.0), y: 16.0), size: panelCheckSize) + } + if let panelButtonView = panelButton.view { + panelButtonView.isHidden = true } - panelButton.isHidden = true } panelTransition.setFrame(view: panelBackground.view, frame: CGRect(x: 0.0, y: size.height - bottomPanelHeight - scrollOffset, width: size.width, height: bottomPanelHeight)) - panelTransition.setAlpha(view: panelBackground.view, alpha: panelAlpha) + ComponentTransition.spring(duration: 0.4).setSublayerTransform(view: panelBackground.view, transform: CATransform3DMakeTranslation(0.0, bottomPanelHeight * (1.0 - panelVisibility), 0.0)) + panelBackground.update(size: CGSize(width: size.width, height: bottomPanelHeight), transition: transition.containedViewLayoutTransition) - panelTransition.setFrame(view: panelSeparator.view, frame: CGRect(x: 0.0, y: size.height - bottomPanelHeight - scrollOffset, width: size.width, height: UIScreenPixel)) - panelTransition.setAlpha(view: panelSeparator.view, alpha: panelAlpha) + panelTransition.setFrame(view: panelSeparator.view, frame: CGRect(x: 0.0, y: 0.0, width: size.width, height: UIScreenPixel)) - let fadeTransition = ComponentTransition.easeInOut(duration: 0.25) - if self.resultsAreEmpty { - let sideInset: CGFloat = 44.0 - let emptyAnimationHeight = 148.0 - let topInset: CGFloat = 0.0 - let bottomInset: CGFloat = bottomPanelHeight - let visibleHeight = params.visibleHeight - let emptyAnimationSpacing: CGFloat = 20.0 - let emptyTextSpacing: CGFloat = 18.0 - - self.emptyResultsClippingView.isHidden = false - - panelTransition.setFrame(view: self.emptyResultsClippingView, frame: CGRect(origin: CGPoint(x: 0.0, y: 48.0), size: self.scrollNode.frame.size)) - panelTransition.setBounds(view: self.emptyResultsClippingView, bounds: CGRect(origin: CGPoint(x: 0.0, y: 48.0), size: self.scrollNode.frame.size)) - - let emptyResultsTitleSize = self.emptyResultsTitle.update( - transition: .immediate, - component: AnyComponent( - MultilineTextComponent( - text: .plain(NSAttributedString(string: presentationData.strings.PeerInfo_Gifts_NoResults, font: Font.semibold(17.0), textColor: presentationData.theme.list.itemPrimaryTextColor)), - horizontalAlignment: .center - ) - ), - environment: {}, - containerSize: params.size - ) - let emptyResultsActionSize = self.emptyResultsAction.update( - transition: .immediate, - component: AnyComponent( - PlainButtonComponent( - content: AnyComponent( - MultilineTextComponent( - text: .plain(NSAttributedString(string: presentationData.strings.PeerInfo_Gifts_NoResults_ViewAll, font: Font.regular(17.0), textColor: presentationData.theme.list.itemAccentColor)), - horizontalAlignment: .center, - maximumNumberOfLines: 0 - ) - ), - effectAlignment: .center, - action: { [weak self] in - guard let self else { - return - } - self.profileGifts.updateFilter(.All) - }, - animateScale: false - ) - ), - environment: {}, - containerSize: CGSize(width: params.size.width - sideInset * 2.0, height: visibleHeight) - ) - let emptyResultsAnimationSize = self.emptyResultsAnimation.update( - transition: .immediate, - component: AnyComponent(LottieComponent( - content: LottieComponent.AppBundleContent(name: "ChatListNoResults") - )), - environment: {}, - containerSize: CGSize(width: emptyAnimationHeight, height: emptyAnimationHeight) - ) - - let emptyTotalHeight = emptyAnimationHeight + emptyAnimationSpacing + emptyResultsTitleSize.height + emptyResultsActionSize.height + emptyTextSpacing - let emptyAnimationY = topInset + floorToScreenPixels((visibleHeight - topInset - bottomInset - emptyTotalHeight) / 2.0) - - let emptyResultsAnimationFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((params.size.width - emptyResultsAnimationSize.width) / 2.0), y: emptyAnimationY), size: emptyResultsAnimationSize) - - let emptyResultsTitleFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((params.size.width - emptyResultsTitleSize.width) / 2.0), y: emptyResultsAnimationFrame.maxY + emptyAnimationSpacing), size: emptyResultsTitleSize) - - let emptyResultsActionFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((params.size.width - emptyResultsActionSize.width) / 2.0), y: emptyResultsTitleFrame.maxY + emptyTextSpacing), size: emptyResultsActionSize) - - if let view = self.emptyResultsAnimation.view as? LottieComponent.View { - if view.superview == nil { - view.alpha = 0.0 - fadeTransition.setAlpha(view: view, alpha: 1.0) - self.emptyResultsClippingView.addSubview(view) - view.playOnce() - } - view.bounds = CGRect(origin: .zero, size: emptyResultsAnimationFrame.size) - panelTransition.setPosition(view: view, position: emptyResultsAnimationFrame.center) - } - if let view = self.emptyResultsTitle.view { - if view.superview == nil { - view.alpha = 0.0 - fadeTransition.setAlpha(view: view, alpha: 1.0) - self.emptyResultsClippingView.addSubview(view) - } - view.bounds = CGRect(origin: .zero, size: emptyResultsTitleFrame.size) - panelTransition.setPosition(view: view, position: emptyResultsTitleFrame.center) - } - if let view = self.emptyResultsAction.view { - if view.superview == nil { - view.alpha = 0.0 - fadeTransition.setAlpha(view: view, alpha: 1.0) - self.emptyResultsClippingView.addSubview(view) - } - view.bounds = CGRect(origin: .zero, size: emptyResultsActionFrame.size) - panelTransition.setPosition(view: view, position: emptyResultsActionFrame.center) - } - } else { - if let view = self.emptyResultsAnimation.view { - fadeTransition.setAlpha(view: view, alpha: 0.0, completion: { _ in - self.emptyResultsClippingView.isHidden = true - view.removeFromSuperview() - }) - } - if let view = self.emptyResultsTitle.view { - fadeTransition.setAlpha(view: view, alpha: 0.0, completion: { _ in - view.removeFromSuperview() - }) - } - if let view = self.emptyResultsAction.view { - fadeTransition.setAlpha(view: view, alpha: 0.0, completion: { _ in - view.removeFromSuperview() - }) - } - } - - if self.peerId == self.context.account.peerId, !self.resultsAreEmpty { - let footerText: ComponentView - if let current = self.footerText { - footerText = current - } else { - footerText = ComponentView() - self.footerText = footerText - } - let footerTextSize = footerText.update( - transition: .immediate, - component: AnyComponent( - BalancedTextComponent( - text: .markdown(text: presentationData.strings.PeerInfo_Gifts_Info, attributes: markdownAttributes), - horizontalAlignment: .center, - maximumNumberOfLines: 0, - lineSpacing: 0.2 - ) - ), - environment: {}, - containerSize: CGSize(width: size.width - 32.0, height: 200.0) - ) - if let view = footerText.view { - if view.superview == nil { - self.scrollNode.view.addSubview(view) - } - transition.setFrame(view: view, frame: CGRect(origin: CGPoint(x: floor((size.width - footerTextSize.width) / 2.0), y: contentHeight), size: footerTextSize)) - } - contentHeight += footerTextSize.height - } contentHeight += bottomPanelHeight - bottomScrollInset = bottomPanelHeight - 40.0 - contentHeight += params.bottomInset self.scrollNode.view.scrollIndicatorInsets = UIEdgeInsets(top: 50.0, left: 0.0, bottom: bottomScrollInset, right: 0.0) @@ -1051,29 +815,33 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr let bottomContentOffset = max(0.0, self.scrollNode.view.contentSize.height - self.scrollNode.view.contentOffset.y - self.scrollNode.view.frame.height) if interactive, bottomContentOffset < 200.0 { - self.profileGifts.loadMore() + self.giftsListView.loadMore() } } @objc private func buttonPressed() { - if self.peerId == self.context.account.peerId { - let _ = (self.context.account.stateManager.contactBirthdays - |> take(1) - |> deliverOnMainQueue).start(next: { [weak self] birthdays in - guard let self else { - return - } - let controller = self.context.sharedContext.makePremiumGiftController(context: self.context, source: .settings(birthdays), completion: nil) - controller.navigationPresentation = .modal - self.chatControllerInteraction.navigationController()?.pushViewController(controller) - }) + if self.peerId == self.context.account.peerId || self.canManage { + if case let .collection(id) = self.currentCollection { + self.addGiftsToCollection(id: id) + } else { + let _ = (self.context.account.stateManager.contactBirthdays + |> take(1) + |> deliverOnMainQueue).start(next: { [weak self] birthdays in + guard let self else { + return + } + let controller = self.context.sharedContext.makePremiumGiftController(context: self.context, source: .settings(birthdays), completion: nil) + controller.navigationPresentation = .modal + self.chatControllerInteraction.navigationController()?.pushViewController(controller) + }) + } } else { self.chatControllerInteraction.sendGift(self.peerId) } } private func contextAction(gift: ProfileGiftsContext.State.StarGift, view: UIView, gesture: ContextGesture) { - guard let currentParams = self.currentParams, let currentState = self.profileGifts.currentState else { + guard let currentParams = self.currentParams else { return } let presentationData = currentParams.presentationData @@ -1081,19 +849,86 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr let canManage = self.peerId == self.context.account.peerId || self.canManage var canReorder = false - if case .All = currentState.filter { - for gift in currentState.gifts { - if gift.pinnedToTop { - canReorder = true - break + if case .all = self.currentCollection, let currentState = self.profileGifts.currentState { + if case .All = currentState.filter { + for gift in currentState.gifts { + if gift.pinnedToTop { + canReorder = true + break + } } } + } else { + canReorder = true } var items: [ContextMenuItem] = [] if canManage { - if case .unique = gift.gift { - items.append(.action(ContextMenuActionItem(text: gift.pinnedToTop ? strings.PeerInfo_Gifts_Context_Unpin : strings.PeerInfo_Gifts_Context_Pin , icon: { theme in generateTintedImage(image: UIImage(bundleImageName: gift.pinnedToTop ? "Chat/Context Menu/Unpin" : "Chat/Context Menu/Pin"), color: theme.contextMenu.primaryColor) }, action: { [weak self] c, f in + items.append(.action(ContextMenuActionItem(text: "Add to Collection", textLayout: .twoLinesMax, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Peer Info/Gifts/AddToCollection"), color: theme.contextMenu.primaryColor) }, action: { [weak self] c, f in + var subItems: [ContextMenuItem] = [] + + subItems.append(.action(ContextMenuActionItem(text: strings.Common_Back, textColor: .primary, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Back"), color: theme.actionSheet.primaryTextColor) + }, iconSource: nil, iconPosition: .left, action: { c, _ in + c?.popItems() + }))) + + subItems.append(.separator) + + subItems.append(.action(ContextMenuActionItem(text: "New Collection", icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Peer Info/Gifts/AddCollection"), color: theme.contextMenu.primaryColor) }, iconPosition: .left, action: { [weak self] c, f in + f(.default) + + self?.createCollection(gifts: [gift]) + }))) + + var entityFiles: [Int64: TelegramMediaFile] = [:] + + if let collections = self?.collections { + for collection in collections { + if let file = collection.icon { + entityFiles[file.fileId.id] = file + } + } + + for collection in collections { + let title: String + var entities: [MessageTextEntity] = [] + if let icon = collection.icon { + title = "# \(collection.title)" + entities = [ + MessageTextEntity( + range: 0..<1, + type: .CustomEmoji(stickerPack: nil, fileId: icon.fileId.id) + ) + ] + } else { + title = collection.title + } + + let isAdded = gift.collectionIds?.contains(collection.id) ?? false + + subItems.append(.action(ContextMenuActionItem(text: title, entities: entities, entityFiles: entityFiles, enableEntityAnimations: false, icon: { theme in + return entities.isEmpty ? generateTintedImage(image: UIImage(bundleImageName: "Peer Info/Gifts/Collection"), color: theme.contextMenu.primaryColor) : (isAdded ? generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Check"), color: theme.contextMenu.primaryColor) : nil) + }, iconPosition: collection.icon == nil ? .left : .right, action: { [weak self] _, f in + f(.default) + + if isAdded, let giftReference = gift.reference { + let _ = self?.profileGiftsCollections.removeGifts(id: collection.id, gifts: [giftReference]).start() + } else { + let _ = self?.profileGiftsCollections.addGifts(id: collection.id, gifts: [gift]).start() + } + }))) + } + } + + c?.pushItems(items: .single(ContextController.Items(content: .list(subItems)))) + }))) + items.append(.separator) + } + + if canManage { + if case .unique = gift.gift, case .all = self.currentCollection { + items.append(.action(ContextMenuActionItem(text: gift.pinnedToTop ? strings.PeerInfo_Gifts_Context_Unpin : strings.PeerInfo_Gifts_Context_Pin, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: gift.pinnedToTop ? "Chat/Context Menu/Unpin" : "Chat/Context Menu/Pin"), color: theme.contextMenu.primaryColor) }, action: { [weak self] c, f in c?.dismiss(completion: { [weak self] in guard let self else { return @@ -1103,7 +938,7 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr return } - if pinnedToTop && self.pinnedReferences.count >= self.maxPinnedCount { + if pinnedToTop && self.giftsListView.pinnedReferences.count >= self.giftsListView.maxPinnedCount { self.displayUnpinScreen(gift: gift) return } @@ -1128,7 +963,14 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr }))) } - if case .unique = gift.gift, canManage && canReorder { + var isReorderableGift = false + if case .unique = gift.gift { + isReorderableGift = true + } else if case .collection = self.currentCollection { + isReorderableGift = true + } + + if isReorderableGift && canManage && canReorder { items.append(.action(ContextMenuActionItem(text: strings.PeerInfo_Gifts_Context_Reorder, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/ReorderItems"), color: theme.contextMenu.primaryColor) }, action: { [weak self] c, f in c?.dismiss(completion: { [weak self] in guard let self else { @@ -1345,12 +1187,23 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr } } + if case let .collection(id) = self.currentCollection { + items.append(.action(ContextMenuActionItem(text: "Remove From Collection", textColor: .destructive, textLayout: .twoLinesMax, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Peer Info/Gifts/RemoveFromCollection"), color: theme.contextMenu.destructiveColor) }, action: { [weak self] c, f in + f(.default) + + if let reference = gift.reference { + let _ = self?.profileGiftsCollections.removeGifts(id: id, gifts: [reference]).start() + } + }))) + } + guard !items.isEmpty else { return } let previewController = GiftContextPreviewController(context: self.context, gift: gift) let contextController = ContextController( + context: self.context, presentationData: currentParams.presentationData, source: .controller(ContextControllerContentSourceImpl(controller: previewController, sourceView: view)), items: .single(ContextController.Items(content: .list(items))), gesture: gesture @@ -1359,13 +1212,18 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr } public func update(size: CGSize, topInset: CGFloat, sideInset: CGFloat, bottomInset: CGFloat, deviceMetrics: DeviceMetrics, visibleHeight: CGFloat, isScrollingLockedAtTop: Bool, expandProgress: CGFloat, navigationHeight: CGFloat, presentationData: PresentationData, synchronous: Bool, transition: ContainedViewLayoutTransition) { - self.currentParams = (size, sideInset, bottomInset, deviceMetrics, visibleHeight, isScrollingLockedAtTop, expandProgress, presentationData) + self.currentParams = (size, topInset, sideInset, bottomInset, deviceMetrics, visibleHeight, isScrollingLockedAtTop, expandProgress, navigationHeight, presentationData) self.presentationDataPromise.set(.single(presentationData)) self.backgroundNode.backgroundColor = presentationData.theme.list.blocksBackgroundColor transition.updateFrame(node: self.backgroundNode, frame: CGRect(origin: CGPoint(x: 0.0, y: 48.0), size: size)) transition.updateFrame(node: self.scrollNode, frame: CGRect(origin: CGPoint(), size: size)) - + + let visibleBounds = self.scrollNode.bounds.insetBy(dx: 0.0, dy: -10.0) + + let contentHeight = self.giftsListView.update(size: size, sideInset: sideInset, bottomInset: bottomInset, deviceMetrics: deviceMetrics, visibleHeight: visibleHeight, isScrollingLockedAtTop: isScrollingLockedAtTop, expandProgress: expandProgress, presentationData: presentationData, synchronous: synchronous, visibleBounds: visibleBounds, transition: transition) + transition.updateFrame(view: self.giftsListView, frame: CGRect(origin: CGPoint(), size: CGSize(width: size.width, height: max(size.height, contentHeight)))) + if isScrollingLockedAtTop { self.scrollNode.view.contentOffset = .zero } @@ -1401,6 +1259,173 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr } } +private func cancelContextGestures(view: UIView) { + if let gestureRecognizers = view.gestureRecognizers { + for gesture in gestureRecognizers { + if let gesture = gesture as? ContextGesture { + gesture.cancel() + } + } + } + for subview in view.subviews { + cancelContextGestures(view: subview) + } +} + +private final class CollectionTabItemComponent: Component { + typealias EnvironmentType = TabSelectorComponent.ItemEnvironment + + enum Icon: Equatable { + case collection(TelegramMediaFile) + case add + } + + let context: AccountContext + let icon: Icon? + let title: String + let theme: PresentationTheme + + init( + context: AccountContext, + icon: Icon?, + title: String, + theme: PresentationTheme + ) { + self.context = context + self.icon = icon + self.title = title + self.theme = theme + } + + static func ==(lhs: CollectionTabItemComponent, rhs: CollectionTabItemComponent) -> Bool { + if lhs.icon != rhs.icon { + return false + } + if lhs.title != rhs.title { + return false + } + if lhs.theme !== rhs.theme { + return false + } + return true + } + + final class View: UIView { + private let title = ComponentView() + private let icon = ComponentView() + private var iconLayer: InlineStickerItemLayer? + + private var component: CollectionTabItemComponent? + + func update(component: CollectionTabItemComponent, availableSize: CGSize, state: State, environment: Environment, transition: ComponentTransition) -> CGSize { + self.component = component + + let iconSpacing: CGFloat = 3.0 + + let titleSize = self.title.update( + transition: .immediate, + component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString(string: component.title, font: Font.semibold(14.0), textColor: .white)) + )), + environment: {}, + containerSize: CGSize(width: availableSize.width, height: 100.0) + ) + + let tintColor = component.theme.list.itemSecondaryTextColor + + var iconOffset: CGFloat = 0.0 + var iconSize = CGSize() + if let icon = component.icon { + switch icon { + case let .collection(file): + iconSize = CGSize(width: 16.0, height: 16.0) + + let iconLayer: InlineStickerItemLayer + if let current = self.iconLayer { + iconLayer = current + } else { + iconLayer = InlineStickerItemLayer( + context: component.context, + userLocation: .other, + attemptSynchronousLoad: true, + emoji: ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: nil, fileId: file.fileId.id, file: file), + file: file, + cache: component.context.animationCache, + renderer: component.context.animationRenderer, + placeholderColor: component.theme.list.mediaPlaceholderColor, + pointSize: iconSize, + loopCount: 1 + ) + self.layer.addSublayer(iconLayer) + self.iconLayer = iconLayer + } + let iconFrame = CGRect(origin: CGPoint(x: iconOffset, y: floorToScreenPixels((titleSize.height - iconSize.height) * 0.5)), size: iconSize) + iconLayer.frame = iconFrame + case .add: + iconSize = self.icon.update( + transition: .immediate, + component: AnyComponent(BundleIconComponent( + name: "Chat/Input/Media/PanelBadgeAdd", + tintColor: tintColor + )), + environment: {}, + containerSize: CGSize(width: 100.0, height: 100.0) + ) + let iconFrame = CGRect(origin: CGPoint(x: iconOffset, y: floorToScreenPixels((titleSize.height - iconSize.height) * 0.5)), size: iconSize) + if let iconView = self.icon.view { + if iconView.superview == nil { + iconView.isUserInteractionEnabled = false + self.addSubview(iconView) + } + iconView.frame = iconFrame + } + } + + iconOffset += iconSize.width + iconSpacing + } else { + if let iconLayer = self.iconLayer { + self.iconLayer = nil + iconLayer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { _ in + iconLayer.removeFromSuperlayer() + }) + iconLayer.animateScale(from: 1.0, to: 0.01, duration: 0.2, removeOnCompletion: false) + } + if let iconView = self.icon.view { + iconView.removeFromSuperview() + } + } + + let titleFrame = CGRect(origin: CGPoint(x: iconOffset, y: 0.0), size: titleSize) + if let titleView = self.title.view { + if titleView.superview == nil { + titleView.isUserInteractionEnabled = false + self.addSubview(titleView) + } + titleView.frame = titleFrame + + transition.setTintColor(layer: titleView.layer, color: tintColor) + } + + let size: CGSize + if let _ = component.icon { + size = CGSize(width: iconSize.width + iconSpacing + titleSize.width, height: titleSize.height) + } else { + size = titleSize + } + + return size + } + } + + func makeView() -> View { + return View(frame: CGRect()) + } + + func update(view: View, availableSize: CGSize, state: State, environment: Environment, transition: ComponentTransition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } +} + private final class ContextControllerContentSourceImpl: ContextControllerContentSource { let controller: ViewController weak var sourceView: UIView? @@ -1431,273 +1456,22 @@ private final class ContextControllerContentSourceImpl: ContextControllerContent } } -private func startShaking(layer: CALayer) { - func degreesToRadians(_ x: CGFloat) -> CGFloat { - return .pi * x / 180.0 - } - - let duration: Double = 0.4 - let displacement: CGFloat = 1.0 - let degreesRotation: CGFloat = 2.0 +private final class GiftsExtractedContentSource: ContextExtractedContentSource { + let keepInPlace: Bool = false + let ignoreContentTouches: Bool = false + let blurBackground: Bool = true - let negativeDisplacement = -1.0 * displacement - let position = CAKeyframeAnimation.init(keyPath: "position") - position.beginTime = 0.8 - position.duration = duration - position.values = [ - NSValue(cgPoint: CGPoint(x: negativeDisplacement, y: negativeDisplacement)), - NSValue(cgPoint: CGPoint(x: 0, y: 0)), - NSValue(cgPoint: CGPoint(x: negativeDisplacement, y: 0)), - NSValue(cgPoint: CGPoint(x: 0, y: negativeDisplacement)), - NSValue(cgPoint: CGPoint(x: negativeDisplacement, y: negativeDisplacement)) - ] - position.calculationMode = .linear - position.isRemovedOnCompletion = false - position.repeatCount = Float.greatestFiniteMagnitude - position.beginTime = CFTimeInterval(Float(arc4random()).truncatingRemainder(dividingBy: Float(25)) / Float(100)) - position.isAdditive = true - - let transform = CAKeyframeAnimation.init(keyPath: "transform") - transform.beginTime = 2.6 - transform.duration = 0.3 - transform.valueFunction = CAValueFunction(name: CAValueFunctionName.rotateZ) - transform.values = [ - degreesToRadians(-1.0 * degreesRotation), - degreesToRadians(degreesRotation), - degreesToRadians(-1.0 * degreesRotation) - ] - transform.calculationMode = .linear - transform.isRemovedOnCompletion = false - transform.repeatCount = Float.greatestFiniteMagnitude - transform.isAdditive = true - transform.beginTime = CFTimeInterval(Float(arc4random()).truncatingRemainder(dividingBy: Float(25)) / Float(100)) - - layer.add(position, forKey: "shaking_position") - layer.add(transform, forKey: "shaking_rotation") -} - - -private extension StarGiftReference { - var stringValue: String { - switch self { - case let .message(messageId): - return "m_\(messageId.id)" - case let .peer(peerId, id): - return "p_\(peerId.toInt64())_\(id)" - case let .slug(slug): - return "s_\(slug)" - } - } -} - - -private final class ReorderGestureRecognizer: UIGestureRecognizer { - private let shouldBegin: (CGPoint) -> (allowed: Bool, requiresLongPress: Bool, id: AnyHashable?, item: ComponentView?) - private let willBegin: (CGPoint) -> Void - private let began: (AnyHashable) -> Void - private let ended: () -> Void - private let moved: (CGPoint) -> Void - private let isActiveUpdated: (Bool) -> Void - - private var initialLocation: CGPoint? - private var longTapTimer: SwiftSignalKit.Timer? - private var longPressTimer: SwiftSignalKit.Timer? - - private var id: AnyHashable? - private var itemView: ComponentView? - - public init(shouldBegin: @escaping (CGPoint) -> (allowed: Bool, requiresLongPress: Bool, id: AnyHashable?, item: ComponentView?), willBegin: @escaping (CGPoint) -> Void, began: @escaping (AnyHashable) -> Void, ended: @escaping () -> Void, moved: @escaping (CGPoint) -> Void, isActiveUpdated: @escaping (Bool) -> Void) { - self.shouldBegin = shouldBegin - self.willBegin = willBegin - self.began = began - self.ended = ended - self.moved = moved - self.isActiveUpdated = isActiveUpdated - - super.init(target: nil, action: nil) - } - - deinit { - self.longTapTimer?.invalidate() - self.longPressTimer?.invalidate() - } - - private func startLongTapTimer() { - self.longTapTimer?.invalidate() - let longTapTimer = SwiftSignalKit.Timer(timeout: 0.25, repeat: false, completion: { [weak self] in - self?.longTapTimerFired() - }, queue: Queue.mainQueue()) - self.longTapTimer = longTapTimer - longTapTimer.start() - } - - private func stopLongTapTimer() { - self.itemView = nil - self.longTapTimer?.invalidate() - self.longTapTimer = nil - } - - private func startLongPressTimer() { - self.longPressTimer?.invalidate() - let longPressTimer = SwiftSignalKit.Timer(timeout: 0.6, repeat: false, completion: { [weak self] in - self?.longPressTimerFired() - }, queue: Queue.mainQueue()) - self.longPressTimer = longPressTimer - longPressTimer.start() - } - - private func stopLongPressTimer() { - self.itemView = nil - self.longPressTimer?.invalidate() - self.longPressTimer = nil - } - - override public func reset() { - super.reset() - - self.itemView = nil - self.stopLongTapTimer() - self.stopLongPressTimer() - self.initialLocation = nil - - self.isActiveUpdated(false) - } - - private func longTapTimerFired() { - guard let location = self.initialLocation else { - return - } - - self.longTapTimer?.invalidate() - self.longTapTimer = nil - - self.willBegin(location) - } - - private func longPressTimerFired() { - guard let _ = self.initialLocation else { - return - } - - self.isActiveUpdated(true) - self.state = .began - self.longPressTimer?.invalidate() - self.longPressTimer = nil - self.longTapTimer?.invalidate() - self.longTapTimer = nil - if let id = self.id { - self.began(id) - } - self.isActiveUpdated(true) - } - - override public func touchesBegan(_ touches: Set, with event: UIEvent) { - super.touchesBegan(touches, with: event) - - if self.numberOfTouches > 1 { - self.isActiveUpdated(false) - self.state = .failed - self.ended() - return - } - - if self.state == .possible { - if let location = touches.first?.location(in: self.view) { - let (allowed, requiresLongPress, id, itemView) = self.shouldBegin(location) - if allowed { - self.isActiveUpdated(true) - - self.id = id - self.itemView = itemView - self.initialLocation = location - if requiresLongPress { - self.startLongTapTimer() - self.startLongPressTimer() - } else { - self.state = .began - if let id = self.id { - self.began(id) - } - } - } else { - self.isActiveUpdated(false) - self.state = .failed - } - } else { - self.isActiveUpdated(false) - self.state = .failed - } - } - } - - override public func touchesEnded(_ touches: Set, with event: UIEvent) { - super.touchesEnded(touches, with: event) - - self.initialLocation = nil - - self.stopLongTapTimer() - if self.longPressTimer != nil { - self.stopLongPressTimer() - self.isActiveUpdated(false) - self.state = .failed - } - if self.state == .began || self.state == .changed { - self.isActiveUpdated(false) - self.ended() - self.state = .failed - } - } - - override public func touchesCancelled(_ touches: Set, with event: UIEvent) { - super.touchesCancelled(touches, with: event) - - self.initialLocation = nil - - self.stopLongTapTimer() - if self.longPressTimer != nil { - self.isActiveUpdated(false) - self.stopLongPressTimer() - self.state = .failed - } - if self.state == .began || self.state == .changed { - self.isActiveUpdated(false) - self.ended() - self.state = .failed - } - } - - override public func touchesMoved(_ touches: Set, with event: UIEvent) { - super.touchesMoved(touches, with: event) - - if (self.state == .began || self.state == .changed), let initialLocation = self.initialLocation, let location = touches.first?.location(in: self.view) { - self.state = .changed - let offset = CGPoint(x: location.x - initialLocation.x, y: location.y - initialLocation.y) - self.moved(offset) - } else if let touch = touches.first, let initialTapLocation = self.initialLocation, self.longPressTimer != nil { - let touchLocation = touch.location(in: self.view) - let dX = touchLocation.x - initialTapLocation.x - let dY = touchLocation.y - initialTapLocation.y - - if dX * dX + dY * dY > 3.0 * 3.0 { - self.stopLongTapTimer() - self.stopLongPressTimer() - self.initialLocation = nil - self.isActiveUpdated(false) - self.state = .failed - } - } - } -} - -private func cancelContextGestures(view: UIView) { - if let gestureRecognizers = view.gestureRecognizers { - for gesture in gestureRecognizers { - if let gesture = gesture as? ContextGesture { - gesture.cancel() - } - } - } - for subview in view.subviews { - cancelContextGestures(view: subview) + private let sourceNode: ContextExtractedContentContainingNode + + init(sourceNode: ContextExtractedContentContainingNode) { + self.sourceNode = sourceNode + } + + func takeView() -> ContextControllerTakeViewInfo? { + return ContextControllerTakeViewInfo(containingItem: .node(self.sourceNode), contentAreaInScreenSpace: UIScreen.main.bounds) + } + + func putBack() -> ContextControllerPutBackViewInfo? { + return ContextControllerPutBackViewInfo(contentAreaInScreenSpace: UIScreen.main.bounds) } } diff --git a/submodules/TelegramUI/Components/ShareExtensionContext/Sources/ShareExtensionContext.swift b/submodules/TelegramUI/Components/ShareExtensionContext/Sources/ShareExtensionContext.swift index 6af3a29ac9..d491abba5a 100644 --- a/submodules/TelegramUI/Components/ShareExtensionContext/Sources/ShareExtensionContext.swift +++ b/submodules/TelegramUI/Components/ShareExtensionContext/Sources/ShareExtensionContext.swift @@ -598,9 +598,11 @@ public class ShareRootControllerImpl { } var canShareToStory = true + var canSendInHighQuality = false if let inputItems = self?.getExtensionContext()?.inputItems, inputItems.count == 1, let item = inputItems[0] as? NSExtensionItem, let attachments = item.attachments { for attachment in attachments { if attachment.hasItemConformingToTypeIdentifier(kUTTypeImage as String) { + canSendInHighQuality = true } else if attachment.hasItemConformingToTypeIdentifier(kUTTypeMovie as String) { } else { canShareToStory = false @@ -609,6 +611,7 @@ public class ShareRootControllerImpl { } if canShareToStory { + shareController.canSendInHighQuality = canSendInHighQuality shareController.shareStory = { [weak self] in guard let self else { return diff --git a/submodules/TelegramUI/Components/Stars/StarsTransactionsScreen/Sources/StarsOverviewItemComponent.swift b/submodules/TelegramUI/Components/Stars/StarsTransactionsScreen/Sources/StarsOverviewItemComponent.swift index 34fea1b120..bf7297a4d0 100644 --- a/submodules/TelegramUI/Components/Stars/StarsTransactionsScreen/Sources/StarsOverviewItemComponent.swift +++ b/submodules/TelegramUI/Components/Stars/StarsTransactionsScreen/Sources/StarsOverviewItemComponent.swift @@ -14,14 +14,14 @@ final class StarsOverviewItemComponent: Component { let theme: PresentationTheme let dateTimeFormat: PresentationDateTimeFormat let title: String - let value: StarsAmount + let value: CurrencyAmount let rate: Double init( theme: PresentationTheme, dateTimeFormat: PresentationDateTimeFormat, title: String, - value: StarsAmount, + value: CurrencyAmount, rate: Double ) { self.theme = theme @@ -60,9 +60,7 @@ final class StarsOverviewItemComponent: Component { override init(frame: CGRect) { super.init(frame: frame) - - self.icon.image = UIImage(bundleImageName: "Premium/Stars/StarMedium") - + self.addSubview(self.icon) } @@ -75,14 +73,24 @@ final class StarsOverviewItemComponent: Component { let sideInset: CGFloat = 16.0 + let iconY: CGFloat = component.value.currency == .ton ? 13.0 + UIScreenPixel : 10.0 + if self.icon.image == nil { + switch component.value.currency { + case .ton: + self.icon.image = generateTintedImage(image: UIImage(bundleImageName: "Ads/TonMedium"), color: component.theme.list.itemAccentColor) + case .stars: + self.icon.image = UIImage(bundleImageName: "Premium/Stars/StarMedium") + } + } + var valueOffset: CGFloat = 0.0 if let icon = self.icon.image { - self.icon.frame = CGRect(origin: CGPoint(x: sideInset - 1.0, y: 10.0), size: icon.size) + self.icon.frame = CGRect(origin: CGPoint(x: sideInset - 1.0, y: iconY), size: icon.size) valueOffset += icon.size.width } - let valueString = formatStarsAmountText(component.value, dateTimeFormat: component.dateTimeFormat) - let usdValueString = formatTonUsdValue(component.value.value, divide: false, rate: component.rate, dateTimeFormat: component.dateTimeFormat) + let valueString = formatStarsAmountText(component.value.amount, dateTimeFormat: component.dateTimeFormat) + let usdValueString = formatTonUsdValue(component.value.amount.value, divide: false, rate: component.rate, dateTimeFormat: component.dateTimeFormat) let valueSize = self.value.update( transition: .immediate, diff --git a/submodules/TelegramUI/Components/Stars/StarsTransactionsScreen/Sources/StarsStatisticsScreen.swift b/submodules/TelegramUI/Components/Stars/StarsTransactionsScreen/Sources/StarsStatisticsScreen.swift index 49ef7dc8c0..6eecb0ed26 100644 --- a/submodules/TelegramUI/Components/Stars/StarsTransactionsScreen/Sources/StarsStatisticsScreen.swift +++ b/submodules/TelegramUI/Components/Stars/StarsTransactionsScreen/Sources/StarsStatisticsScreen.swift @@ -522,21 +522,21 @@ final class StarsStatisticsScreenComponent: Component { theme: environment.theme, dateTimeFormat: environment.dateTimeFormat, title: strings.Stars_BotRevenue_Proceeds_Available, - value: starsState?.balances.availableBalance.amount ?? StarsAmount.zero, + value: starsState?.balances.availableBalance ?? CurrencyAmount(amount: .zero, currency: .stars), rate: starsState?.usdRate ?? 0.0 ))), AnyComponentWithIdentity(id: 1, component: AnyComponent(StarsOverviewItemComponent( theme: environment.theme, dateTimeFormat: environment.dateTimeFormat, title: strings.Stars_BotRevenue_Proceeds_Current, - value: starsState?.balances.currentBalance.amount ?? StarsAmount.zero, + value: starsState?.balances.currentBalance ?? CurrencyAmount(amount: .zero, currency: .stars), rate: starsState?.usdRate ?? 0.0 ))), AnyComponentWithIdentity(id: 2, component: AnyComponent(StarsOverviewItemComponent( theme: environment.theme, dateTimeFormat: environment.dateTimeFormat, title: strings.Stars_BotRevenue_Proceeds_Total, - value: starsState?.balances.overallRevenue.amount ?? StarsAmount.zero, + value: starsState?.balances.overallRevenue ?? CurrencyAmount(amount: .zero, currency: .stars), rate: starsState?.usdRate ?? 0.0 ))) ], diff --git a/submodules/TelegramUI/Components/Stars/StarsTransactionsScreen/Sources/StarsTransactionsScreen.swift b/submodules/TelegramUI/Components/Stars/StarsTransactionsScreen/Sources/StarsTransactionsScreen.swift index ff8a3c7a0f..3b587d8402 100644 --- a/submodules/TelegramUI/Components/Stars/StarsTransactionsScreen/Sources/StarsTransactionsScreen.swift +++ b/submodules/TelegramUI/Components/Stars/StarsTransactionsScreen/Sources/StarsTransactionsScreen.swift @@ -114,6 +114,7 @@ final class StarsTransactionsScreenComponent: Component { private let titleView = ComponentView() private let descriptionView = ComponentView() + private let proceedsView = ComponentView() private let balanceView = ComponentView() private let earnStarsSection = ComponentView() @@ -154,6 +155,8 @@ final class StarsTransactionsScreenComponent: Component { private var allTransactionsContext: StarsTransactionsContext? private var incomingTransactionsContext: StarsTransactionsContext? private var outgoingTransactionsContext: StarsTransactionsContext? + + private var cachedChevronImage: (UIImage, PresentationTheme)? override init(frame: CGRect) { self.navigationBackgroundView = BlurredBackgroundView(color: nil, enableBlur: true) @@ -537,7 +540,8 @@ final class StarsTransactionsScreenComponent: Component { let descriptionString: String if component.starsContext.ton { titleString = environment.strings.Stars_Ton_Title - descriptionString = environment.strings.Stars_Ton_Description + descriptionString = "Use TON to submit post suggestions in channels or buy gifts." + //descriptionString = environment.strings.Stars_Ton_Description } else { titleString = environment.strings.Stars_Intro_Title descriptionString = environment.strings.Stars_Intro_Description @@ -667,14 +671,93 @@ final class StarsTransactionsScreenComponent: Component { contentHeight += 29.0 let withdrawAvailable = (self.revenueState?.balances.overallRevenue.amount.value ?? 0) > 0 - + + if component.starsContext.ton { + //TODO:localize + let proceedsSize = self.proceedsView.update( + transition: .immediate, + component: AnyComponent(ListSectionComponent( + theme: environment.theme, + header: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString( + string: "Proceeds Overview".uppercased(), + font: Font.regular(presentationData.listsFontSize.itemListBaseHeaderFontSize), + textColor: environment.theme.list.freeTextColor + )), + maximumNumberOfLines: 0 + )), + footer: nil, + items: [ + AnyComponentWithIdentity(id: 0, component: AnyComponent(StarsOverviewItemComponent( + theme: environment.theme, + dateTimeFormat: environment.dateTimeFormat, + title: "Balance Available to Withdraw", + value: self.revenueState?.balances.availableBalance ?? CurrencyAmount(amount: .zero, currency: .stars), + rate: self.revenueState?.usdRate ?? 0.0 + ))), + AnyComponentWithIdentity(id: 1, component: AnyComponent(StarsOverviewItemComponent( + theme: environment.theme, + dateTimeFormat: environment.dateTimeFormat, + title: "Total Lifetime Proceeds", + value: self.revenueState?.balances.overallRevenue ?? CurrencyAmount(amount: .zero, currency: .stars), + rate: self.revenueState?.usdRate ?? 0.0 + ))) + ], + displaySeparators: false + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - sideInsets, height: availableSize.height) + ) + let proceedsFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((availableSize.width - proceedsSize.width) / 2.0), y: contentHeight), size: proceedsSize) + if let proceedsView = self.proceedsView.view { + if proceedsView.superview == nil { + self.scrollView.addSubview(proceedsView) + } + transition.setFrame(view: proceedsView, frame: proceedsFrame) + } + contentHeight += proceedsSize.height + contentHeight += 31.0 + } + + let termsFont = Font.regular(13.0) + let termsTextColor = environment.theme.list.freeTextColor + let termsMarkdownAttributes = MarkdownAttributes(body: MarkdownAttributeSet(font: termsFont, textColor: termsTextColor), bold: MarkdownAttributeSet(font: termsFont, textColor: termsTextColor), link: MarkdownAttributeSet(font: termsFont, textColor: environment.theme.list.itemAccentColor), linkAttribute: { contents in + return (TelegramTextAttributes.URL, contents) + }) + + let balanceInfoRawString = "Collect your TON using Fragment. [Learn More >]()" + let balanceInfoString = NSMutableAttributedString(attributedString: parseMarkdownIntoAttributedString(balanceInfoRawString, attributes: termsMarkdownAttributes, textAlignment: .natural)) + if self.cachedChevronImage == nil || self.cachedChevronImage?.1 !== environment.theme { + self.cachedChevronImage = (generateTintedImage(image: UIImage(bundleImageName: "Contact List/SubtitleArrow"), color: environment.theme.list.itemAccentColor)!, environment.theme) + } + if let range = balanceInfoString.string.range(of: ">"), let chevronImage = self.cachedChevronImage?.0 { + balanceInfoString.addAttribute(.attachment, value: chevronImage, range: NSRange(range, in: balanceInfoString.string)) + } + let premiumConfiguration = PremiumConfiguration.with(appConfiguration: component.context.currentAppConfiguration.with { $0 }) let balanceSize = self.balanceView.update( transition: .immediate, component: AnyComponent(ListSectionComponent( theme: environment.theme, header: nil, - footer: nil, + footer: component.starsContext.ton ? AnyComponent(MultilineTextComponent( + text: .plain(balanceInfoString), + maximumNumberOfLines: 0, + highlightColor: environment.theme.list.itemAccentColor.withAlphaComponent(0.1), + highlightInset: UIEdgeInsets(top: 0.0, left: 0.0, bottom: 0.0, right: -8.0), + highlightAction: { attributes in + if let _ = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.URL)] { + return NSAttributedString.Key(rawValue: TelegramTextAttributes.URL) + } else { + return nil + } + }, + tapAction: { [weak self] attributes, _ in + if let controller = self?.controller?() as? StarsTransactionsScreen, let navigationController = controller.navigationController as? NavigationController { + component.context.sharedContext.openExternalUrl(context: component.context, urlContext: .generic, url: environment.strings.Stars_BotRevenue_Withdraw_Info_URL, forceExternal: false, presentationData: presentationData, navigationController: navigationController, dismissInput: {}) + } + } + )) : nil, items: [AnyComponentWithIdentity(id: 0, component: AnyComponent( StarsBalanceComponent( theme: environment.theme, @@ -683,8 +766,8 @@ final class StarsTransactionsScreenComponent: Component { count: self.starsState?.balance ?? StarsAmount.zero, currency: component.starsContext.ton ? .ton : .stars, rate: nil, - actionTitle: (withdrawAvailable ? environment.strings.Stars_Intro_BuyShort : environment.strings.Stars_Intro_Buy), - actionAvailable: (!component.starsContext.ton && !premiumConfiguration.areStarsDisabled && !premiumConfiguration.isPremiumDisabled), + actionTitle: component.starsContext.ton ? "Withdraw via Fragment" : (withdrawAvailable ? environment.strings.Stars_Intro_BuyShort : environment.strings.Stars_Intro_Buy), + actionAvailable: (!premiumConfiguration.areStarsDisabled && !premiumConfiguration.isPremiumDisabled), actionIsEnabled: true, actionIcon: component.starsContext.ton ? nil : PresentationResourcesItemList.itemListRoundTopupIcon(environment.theme), action: { [weak self] in @@ -1270,51 +1353,53 @@ public final class StarsTransactionsScreen: ViewControllerComponentContainer { return } - let controller = self.context.sharedContext.makeStarsStatisticsScreen(context: context, peerId: context.account.peerId, revenueContext: self.starsRevenueStatsContext) - self.push(controller) - -// let _ = (context.engine.peers.checkStarsRevenueWithdrawalAvailability() -// |> deliverOnMainQueue).start(error: { [weak self] error in -// guard let self else { -// return -// } -// switch error { -// case .serverProvided: -// return -// case .requestPassword: -// let _ = (self.starsRevenueStatsContext.state -// |> take(1) -// |> deliverOnMainQueue).start(next: { [weak self] state in -// guard let self else { -// return -// } -// let controller = self.context.sharedContext.makeStarsWithdrawalScreen(context: context, completion: { [weak self] amount in -// guard let self else { -// return -// } -// let controller = confirmStarsRevenueWithdrawalController(context: context, peerId: context.account.peerId, amount: amount, present: { [weak self] c, a in -// self?.present(c, in: .window(.root)) -// }, completion: { [weak self] 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) { -// self?.starsRevenueStatsContext.reload() -// } -// }) -// self.present(controller, in: .window(.root)) -// }) -// self.push(controller) -// }) -// default: -// let controller = starsRevenueWithdrawalController(context: context, peerId: context.account.peerId, amount: 0, initialError: error, present: { [weak self] c, a in -// self?.present(c, in: .window(.root)) -// }, completion: { _ in -// -// }) -// self.present(controller, in: .window(.root)) -// } -// }) + if self.starsContext.ton { + let _ = (context.engine.peers.checkStarsRevenueWithdrawalAvailability() + |> deliverOnMainQueue).start(error: { [weak self] error in + guard let self else { + return + } + switch error { + case .serverProvided: + return + case .requestPassword: + let _ = (self.starsRevenueStatsContext.state + |> take(1) + |> deliverOnMainQueue).start(next: { [weak self] state in + guard let self, let stats = state.stats else { + return + } + let controller = self.context.sharedContext.makeStarsWithdrawalScreen(context: context, stats: stats, completion: { [weak self] amount in + guard let self else { + return + } + let controller = confirmStarsRevenueWithdrawalController(context: context, peerId: context.account.peerId, amount: amount, present: { [weak self] c, a in + self?.present(c, in: .window(.root)) + }, completion: { [weak self] 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) { + self?.starsRevenueStatsContext.reload() + } + }) + self.present(controller, in: .window(.root)) + }) + self.push(controller) + }) + default: + let controller = starsRevenueWithdrawalController(context: context, peerId: context.account.peerId, amount: 0, initialError: error, present: { [weak self] c, a in + self?.present(c, in: .window(.root)) + }, completion: { _ in + + }) + self.present(controller, in: .window(.root)) + } + }) + } else { + let controller = self.context.sharedContext.makeStarsStatisticsScreen(context: context, peerId: context.account.peerId, revenueContext: self.starsRevenueStatsContext) + self.push(controller) + } } showTimeoutTooltipImpl = { [weak self] cooldownUntilTimestamp in diff --git a/submodules/TelegramUI/Images.xcassets/Peer Info/Gifts/AddCollection.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Peer Info/Gifts/AddCollection.imageset/Contents.json new file mode 100644 index 0000000000..059c2a6f9a --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Peer Info/Gifts/AddCollection.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "addfolder_24.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/submodules/TelegramUI/Images.xcassets/Peer Info/Gifts/AddCollection.imageset/addfolder_24.svg b/submodules/TelegramUI/Images.xcassets/Peer Info/Gifts/AddCollection.imageset/addfolder_24.svg new file mode 100644 index 0000000000..a7b1ee21b9 --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Peer Info/Gifts/AddCollection.imageset/addfolder_24.svg @@ -0,0 +1,4 @@ + + + + diff --git a/submodules/TelegramUI/Images.xcassets/Peer Info/Gifts/AddGift.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Peer Info/Gifts/AddGift.imageset/Contents.json new file mode 100644 index 0000000000..2023227eaf --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Peer Info/Gifts/AddGift.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "addgift_24.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/submodules/TelegramUI/Images.xcassets/Peer Info/Gifts/AddGift.imageset/addgift_24.svg b/submodules/TelegramUI/Images.xcassets/Peer Info/Gifts/AddGift.imageset/addgift_24.svg new file mode 100644 index 0000000000..b363952f49 --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Peer Info/Gifts/AddGift.imageset/addgift_24.svg @@ -0,0 +1,4 @@ + + + + diff --git a/submodules/TelegramUI/Images.xcassets/Peer Info/Gifts/AddToCollection.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Peer Info/Gifts/AddToCollection.imageset/Contents.json new file mode 100644 index 0000000000..8e40eb345e --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Peer Info/Gifts/AddToCollection.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "fromfolder_24.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/submodules/TelegramUI/Images.xcassets/Peer Info/Gifts/AddToCollection.imageset/fromfolder_24.svg b/submodules/TelegramUI/Images.xcassets/Peer Info/Gifts/AddToCollection.imageset/fromfolder_24.svg new file mode 100644 index 0000000000..3c692a54d9 --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Peer Info/Gifts/AddToCollection.imageset/fromfolder_24.svg @@ -0,0 +1,3 @@ + + + diff --git a/submodules/TelegramUI/Images.xcassets/Peer Info/Gifts/Collection.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Peer Info/Gifts/Collection.imageset/Contents.json new file mode 100644 index 0000000000..4d4631d0cf --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Peer Info/Gifts/Collection.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "folder_24.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/submodules/TelegramUI/Images.xcassets/Peer Info/Gifts/Collection.imageset/folder_24.pdf b/submodules/TelegramUI/Images.xcassets/Peer Info/Gifts/Collection.imageset/folder_24.pdf new file mode 100644 index 0000000000..3be76846f5 Binary files /dev/null and b/submodules/TelegramUI/Images.xcassets/Peer Info/Gifts/Collection.imageset/folder_24.pdf differ diff --git a/submodules/TelegramUI/Images.xcassets/Peer Info/Gifts/Contents.json b/submodules/TelegramUI/Images.xcassets/Peer Info/Gifts/Contents.json new file mode 100644 index 0000000000..6e965652df --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Peer Info/Gifts/Contents.json @@ -0,0 +1,9 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "provides-namespace" : true + } +} diff --git a/submodules/TelegramUI/Images.xcassets/Peer Info/Gifts/RemoveFromCollection.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Peer Info/Gifts/RemoveFromCollection.imageset/Contents.json new file mode 100644 index 0000000000..5babd3f81b --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Peer Info/Gifts/RemoveFromCollection.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "tofolder_24.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/submodules/TelegramUI/Images.xcassets/Peer Info/Gifts/RemoveFromCollection.imageset/tofolder_24.svg b/submodules/TelegramUI/Images.xcassets/Peer Info/Gifts/RemoveFromCollection.imageset/tofolder_24.svg new file mode 100644 index 0000000000..c84ddc0d14 --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Peer Info/Gifts/RemoveFromCollection.imageset/tofolder_24.svg @@ -0,0 +1,3 @@ + + + diff --git a/submodules/TelegramUI/Images.xcassets/Settings/FaceVerification.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Settings/FaceVerification.imageset/Contents.json new file mode 100644 index 0000000000..75ffa7e156 --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Settings/FaceVerification.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "faceid.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/submodules/TelegramUI/Images.xcassets/Settings/FaceVerification.imageset/faceid.pdf b/submodules/TelegramUI/Images.xcassets/Settings/FaceVerification.imageset/faceid.pdf new file mode 100644 index 0000000000..5c2ac27ab8 Binary files /dev/null and b/submodules/TelegramUI/Images.xcassets/Settings/FaceVerification.imageset/faceid.pdf differ diff --git a/submodules/TelegramUI/Sources/TelegramRootController.swift b/submodules/TelegramUI/Sources/TelegramRootController.swift index c6f68a0004..1008217279 100644 --- a/submodules/TelegramUI/Sources/TelegramRootController.swift +++ b/submodules/TelegramUI/Sources/TelegramRootController.swift @@ -31,6 +31,8 @@ import PeerInfoScreen import PeerInfoStoryGridScreen import ShareWithPeersScreen import ChatEmptyNode +import FaceScanScreen +import UndoUI private class DetailsChatPlaceholderNode: ASDisplayNode, NavigationDetailsPlaceholderNode { private var presentationData: PresentationData @@ -235,6 +237,22 @@ public final class TelegramRootController: NavigationController, TelegramRootCon self.accountSettingsController = accountSettingsController self.rootTabController = tabBarController self.pushViewController(tabBarController, animated: false) + +// Queue.mainQueue().after(1.0) { +// let context = self.context +// let infoScreen = AgeVerificationScreen(context: context, completion: { [weak chatListController] proceed in +// if proceed { +// let scanScreen = FaceScanScreen(context: context, completion: { success in +// let controller = UndoOverlayController(presentationData: self.presentationData, content: .actionSucceeded(title: "Age check passed!", text: "You can now view this content.", cancel: nil, destructive: false), elevatedLayout: true, action: { _ in return true }) +// Queue.mainQueue().after(0.1) { +// chatListController?.present(controller, in: .window(.root)) +// } +// }) +// chatListController?.push(scanScreen) +// } +// }) +// chatListController.push(infoScreen) +// } } public func updateRootControllers(showCallsTab: Bool) { diff --git a/submodules/WebUI/Sources/WebAppWebView.swift b/submodules/WebUI/Sources/WebAppWebView.swift index e161952561..3ad33bca74 100644 --- a/submodules/WebUI/Sources/WebAppWebView.swift +++ b/submodules/WebUI/Sources/WebAppWebView.swift @@ -48,11 +48,10 @@ private let selectionSource = "var css = '*{-webkit-touch-callout:none;} :not(in " style.appendChild(document.createTextNode(css)); head.appendChild(style);" private let videoSource = """ +document.addEventListener('DOMContentLoaded', () => { function tgBrowserDisableWebkitEnterFullscreen(videoElement) { if (videoElement && videoElement.webkitEnterFullscreen) { - Object.defineProperty(videoElement, 'webkitEnterFullscreen', { - value: undefined - }); + videoElement.setAttribute('playsinline', ''); } } @@ -87,6 +86,7 @@ _tgbrowser_observer.observe(document.body, { function tgBrowserDisconnectObserver() { _tgbrowser_observer.disconnect(); } +}); """ final class WebAppWebView: WKWebView { @@ -151,9 +151,9 @@ final class WebAppWebView: WKWebView { configuration.allowsInlineMediaPlayback = true configuration.allowsPictureInPictureMediaPlayback = false if #available(iOS 10.0, *) { - configuration.mediaTypesRequiringUserActionForPlayback = .audio + configuration.mediaTypesRequiringUserActionForPlayback = [] } else { - configuration.mediaPlaybackRequiresUserAction = true + configuration.mediaPlaybackRequiresUserAction = false } super.init(frame: CGRect(), configuration: configuration)