diff --git a/Telegram/Telegram-iOS/en.lproj/Localizable.strings b/Telegram/Telegram-iOS/en.lproj/Localizable.strings index 9268a2b745..7051fae35d 100644 --- a/Telegram/Telegram-iOS/en.lproj/Localizable.strings +++ b/Telegram/Telegram-iOS/en.lproj/Localizable.strings @@ -14690,3 +14690,61 @@ 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+"; + +"AccessDenied.AgeVerificationCamera" = "Telegram needs access to your camera for age verification.\n\nOpen your device's Settings > Privacy > Camera and set Telegram to ON."; + +"PeerInfo.Gifts.AddGiftsButton" = "Add Gifts"; + +"PeerInfo.Gifts.AddCollection" = "Add Collection"; +"PeerInfo.Gifts.AddGifts" = "Add Gifts"; +"PeerInfo.Gifts.RenameCollection" = "Rename"; +"PeerInfo.Gifts.ShareCollection" = "Share"; +"PeerInfo.Gifts.DeleteCollection" = "Delete Collection"; + +"PeerInfo.Gifts.CreateCollection.Title" = "Create a New Collection"; +"PeerInfo.Gifts.CreateCollection.Text" = "Choose a name for your collection and start adding your gifts there."; +"PeerInfo.Gifts.CreateCollection.Placeholder" = "Title"; + +"PeerInfo.Gifts.RenameCollection.Title" = "Create a New Collection"; + +"PeerInfo.Gifts.Context.AddToCollection" = "Add to Collection"; +"PeerInfo.Gifts.Context.NewCollection" = "New Collection"; +"PeerInfo.Gifts.Context.RemoveFromCollection" = "Remove from Collection"; + +"PeerInfo.Gifts.CollectionLimitReached.Title" = "Limit Reached"; +"PeerInfo.Gifts.CollectionLimitReached.Text" = "Please remove one of the existing collections to add a new one."; + +"PeerInfo.Gifts.RemoveCollectionConfirmation" = "This will remove the collection."; +"PeerInfo.Gifts.RemoveCollectionAction" = "Remove"; + +"PeerInfo.Gifts.EmptyCollection.Title" = "Organize Your Gifts"; +"PeerInfo.Gifts.EmptyCollection.Text" = "Add some gifts to this collection."; +"PeerInfo.Gifts.EmptyCollection.Action" = "Add to Collection"; + +"AddGifts.Title" = "Add Gifts"; +"AddGifts.AddGifts_1" = "Add %@ Gift"; +"AddGifts.AddGifts_any" = "Add %@ Gifts"; + +"Gift.Options.Gift.BuyLimitReached_1" = "You've already sent %@ of these gifts, and it's the limit."; +"Gift.Options.Gift.BuyLimitReached_any" = "You've already sent %@ of these gifts, and it's the limit."; + +"AgeVerification.Title" = "Age Verification"; +"AgeVerification.Text" = "To access this content, you must confirm you are at least **18** years old as required by UK law.\n\nThis is a one-time process using your phone's camera. Your selfie will not be stored by Telegram."; +"AgeVerification.Text.gb" = "To access this content, you must confirm you are at least **18** years old as required by UK law.\n\nThis is a one-time process using your phone's camera. Your selfie will not be stored by Telegram."; +"AgeVerification.Verify" = "Verify My Age"; + +"AgeVerification.Success.Title" = "Age check passed!"; +"AgeVerification.Success.Text" = "You can now view this content."; + +"AgeVerification.Fail.Title" = "Age check failed"; +"AgeVerification.Fail.Text" = "Sorry, you can't view this content."; + +"FaceScan.Instruction.Position" = "Position your face\nwithin the frame"; +"FaceScan.Instruction.Rotate" = "Move your head slowly to\ncomplete the circle"; diff --git a/submodules/AccountContext/Sources/AccountContext.swift b/submodules/AccountContext/Sources/AccountContext.swift index 7ea53add07..7b0b0d692f 100644 --- a/submodules/AccountContext/Sources/AccountContext.swift +++ b/submodules/AccountContext/Sources/AccountContext.swift @@ -543,12 +543,13 @@ public final class NavigateToChatControllerParams { public let changeColors: Bool public let setupController: (ChatController) -> Void public let completion: (ChatController) -> Void - public let chatListCompletion: ((ChatListController) -> Void)? + public let chatListCompletion: ((ChatListController) -> Void) public let pushController: ((ChatController, Bool, @escaping () -> Void) -> Void)? public let forceOpenChat: Bool public let customChatNavigationStack: [EnginePeer.Id]? + public let skipAgeVerification: Bool - public init(navigationController: NavigationController, chatController: ChatController? = nil, context: AccountContext, chatLocation: Location, chatLocationContextHolder: Atomic = Atomic(value: nil), subject: ChatControllerSubject? = nil, botStart: ChatControllerInitialBotStart? = nil, attachBotStart: ChatControllerInitialAttachBotStart? = nil, botAppStart: ChatControllerInitialBotAppStart? = nil, updateTextInputState: ChatTextInputState? = nil, activateInput: ChatControllerActivateInput? = nil, keepStack: NavigateToChatKeepStack = .default, useExisting: Bool = true, useBackAnimation: Bool = false, purposefulAction: (() -> Void)? = nil, scrollToEndIfExists: Bool = false, activateMessageSearch: (ChatSearchDomain, String)? = nil, peekData: ChatPeekTimeout? = nil, peerNearbyData: ChatPeerNearbyData? = nil, reportReason: NavigateToChatControllerParams.ReportReason? = nil, animated: Bool = true, forceAnimatedScroll: Bool = false, options: NavigationAnimationOptions = [], parentGroupId: PeerGroupId? = nil, chatListFilter: Int32? = nil, chatNavigationStack: [ChatNavigationStackItem] = [], changeColors: Bool = false, setupController: @escaping (ChatController) -> Void = { _ in }, pushController: ((ChatController, Bool, @escaping () -> Void) -> Void)? = nil, completion: @escaping (ChatController) -> Void = { _ in }, chatListCompletion: @escaping (ChatListController) -> Void = { _ in }, forceOpenChat: Bool = false, customChatNavigationStack: [EnginePeer.Id]? = nil) { + public init(navigationController: NavigationController, chatController: ChatController? = nil, context: AccountContext, chatLocation: Location, chatLocationContextHolder: Atomic = Atomic(value: nil), subject: ChatControllerSubject? = nil, botStart: ChatControllerInitialBotStart? = nil, attachBotStart: ChatControllerInitialAttachBotStart? = nil, botAppStart: ChatControllerInitialBotAppStart? = nil, updateTextInputState: ChatTextInputState? = nil, activateInput: ChatControllerActivateInput? = nil, keepStack: NavigateToChatKeepStack = .default, useExisting: Bool = true, useBackAnimation: Bool = false, purposefulAction: (() -> Void)? = nil, scrollToEndIfExists: Bool = false, activateMessageSearch: (ChatSearchDomain, String)? = nil, peekData: ChatPeekTimeout? = nil, peerNearbyData: ChatPeerNearbyData? = nil, reportReason: NavigateToChatControllerParams.ReportReason? = nil, animated: Bool = true, forceAnimatedScroll: Bool = false, options: NavigationAnimationOptions = [], parentGroupId: PeerGroupId? = nil, chatListFilter: Int32? = nil, chatNavigationStack: [ChatNavigationStackItem] = [], changeColors: Bool = false, setupController: @escaping (ChatController) -> Void = { _ in }, pushController: ((ChatController, Bool, @escaping () -> Void) -> Void)? = nil, completion: @escaping (ChatController) -> Void = { _ in }, chatListCompletion: @escaping (ChatListController) -> Void = { _ in }, forceOpenChat: Bool = false, customChatNavigationStack: [EnginePeer.Id]? = nil, skipAgeVerification: Bool = false) { self.navigationController = navigationController self.chatController = chatController self.chatLocationContextHolder = chatLocationContextHolder @@ -582,6 +583,46 @@ public final class NavigateToChatControllerParams { self.chatListCompletion = chatListCompletion self.forceOpenChat = forceOpenChat self.customChatNavigationStack = customChatNavigationStack + self.skipAgeVerification = skipAgeVerification + } + + public func withSkipAgeVerification(_ skipAgeVerification: Bool) -> NavigateToChatControllerParams { + return NavigateToChatControllerParams( + navigationController: self.navigationController, + chatController: self.chatController, + context: self.context, + chatLocation: self.chatLocation, + chatLocationContextHolder: self.chatLocationContextHolder, + subject: self.subject, + botStart: self.botStart, + attachBotStart: self.attachBotStart, + botAppStart: self.botAppStart, + updateTextInputState: self.updateTextInputState, + activateInput: self.activateInput, + keepStack: self.keepStack, + useExisting: self.useExisting, + useBackAnimation: self.useBackAnimation, + purposefulAction: self.purposefulAction, + scrollToEndIfExists: self.scrollToEndIfExists, + activateMessageSearch: self.activateMessageSearch, + peekData: self.peekData, + peerNearbyData: self.peerNearbyData, + reportReason: self.reportReason, + animated: self.animated, + forceAnimatedScroll: self.forceAnimatedScroll, + options: self.options, + parentGroupId: self.parentGroupId, + chatListFilter: self.chatListFilter, + chatNavigationStack: self.chatNavigationStack, + changeColors: self.changeColors, + setupController: self.setupController, + pushController: self.pushController, + completion: self.completion, + chatListCompletion: self.chatListCompletion, + forceOpenChat: self.forceOpenChat, + customChatNavigationStack: self.customChatNavigationStack, + skipAgeVerification: skipAgeVerification + ) } } 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/DeviceAccess/Sources/DeviceAccess.swift b/submodules/DeviceAccess/Sources/DeviceAccess.swift index 8db10249eb..625bd10f41 100644 --- a/submodules/DeviceAccess/Sources/DeviceAccess.swift +++ b/submodules/DeviceAccess/Sources/DeviceAccess.swift @@ -16,6 +16,7 @@ public enum DeviceAccessCameraSubject { case video case videoCall case qrCode + case ageVerification } public enum DeviceAccessMicrophoneSubject { @@ -332,6 +333,8 @@ public final class DeviceAccess { text = presentationData.strings.AccessDenied_VideoCallCamera case .qrCode: text = presentationData.strings.AccessDenied_QrCamera + case .ageVerification: + text = presentationData.strings.AccessDenied_AgeVerificationCamera } present(standardTextAlertController(theme: AlertControllerTheme(presentationData: presentationData), title: presentationData.strings.AccessDenied_Title, text: text, actions: [TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_NotNow, action: {}), TextAlertAction(type: .genericAction, title: presentationData.strings.AccessDenied_Settings, action: { openSettings() @@ -356,6 +359,8 @@ public final class DeviceAccess { text = presentationData.strings.AccessDenied_VideoCallCamera case .qrCode: text = presentationData.strings.AccessDenied_QrCamera + case .ageVerification: + text = presentationData.strings.AccessDenied_AgeVerificationCamera } } present(standardTextAlertController(theme: AlertControllerTheme(presentationData: presentationData), title: presentationData.strings.AccessDenied_Title, text: text, actions: [TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_NotNow, action: {}), TextAlertAction(type: .genericAction, title: presentationData.strings.AccessDenied_Settings, action: { 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/SettingsUI/BUILD b/submodules/SettingsUI/BUILD index edf5e5466d..bfb017f963 100644 --- a/submodules/SettingsUI/BUILD +++ b/submodules/SettingsUI/BUILD @@ -126,6 +126,7 @@ swift_library( "//submodules/TelegramUI/Components/Settings/ThemeAccentColorScreen", "//submodules/TelegramUI/Components/Settings/GenerateThemeName", "//submodules/TelegramUI/Components/Settings/PeerNameColorItem", + "//submodules/TelegramUI/Components/FaceScanScreen", ], visibility = [ "//visibility:public", diff --git a/submodules/SettingsUI/Sources/Data and Storage/DataAndStorageSettingsController.swift b/submodules/SettingsUI/Sources/Data and Storage/DataAndStorageSettingsController.swift index 2caf610c6a..082da648a9 100644 --- a/submodules/SettingsUI/Sources/Data and Storage/DataAndStorageSettingsController.swift +++ b/submodules/SettingsUI/Sources/Data and Storage/DataAndStorageSettingsController.swift @@ -14,6 +14,7 @@ import OpenInExternalAppUI import ItemListPeerActionItem import StorageUsageScreen import PresentationDataUtils +import FaceScanScreen public enum AutomaticSaveIncomingPeerType { case privateChats @@ -617,7 +618,7 @@ private func autosaveLabelAndValue(presentationData: PresentationData, settings: return (label, value) } -private func dataAndStorageControllerEntries(state: DataAndStorageControllerState, data: DataAndStorageData, presentationData: PresentationData, defaultWebBrowser: String, contentSettingsConfiguration: ContentSettingsConfiguration?, networkUsage: Int64, storageUsage: Int64, mediaAutoSaveSettings: MediaAutoSaveSettings, autosaveExceptionPeers: [EnginePeer.Id: EnginePeer?], mediaSettings: MediaDisplaySettings) -> [DataAndStorageEntry] { +private func dataAndStorageControllerEntries(state: DataAndStorageControllerState, data: DataAndStorageData, presentationData: PresentationData, defaultWebBrowser: String, contentSettingsConfiguration: ContentSettingsConfiguration?, networkUsage: Int64, storageUsage: Int64, mediaAutoSaveSettings: MediaAutoSaveSettings, autosaveExceptionPeers: [EnginePeer.Id: EnginePeer?], mediaSettings: MediaDisplaySettings, showSensitiveContentSetting: Bool) -> [DataAndStorageEntry] { var entries: [DataAndStorageEntry] = [] entries.append(.storageUsage(presentationData.theme, presentationData.strings.ChatSettings_Cache, dataSizeString(storageUsage, formatting: DataSizeStringFormatting(presentationData: presentationData)))) @@ -656,7 +657,7 @@ private func dataAndStorageControllerEntries(state: DataAndStorageControllerStat entries.append(.raiseToListen(presentationData.theme, presentationData.strings.Settings_RaiseToListen, data.mediaInputSettings.enableRaiseToSpeak)) entries.append(.raiseToListenInfo(presentationData.theme, presentationData.strings.Settings_RaiseToListenInfo)) - if !"".isEmpty, let contentSettingsConfiguration = contentSettingsConfiguration, contentSettingsConfiguration.canAdjustSensitiveContent { + if let contentSettingsConfiguration = contentSettingsConfiguration, contentSettingsConfiguration.canAdjustSensitiveContent && showSensitiveContentSetting { entries.append(.sensitiveContent(presentationData.strings.Settings_SensitiveContent, contentSettingsConfiguration.sensitiveContentEnabled)) entries.append(.sensitiveContentInfo(presentationData.strings.Settings_SensitiveContentInfo)) } @@ -911,6 +912,12 @@ public func dataAndStorageController(context: AccountContext, focusOnItemTag: Da } }) updateSensitiveContentDisposable.set(updateRemoteContentSettingsConfiguration(postbox: context.account.postbox, network: context.account.network, sensitiveContentEnabled: value).start()) + + if !value { + let _ = updateAgeVerificationState(engine: context.engine, { _ in + return AgeVerificationState(verificationPassed: false) + }).start() + } } if value { let presentationData = context.sharedContext.currentPresentationData.with { $0 } @@ -944,6 +951,7 @@ public func dataAndStorageController(context: AccountContext, focusOnItemTag: Da } let sensitiveContent = Atomic(value: nil) + let canAdjustSensitiveContent = Atomic(value: nil) let signal = combineLatest(queue: .mainQueue(), context.sharedContext.presentationData, @@ -974,9 +982,14 @@ public func dataAndStorageController(context: AccountContext, focusOnItemTag: Da if previousSensitiveContent != contentSettingsConfiguration?.sensitiveContentEnabled { animateChanges = true } + + if canAdjustSensitiveContent.with({ $0 }) == nil { + let _ = canAdjustSensitiveContent.swap(contentSettingsConfiguration?.sensitiveContentEnabled) + } + let showSensitiveContentSetting = canAdjustSensitiveContent.with { $0 } ?? false let controllerState = ItemListControllerState(presentationData: ItemListPresentationData(presentationData), title: .text(presentationData.strings.ChatSettings_Title), leftNavigationButton: nil, rightNavigationButton: nil, backNavigationButton: ItemListBackButton(title: presentationData.strings.Common_Back), animateChanges: false) - let listState = ItemListNodeState(presentationData: ItemListPresentationData(presentationData), entries: dataAndStorageControllerEntries(state: state, data: dataAndStorageData, presentationData: presentationData, defaultWebBrowser: defaultWebBrowser, contentSettingsConfiguration: contentSettingsConfiguration, networkUsage: usageSignal.network, storageUsage: usageSignal.storage, mediaAutoSaveSettings: mediaAutoSaveSettings, autosaveExceptionPeers: autosaveExceptionPeers, mediaSettings: mediaSettings), style: .blocks, ensureVisibleItemTag: focusOnItemTag, emptyStateItem: nil, animateChanges: animateChanges) + let listState = ItemListNodeState(presentationData: ItemListPresentationData(presentationData), entries: dataAndStorageControllerEntries(state: state, data: dataAndStorageData, presentationData: presentationData, defaultWebBrowser: defaultWebBrowser, contentSettingsConfiguration: contentSettingsConfiguration, networkUsage: usageSignal.network, storageUsage: usageSignal.storage, mediaAutoSaveSettings: mediaAutoSaveSettings, autosaveExceptionPeers: autosaveExceptionPeers, mediaSettings: mediaSettings, showSensitiveContentSetting: showSensitiveContentSetting), style: .blocks, ensureVisibleItemTag: focusOnItemTag, emptyStateItem: nil, animateChanges: animateChanges) return (controllerState, (listState, arguments)) } |> afterDisposed { 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/BUILD b/submodules/TelegramUI/BUILD index ea0a63e21b..7d4d317c64 100644 --- a/submodules/TelegramUI/BUILD +++ b/submodules/TelegramUI/BUILD @@ -482,6 +482,7 @@ swift_library( "//submodules/TelegramUI/Components/ComposeTodoScreen", "//submodules/TelegramUI/Components/SuggestedPostApproveAlert", "//submodules/TelegramUI/Components/Stars/BalanceNeededScreen", + "//submodules/TelegramUI/Components/FaceScanScreen", "//submodules/ContactsHelper", ] + select({ "@build_bazel_rules_apple//apple:ios_arm64": appcenter_targets, 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/FaceScanScreen/BUILD b/submodules/TelegramUI/Components/FaceScanScreen/BUILD new file mode 100644 index 0000000000..e024731f40 --- /dev/null +++ b/submodules/TelegramUI/Components/FaceScanScreen/BUILD @@ -0,0 +1,41 @@ +load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library") + +swift_library( + name = "FaceScanScreen", + module_name = "FaceScanScreen", + srcs = glob([ + "Sources/**/*.swift", + ]), + copts = [ + "-warnings-as-errors", + ], + deps = [ + "//submodules/TelegramCore", + "//submodules/SSignalKit/SwiftSignalKit", + "//submodules/AsyncDisplayKit", + "//submodules/Display", + "//submodules/TelegramPresentationData", + "//submodules/AccountContext", + "//submodules/AppBundle", + "//submodules/PresentationDataUtils", + "//submodules/TextFormat", + "//submodules/Markdown", + "//submodules/Camera", + "//submodules/FileMediaResourceStatus", + "//submodules/TelegramUIPreferences", + "//submodules/UndoUI", + "//submodules/DeviceAccess", + "//third-party/ZipArchive", + "//submodules/ComponentFlow", + "//submodules/Components/ViewControllerComponent", + "//submodules/Components/MultilineTextComponent", + "//submodules/Components/BalancedTextComponent", + "//submodules/Components/BundleIconComponent", + "//submodules/TelegramUI/Components/PlainButtonComponent", + "//submodules/Components/SheetComponent", + "//submodules/TelegramUI/Components/ButtonComponent", + ], + visibility = [ + "//visibility:public", + ], +) diff --git a/submodules/TelegramUI/Components/FaceScanScreen/Sources/AgeNet.swift b/submodules/TelegramUI/Components/FaceScanScreen/Sources/AgeNet.swift new file mode 100644 index 0000000000..3dd5aefe31 --- /dev/null +++ b/submodules/TelegramUI/Components/FaceScanScreen/Sources/AgeNet.swift @@ -0,0 +1,299 @@ +// +// AgeNet.swift +// +// This file was automatically generated and should not be edited. +// + +import CoreML + + +/// Model Prediction Input Type +@available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) +class AgeNetInput : MLFeatureProvider { + + /// input as color (kCVPixelFormatType_32BGRA) image buffer, 112 pixels wide by 112 pixels high + var input: CVPixelBuffer + + var featureNames: Set { ["input"] } + + func featureValue(for featureName: String) -> MLFeatureValue? { + if featureName == "input" { + return MLFeatureValue(pixelBuffer: input) + } + return nil + } + + init(input: CVPixelBuffer) { + self.input = input + } + + convenience init(inputWith input: CGImage) throws { + self.init(input: try MLFeatureValue(cgImage: input, pixelsWide: 112, pixelsHigh: 112, pixelFormatType: kCVPixelFormatType_32ARGB, options: nil).imageBufferValue!) + } + + convenience init(inputAt input: URL) throws { + self.init(input: try MLFeatureValue(imageAt: input, pixelsWide: 112, pixelsHigh: 112, pixelFormatType: kCVPixelFormatType_32ARGB, options: nil).imageBufferValue!) + } + + func setInput(with input: CGImage) throws { + self.input = try MLFeatureValue(cgImage: input, pixelsWide: 112, pixelsHigh: 112, pixelFormatType: kCVPixelFormatType_32ARGB, options: nil).imageBufferValue! + } + + func setInput(with input: URL) throws { + self.input = try MLFeatureValue(imageAt: input, pixelsWide: 112, pixelsHigh: 112, pixelFormatType: kCVPixelFormatType_32ARGB, options: nil).imageBufferValue! + } + +} + + +/// Model Prediction Output Type +@available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) +class AgeNetOutput : MLFeatureProvider { + + /// Source provided by CoreML + private let provider : MLFeatureProvider + + /// Identity as 1 by 1 matrix of floats + var Identity: MLMultiArray { + provider.featureValue(for: "Identity")!.multiArrayValue! + } + + /// Identity as 1 by 1 matrix of floats + var IdentityShapedArray: MLShapedArray { + MLShapedArray(Identity) + } + + var featureNames: Set { + provider.featureNames + } + + func featureValue(for featureName: String) -> MLFeatureValue? { + provider.featureValue(for: featureName) + } + + init(Identity: MLMultiArray) { + self.provider = try! MLDictionaryFeatureProvider(dictionary: ["Identity" : MLFeatureValue(multiArray: Identity)]) + } + + init(features: MLFeatureProvider) { + self.provider = features + } +} + + +/// Class for model loading and prediction +@available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) +class AgeNet { + let model: MLModel + + /// URL of model assuming it was installed in the same bundle as this class + class var urlOfModelInThisBundle : URL { + let bundle = Bundle(for: self) + return bundle.url(forResource: "AgeNet", withExtension:"mlmodelc")! + } + + /** + Construct AgeNet instance with an existing MLModel object. + + Usually the application does not use this initializer unless it makes a subclass of AgeNet. + Such application may want to use `MLModel(contentsOfURL:configuration:)` and `AgeNet.urlOfModelInThisBundle` to create a MLModel object to pass-in. + + - parameters: + - model: MLModel object + */ + init(model: MLModel) { + self.model = model + } + + /** + Construct a model with configuration + + - parameters: + - configuration: the desired model configuration + + - throws: an NSError object that describes the problem + */ + convenience init(configuration: MLModelConfiguration = MLModelConfiguration()) throws { + try self.init(contentsOf: type(of:self).urlOfModelInThisBundle, configuration: configuration) + } + + /** + Construct AgeNet instance with explicit path to mlmodelc file + - parameters: + - modelURL: the file url of the model + + - throws: an NSError object that describes the problem + */ + convenience init(contentsOf modelURL: URL) throws { + try self.init(model: MLModel(contentsOf: modelURL)) + } + + /** + Construct a model with URL of the .mlmodelc directory and configuration + + - parameters: + - modelURL: the file url of the model + - configuration: the desired model configuration + + - throws: an NSError object that describes the problem + */ + convenience init(contentsOf modelURL: URL, configuration: MLModelConfiguration) throws { + try self.init(model: MLModel(contentsOf: modelURL, configuration: configuration)) + } + + /** + Construct AgeNet instance asynchronously with optional configuration. + + Model loading may take time when the model content is not immediately available (e.g. encrypted model). Use this factory method especially when the caller is on the main thread. + + - parameters: + - configuration: the desired model configuration + - handler: the completion handler to be called when the model loading completes successfully or unsuccessfully + */ + class func load(configuration: MLModelConfiguration = MLModelConfiguration(), completionHandler handler: @escaping (Swift.Result) -> Void) { + load(contentsOf: self.urlOfModelInThisBundle, configuration: configuration, completionHandler: handler) + } + + /** + Construct AgeNet instance asynchronously with optional configuration. + + Model loading may take time when the model content is not immediately available (e.g. encrypted model). Use this factory method especially when the caller is on the main thread. + + - parameters: + - configuration: the desired model configuration + */ + class func load(configuration: MLModelConfiguration = MLModelConfiguration()) async throws -> AgeNet { + try await load(contentsOf: self.urlOfModelInThisBundle, configuration: configuration) + } + + /** + Construct AgeNet instance asynchronously with URL of the .mlmodelc directory with optional configuration. + + Model loading may take time when the model content is not immediately available (e.g. encrypted model). Use this factory method especially when the caller is on the main thread. + + - parameters: + - modelURL: the URL to the model + - configuration: the desired model configuration + - handler: the completion handler to be called when the model loading completes successfully or unsuccessfully + */ + class func load(contentsOf modelURL: URL, configuration: MLModelConfiguration = MLModelConfiguration(), completionHandler handler: @escaping (Swift.Result) -> Void) { + MLModel.load(contentsOf: modelURL, configuration: configuration) { result in + switch result { + case .failure(let error): + handler(.failure(error)) + case .success(let model): + handler(.success(AgeNet(model: model))) + } + } + } + + /** + Construct AgeNet instance asynchronously with URL of the .mlmodelc directory with optional configuration. + + Model loading may take time when the model content is not immediately available (e.g. encrypted model). Use this factory method especially when the caller is on the main thread. + + - parameters: + - modelURL: the URL to the model + - configuration: the desired model configuration + */ + class func load(contentsOf modelURL: URL, configuration: MLModelConfiguration = MLModelConfiguration()) async throws -> AgeNet { + let model = try await MLModel.load(contentsOf: modelURL, configuration: configuration) + return AgeNet(model: model) + } + + /** + Make a prediction using the structured interface + + It uses the default function if the model has multiple functions. + + - parameters: + - input: the input to the prediction as AgeNetInput + + - throws: an NSError object that describes the problem + + - returns: the result of the prediction as AgeNetOutput + */ + func prediction(input: AgeNetInput) throws -> AgeNetOutput { + try prediction(input: input, options: MLPredictionOptions()) + } + + /** + Make a prediction using the structured interface + + It uses the default function if the model has multiple functions. + + - parameters: + - input: the input to the prediction as AgeNetInput + - options: prediction options + + - throws: an NSError object that describes the problem + + - returns: the result of the prediction as AgeNetOutput + */ + func prediction(input: AgeNetInput, options: MLPredictionOptions) throws -> AgeNetOutput { + let outFeatures = try model.prediction(from: input, options: options) + return AgeNetOutput(features: outFeatures) + } + + /** + Make an asynchronous prediction using the structured interface + + It uses the default function if the model has multiple functions. + + - parameters: + - input: the input to the prediction as AgeNetInput + - options: prediction options + + - throws: an NSError object that describes the problem + + - returns: the result of the prediction as AgeNetOutput + */ + @available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, visionOS 1.0, *) + func prediction(input: AgeNetInput, options: MLPredictionOptions = MLPredictionOptions()) async throws -> AgeNetOutput { + let outFeatures = try await model.prediction(from: input, options: options) + return AgeNetOutput(features: outFeatures) + } + + /** + Make a prediction using the convenience interface + + It uses the default function if the model has multiple functions. + + - parameters: + - input: color (kCVPixelFormatType_32BGRA) image buffer, 112 pixels wide by 112 pixels high + + - throws: an NSError object that describes the problem + + - returns: the result of the prediction as AgeNetOutput + */ + func prediction(input: CVPixelBuffer) throws -> AgeNetOutput { + let input_ = AgeNetInput(input: input) + return try prediction(input: input_) + } + + /** + Make a batch prediction using the structured interface + + It uses the default function if the model has multiple functions. + + - parameters: + - inputs: the inputs to the prediction as [AgeNetInput] + - options: prediction options + + - throws: an NSError object that describes the problem + + - returns: the result of the prediction as [AgeNetOutput] + */ + func predictions(inputs: [AgeNetInput], options: MLPredictionOptions = MLPredictionOptions()) throws -> [AgeNetOutput] { + let batchIn = MLArrayBatchProvider(array: inputs) + let batchOut = try model.predictions(from: batchIn, options: options) + var results : [AgeNetOutput] = [] + results.reserveCapacity(inputs.count) + for i in 0.. { ["input"] } + + func featureValue(for featureName: String) -> MLFeatureValue? { + if featureName == "input" { + return MLFeatureValue(pixelBuffer: input) + } + return nil + } + + init(input: CVPixelBuffer) { + self.input = input + } + + convenience init(inputWith input: CGImage) throws { + self.init(input: try MLFeatureValue(cgImage: input, pixelsWide: 112, pixelsHigh: 112, pixelFormatType: kCVPixelFormatType_32ARGB, options: nil).imageBufferValue!) + } + + convenience init(inputAt input: URL) throws { + self.init(input: try MLFeatureValue(imageAt: input, pixelsWide: 112, pixelsHigh: 112, pixelFormatType: kCVPixelFormatType_32ARGB, options: nil).imageBufferValue!) + } + + func setInput(with input: CGImage) throws { + self.input = try MLFeatureValue(cgImage: input, pixelsWide: 112, pixelsHigh: 112, pixelFormatType: kCVPixelFormatType_32ARGB, options: nil).imageBufferValue! + } + + func setInput(with input: URL) throws { + self.input = try MLFeatureValue(imageAt: input, pixelsWide: 112, pixelsHigh: 112, pixelFormatType: kCVPixelFormatType_32ARGB, options: nil).imageBufferValue! + } + +} + + +/// Model Prediction Output Type +@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, visionOS 1.0, *) +class AgeNetLegacyOutput : MLFeatureProvider { + + /// Source provided by CoreML + private let provider : MLFeatureProvider + + /// Identity as multidimensional array of floats + var Identity: MLMultiArray { + provider.featureValue(for: "Identity")!.multiArrayValue! + } + + /// Identity as multidimensional array of floats + @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) + var IdentityShapedArray: MLShapedArray { + MLShapedArray(Identity) + } + + var featureNames: Set { + provider.featureNames + } + + func featureValue(for featureName: String) -> MLFeatureValue? { + provider.featureValue(for: featureName) + } + + init(Identity: MLMultiArray) { + self.provider = try! MLDictionaryFeatureProvider(dictionary: ["Identity" : MLFeatureValue(multiArray: Identity)]) + } + + init(features: MLFeatureProvider) { + self.provider = features + } +} + + +/// Class for model loading and prediction +@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, visionOS 1.0, *) +class AgeNetLegacy { + let model: MLModel + + /// URL of model assuming it was installed in the same bundle as this class + class var urlOfModelInThisBundle : URL { + let bundle = Bundle(for: self) + return bundle.url(forResource: "AgeNetLegacy", withExtension:"mlmodelc")! + } + + /** + Construct AgeNetLegacy instance with an existing MLModel object. + + Usually the application does not use this initializer unless it makes a subclass of AgeNetLegacy. + Such application may want to use `MLModel(contentsOfURL:configuration:)` and `AgeNetLegacy.urlOfModelInThisBundle` to create a MLModel object to pass-in. + + - parameters: + - model: MLModel object + */ + init(model: MLModel) { + self.model = model + } + + /** + Construct AgeNetLegacy instance by automatically loading the model from the app's bundle. + */ + @available(*, deprecated, message: "Use init(configuration:) instead and handle errors appropriately.") + convenience init() { + try! self.init(contentsOf: type(of:self).urlOfModelInThisBundle) + } + + /** + Construct a model with configuration + + - parameters: + - configuration: the desired model configuration + + - throws: an NSError object that describes the problem + */ + convenience init(configuration: MLModelConfiguration) throws { + try self.init(contentsOf: type(of:self).urlOfModelInThisBundle, configuration: configuration) + } + + /** + Construct AgeNetLegacy instance with explicit path to mlmodelc file + - parameters: + - modelURL: the file url of the model + + - throws: an NSError object that describes the problem + */ + convenience init(contentsOf modelURL: URL) throws { + try self.init(model: MLModel(contentsOf: modelURL)) + } + + /** + Construct a model with URL of the .mlmodelc directory and configuration + + - parameters: + - modelURL: the file url of the model + - configuration: the desired model configuration + + - throws: an NSError object that describes the problem + */ + convenience init(contentsOf modelURL: URL, configuration: MLModelConfiguration) throws { + try self.init(model: MLModel(contentsOf: modelURL, configuration: configuration)) + } + + /** + Construct AgeNetLegacy instance asynchronously with optional configuration. + + Model loading may take time when the model content is not immediately available (e.g. encrypted model). Use this factory method especially when the caller is on the main thread. + + - parameters: + - configuration: the desired model configuration + - handler: the completion handler to be called when the model loading completes successfully or unsuccessfully + */ + @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, visionOS 1.0, *) + class func load(configuration: MLModelConfiguration = MLModelConfiguration(), completionHandler handler: @escaping (Swift.Result) -> Void) { + load(contentsOf: self.urlOfModelInThisBundle, configuration: configuration, completionHandler: handler) + } + + /** + Construct AgeNetLegacy instance asynchronously with optional configuration. + + Model loading may take time when the model content is not immediately available (e.g. encrypted model). Use this factory method especially when the caller is on the main thread. + + - parameters: + - configuration: the desired model configuration + */ + @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) + class func load(configuration: MLModelConfiguration = MLModelConfiguration()) async throws -> AgeNetLegacy { + try await load(contentsOf: self.urlOfModelInThisBundle, configuration: configuration) + } + + /** + Construct AgeNetLegacy instance asynchronously with URL of the .mlmodelc directory with optional configuration. + + Model loading may take time when the model content is not immediately available (e.g. encrypted model). Use this factory method especially when the caller is on the main thread. + + - parameters: + - modelURL: the URL to the model + - configuration: the desired model configuration + - handler: the completion handler to be called when the model loading completes successfully or unsuccessfully + */ + @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, visionOS 1.0, *) + class func load(contentsOf modelURL: URL, configuration: MLModelConfiguration = MLModelConfiguration(), completionHandler handler: @escaping (Swift.Result) -> Void) { + MLModel.load(contentsOf: modelURL, configuration: configuration) { result in + switch result { + case .failure(let error): + handler(.failure(error)) + case .success(let model): + handler(.success(AgeNetLegacy(model: model))) + } + } + } + + /** + Construct AgeNetLegacy instance asynchronously with URL of the .mlmodelc directory with optional configuration. + + Model loading may take time when the model content is not immediately available (e.g. encrypted model). Use this factory method especially when the caller is on the main thread. + + - parameters: + - modelURL: the URL to the model + - configuration: the desired model configuration + */ + @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) + class func load(contentsOf modelURL: URL, configuration: MLModelConfiguration = MLModelConfiguration()) async throws -> AgeNetLegacy { + let model = try await MLModel.load(contentsOf: modelURL, configuration: configuration) + return AgeNetLegacy(model: model) + } + + /** + Make a prediction using the structured interface + + It uses the default function if the model has multiple functions. + + - parameters: + - input: the input to the prediction as AgeNetLegacyInput + + - throws: an NSError object that describes the problem + + - returns: the result of the prediction as AgeNetLegacyOutput + */ + func prediction(input: AgeNetLegacyInput) throws -> AgeNetLegacyOutput { + try prediction(input: input, options: MLPredictionOptions()) + } + + /** + Make a prediction using the structured interface + + It uses the default function if the model has multiple functions. + + - parameters: + - input: the input to the prediction as AgeNetLegacyInput + - options: prediction options + + - throws: an NSError object that describes the problem + + - returns: the result of the prediction as AgeNetLegacyOutput + */ + func prediction(input: AgeNetLegacyInput, options: MLPredictionOptions) throws -> AgeNetLegacyOutput { + let outFeatures = try model.prediction(from: input, options: options) + return AgeNetLegacyOutput(features: outFeatures) + } + + /** + Make an asynchronous prediction using the structured interface + + It uses the default function if the model has multiple functions. + + - parameters: + - input: the input to the prediction as AgeNetLegacyInput + - options: prediction options + + - throws: an NSError object that describes the problem + + - returns: the result of the prediction as AgeNetLegacyOutput + */ + @available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, visionOS 1.0, *) + func prediction(input: AgeNetLegacyInput, options: MLPredictionOptions = MLPredictionOptions()) async throws -> AgeNetLegacyOutput { + let outFeatures = try await model.prediction(from: input, options: options) + return AgeNetLegacyOutput(features: outFeatures) + } + + /** + Make a prediction using the convenience interface + + It uses the default function if the model has multiple functions. + + - parameters: + - input: color (kCVPixelFormatType_32BGRA) image buffer, 112 pixels wide by 112 pixels high + + - throws: an NSError object that describes the problem + + - returns: the result of the prediction as AgeNetLegacyOutput + */ + func prediction(input: CVPixelBuffer) throws -> AgeNetLegacyOutput { + let input_ = AgeNetLegacyInput(input: input) + return try prediction(input: input_) + } + + /** + Make a batch prediction using the structured interface + + It uses the default function if the model has multiple functions. + + - parameters: + - inputs: the inputs to the prediction as [AgeNetLegacyInput] + - options: prediction options + + - throws: an NSError object that describes the problem + + - returns: the result of the prediction as [AgeNetLegacyOutput] + */ + func predictions(inputs: [AgeNetLegacyInput], options: MLPredictionOptions = MLPredictionOptions()) throws -> [AgeNetLegacyOutput] { + let batchIn = MLArrayBatchProvider(array: inputs) + let batchOut = try model.predictions(from: batchIn, options: options) + var results : [AgeNetLegacyOutput] = [] + results.reserveCapacity(inputs.count) + for i in 0.. String { + return NSTemporaryDirectory() + "AgeNet.mlmodelc" +} +private let modelPeer = "agecomputation" + +private func legacyModelPath() -> String { + return NSTemporaryDirectory() + "AgeNetLegacy.mlmodelc" +} +private let legacyModelPeer = "agelegacycomputation" + +public func ageVerificationAvailability(context: AccountContext) -> Signal { + let compiledModelPath: String + let modelPeerName: String + let isLegacy: Bool + if #available(iOS 15.0, *) { + compiledModelPath = modelPath() + modelPeerName = modelPeer + isLegacy = false + } else { + compiledModelPath = legacyModelPath() + modelPeerName = legacyModelPeer + isLegacy = true + } + if FileManager.default.fileExists(atPath: compiledModelPath) { + return .single(.available(compiledModelPath, isLegacy)) + } + return context.engine.peers.resolvePeerByName(name: modelPeerName, referrer: nil) + |> mapToSignal { result -> Signal in + guard case let .result(maybePeer) = result else { + return .complete() + } + guard let peer = maybePeer else { + return .single(.unavailable) + } + + return context.account.viewTracker.aroundMessageHistoryViewForLocation(.peer(peerId: peer.id, threadId: nil), index: .lowerBound, anchorIndex: .lowerBound, count: 5, fixedCombinedReadStates: nil) + |> mapToSignal { view -> Signal<(TelegramMediaFile, EngineMessage)?, NoError> in + if !view.0.isLoading { + if let message = view.0.entries.last?.message, let file = message.media.first(where: { $0 is TelegramMediaFile }) as? TelegramMediaFile { + return .single((file, EngineMessage(message))) + } else { + return .single(nil) + } + } else { + return .complete() + } + } + |> take(1) + |> mapToSignal { maybeFileAndMessage -> Signal in + if let (file, message) = maybeFileAndMessage { + let fetchedData = fetchedMediaResource(mediaBox: context.account.postbox.mediaBox, userLocation: .other, userContentType: .file, reference: FileMediaReference.message(message: MessageReference(message._asMessage()), media: file).resourceReference(file.resource)) + + enum FetchStatus { + case completed(String) + case progress(Float) + case failed + } + + let fetchStatus = Signal { subscriber in + let fetchedDisposable = fetchedData.start() + let resourceDataDisposable = context.account.postbox.mediaBox.resourceData(file.resource, attemptSynchronously: false).start(next: { next in + if next.complete { + SSZipArchive.unzipFile(atPath: next.path, toDestination: NSTemporaryDirectory()) + subscriber.putNext(.completed(compiledModelPath)) + subscriber.putCompletion() + } + }, error: subscriber.putError, completed: subscriber.putCompletion) + let progressDisposable = messageFileMediaResourceStatus(context: context, file: file, message: message, isRecentActions: false).start(next: { status in + switch status.fetchStatus { + case let .Remote(progress), let .Fetching(_, progress), let .Paused(progress): + subscriber.putNext(.progress(progress)) + default: + break + } + }) + return ActionDisposable { + fetchedDisposable.dispose() + resourceDataDisposable.dispose() + progressDisposable.dispose() + } + } + return fetchStatus + |> mapToSignal { status -> Signal in + switch status { + case .completed: + return .single(.available(compiledModelPath, isLegacy)) + case let .progress(progress): + return .single(.progress(progress)) + case .failed: + return .single(.unavailable) + } + } + } else { + return .single(.unavailable) + } + } + } +} diff --git a/submodules/TelegramUI/Components/FaceScanScreen/Sources/AgeVerificationScreen.swift b/submodules/TelegramUI/Components/FaceScanScreen/Sources/AgeVerificationScreen.swift new file mode 100644 index 0000000000..efb8d18c65 --- /dev/null +++ b/submodules/TelegramUI/Components/FaceScanScreen/Sources/AgeVerificationScreen.swift @@ -0,0 +1,469 @@ +import Foundation +import UIKit +import Display +import ComponentFlow +import SwiftSignalKit +import TelegramCore +import Markdown +import TextFormat +import TelegramPresentationData +import ViewControllerComponent +import SheetComponent +import BalancedTextComponent +import MultilineTextComponent +import BundleIconComponent +import ButtonComponent +import AccountContext +import PresentationDataUtils +import TelegramUIPreferences +import UndoUI +import DeviceAccess + +public func requireAgeVerification(context: AccountContext) -> Bool { + if let value = context.currentAppConfiguration.with({ $0 }).data?["need_age_video_verification"] as? Bool, value { + return true + } + return false +} + +public func requireAgeVerification(context: AccountContext, peer: EnginePeer) -> Signal { + if requireAgeVerification(context: context), peer._asPeer().hasSensitiveContent(platform: "ios") { + return context.engine.data.get(TelegramEngine.EngineData.Item.Configuration.ContentSettings()) + |> map { contentSettings in + if !contentSettings.ignoreContentRestrictionReasons.contains("sensitive") { + return true + } + return false + } + } + return .single(false) +} + +private final class SheetContent: CombinedComponent { + typealias EnvironmentType = ViewControllerComponentContainer.Environment + + let context: AccountContext + let dismiss: () -> Void + + init( + context: AccountContext, + dismiss: @escaping () -> Void + ) { + self.context = context + self.dismiss = dismiss + } + + static func ==(lhs: SheetContent, rhs: SheetContent) -> Bool { + if lhs.context !== rhs.context { + return false + } + return true + } + + final class State: ComponentState { + var cachedCloseImage: (UIImage, PresentationTheme)? + } + + func makeState() -> State { + return State() + } + + static var body: Body { + let background = Child(RoundedRectangle.self) + let icon = Child(ZStack.self) + let closeButton = Child(Button.self) + let title = Child(Text.self) + let text = Child(BalancedTextComponent.self) + + let button = Child(ButtonComponent.self) + + return { context in + let environment = context.environment[EnvironmentType.self] + let component = context.component + let state = context.state + + let presentationData = component.context.sharedContext.currentPresentationData.with { $0 } + let theme = presentationData.theme + let strings = presentationData.strings + + var contentSize = CGSize(width: context.availableSize.width, height: 18.0) + + let background = background.update( + component: RoundedRectangle(color: theme.actionSheet.opaqueItemBackgroundColor, cornerRadius: 8.0), + availableSize: CGSize(width: context.availableSize.width, height: 1000.0), + transition: .immediate + ) + context.add(background + .position(CGPoint(x: context.availableSize.width / 2.0, y: background.size.height / 2.0)) + ) + + let icon = icon.update( + component: ZStack([ + AnyComponentWithIdentity( + id: AnyHashable(0), + component: AnyComponent(RoundedRectangle(color: theme.list.itemCheckColors.fillColor, cornerRadius: 45.0, size: CGSize(width: 90.0, height: 90.0))) + ), + AnyComponentWithIdentity( + id: AnyHashable(1), + component: AnyComponent(BundleIconComponent( + name: "Settings/FaceVerification", + tintColor: theme.list.itemCheckColors.foregroundColor + )) + ) + ]), + availableSize: CGSize(width: 90.0, height: 90.0), + transition: .immediate + ) + context.add(icon + .position(CGPoint(x: context.availableSize.width / 2.0, y: icon.size.height / 2.0 + 31.0)) + ) + + let closeImage: UIImage + if let (image, cacheTheme) = state.cachedCloseImage, theme === cacheTheme { + closeImage = image + } else { + closeImage = generateCloseButtonImage(backgroundColor: UIColor(rgb: 0x808084, alpha: 0.1), foregroundColor: theme.actionSheet.inputClearButtonColor)! + state.cachedCloseImage = (closeImage, theme) + } + let closeButton = closeButton.update( + component: Button( + content: AnyComponent(Image(image: closeImage)), + action: { + component.dismiss() + } + ), + availableSize: CGSize(width: 30.0, height: 30.0), + transition: .immediate + ) + context.add(closeButton + .position(CGPoint(x: context.availableSize.width - closeButton.size.width, y: 28.0)) + ) + + let constrainedTitleWidth = context.availableSize.width - 16.0 * 2.0 + + contentSize.height += 124.0 + + let title = title.update( + component: Text(text: strings.AgeVerification_Title, font: Font.bold(24.0), color: theme.list.itemPrimaryTextColor), + availableSize: CGSize(width: constrainedTitleWidth, height: context.availableSize.height), + transition: .immediate + ) + context.add(title + .position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + title.size.height / 2.0)) + ) + contentSize.height += title.size.height + contentSize.height += 13.0 + + let textFont = Font.regular(15.0) + let boldTextFont = Font.semibold(15.0) + let textColor = theme.actionSheet.primaryTextColor + let linkColor = theme.actionSheet.controlAccentColor + let markdownAttributes = MarkdownAttributes(body: MarkdownAttributeSet(font: textFont, textColor: textColor), bold: MarkdownAttributeSet(font: boldTextFont, textColor: textColor), link: MarkdownAttributeSet(font: textFont, textColor: linkColor), linkAttribute: { contents in + return (TelegramTextAttributes.URL, contents) + }) + + var textString = environment.strings.AgeVerification_Text + if let code = component.context.currentAppConfiguration.with({ $0 }).data?["verify_age_country"] as? String { + let key = "AgeVerification.Text.\(code)" + if let string = environment.strings.primaryComponent.dict[key] { + textString = string + } + } + + let text = text.update( + component: BalancedTextComponent( + text: .markdown( + text: textString, + attributes: markdownAttributes + ), + horizontalAlignment: .center, + maximumNumberOfLines: 0, + lineSpacing: 0.2 + ), + availableSize: CGSize(width: constrainedTitleWidth, height: context.availableSize.height), + transition: .immediate + ) + context.add(text + .position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + text.size.height / 2.0)) + ) + contentSize.height += text.size.height + contentSize.height += 23.0 + + let controller = environment.controller() as? AgeVerificationScreen + + let button = button.update( + component: ButtonComponent( + background: ButtonComponent.Background( + color: theme.list.itemCheckColors.fillColor, + foreground: theme.list.itemCheckColors.foregroundColor, + pressedColor: theme.list.itemCheckColors.fillColor.withMultipliedAlpha(0.9), + cornerRadius: 10.0 + ), + content: AnyComponentWithIdentity( + id: AnyHashable(0), + component: AnyComponent(MultilineTextComponent(text: .plain(NSMutableAttributedString(string: strings.AgeVerification_Verify, font: Font.semibold(17.0), textColor: theme.list.itemCheckColors.foregroundColor, paragraphAlignment: .center)))) + ), + isEnabled: true, + displaysProgress: false, + action: { [weak controller] in + controller?.complete(result: true) + } + ), + availableSize: CGSize(width: context.availableSize.width - 16.0 * 2.0, height: 50), + transition: .immediate + ) + context.add(button + .clipsToBounds(true) + .cornerRadius(10.0) + .position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + button.size.height / 2.0)) + ) + contentSize.height += button.size.height + + contentSize.height += 48.0 + + return contentSize + } + } +} + +private final class AgeVerificationSheetComponent: CombinedComponent { + typealias EnvironmentType = ViewControllerComponentContainer.Environment + + private let context: AccountContext + + init( + context: AccountContext + ) { + self.context = context + } + + static func ==(lhs: AgeVerificationSheetComponent, rhs: AgeVerificationSheetComponent) -> Bool { + if lhs.context !== rhs.context { + return false + } + return true + } + + static var body: Body { + let sheet = Child(SheetComponent<(EnvironmentType)>.self) + let animateOut = StoredActionSlot(Action.self) + + return { context in + let environment = context.environment[EnvironmentType.self] + + let controller = environment.controller + + let sheet = sheet.update( + component: SheetComponent( + content: AnyComponent(SheetContent( + context: context.component.context, + dismiss: { + animateOut.invoke(Action { _ in + if let controller = controller() as? AgeVerificationScreen { + controller.complete(result: false) + controller.dismiss(completion: nil) + } + }) + } + )), + backgroundColor: .color(environment.theme.list.modalBlocksBackgroundColor), + followContentSizeChanges: true, + clipsContent: true, + animateOut: animateOut + ), + environment: { + environment + SheetComponentEnvironment( + isDisplaying: environment.value.isVisible, + isCentered: environment.metrics.widthClass == .regular, + hasInputHeight: !environment.inputHeight.isZero, + regularMetricsSize: CGSize(width: 430.0, height: 900.0), + dismiss: { animated in + if animated { + animateOut.invoke(Action { _ in + if let controller = controller() as? AgeVerificationScreen { + controller.complete(result: false) + controller.dismiss(completion: nil) + } + }) + } else { + if let controller = controller() as? AgeVerificationScreen { + controller.complete(result: false) + controller.dismiss(completion: nil) + } + } + } + ) + }, + availableSize: context.availableSize, + transition: context.transition + ) + + context.add(sheet + .position(CGPoint(x: context.availableSize.width / 2.0, y: context.availableSize.height / 2.0)) + ) + + return context.availableSize + } + } +} + +public final class AgeVerificationScreen: ViewControllerComponentContainer { + private let context: AccountContext + private let completion: (Bool, Signal) -> Void + + private let promise = Promise() + + public init( + context: AccountContext, + completion: @escaping (Bool, Signal) -> Void + ) { + self.context = context + self.completion = completion + + self.promise.set(ageVerificationAvailability(context: context)) + + super.init( + context: context, + component: AgeVerificationSheetComponent( + context: context + ), + navigationBarAppearance: .none, + statusBarStyle: .ignore, + theme: .default + ) + + self.navigationPresentation = .flatModal + } + + required public init(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private var didComplete = false + fileprivate func complete(result: Bool) { + guard !self.didComplete else { + return + } + + if result { + let context = self.context + let presentationData = context.sharedContext.currentPresentationData.with { $0 } + DeviceAccess.authorizeAccess(to: .camera(.ageVerification), presentationData: presentationData, present: { c, a in + c.presentationArguments = a + context.sharedContext.mainWindow?.present(c, on: .root) + }, openSettings: { + context.sharedContext.applicationBindings.openSettings() + }, { [weak self] granted in + guard let self, granted else { + return + } + self.didComplete = true + self.completion(true, self.promise.get()) + self.dismissAnimated() + }) + } else { + self.didComplete = true + self.completion(false, self.promise.get()) + } + } + + public func dismissAnimated() { + if let view = self.node.hostView.findTaggedView(tag: SheetComponent.View.Tag()) as? SheetComponent.View { + view.dismissAnimated() + } + } +} + +func generateCloseButtonImage(backgroundColor: UIColor, foregroundColor: UIColor) -> UIImage? { + return generateImage(CGSize(width: 30.0, height: 30.0), contextGenerator: { size, context in + context.clear(CGRect(origin: CGPoint(), size: size)) + + context.setFillColor(backgroundColor.cgColor) + context.fillEllipse(in: CGRect(origin: CGPoint(), size: size)) + + context.setLineWidth(2.0) + context.setLineCap(.round) + context.setStrokeColor(foregroundColor.cgColor) + + context.move(to: CGPoint(x: 10.0, y: 10.0)) + context.addLine(to: CGPoint(x: 20.0, y: 20.0)) + context.strokePath() + + context.move(to: CGPoint(x: 20.0, y: 10.0)) + context.addLine(to: CGPoint(x: 10.0, y: 20.0)) + context.strokePath() + }) +} + +public func presentAgeVerification(context: AccountContext, parentController: ViewController, completion: @escaping () -> Void) { + let presentationData = context.sharedContext.currentPresentationData.with { $0 } + let _ = (context.engine.data.get( + TelegramEngine.EngineData.Item.Configuration.ApplicationSpecificPreference(key: ApplicationSpecificPreferencesKeys.ageVerificationState) + ) |> deliverOnMainQueue).start(next: { [weak parentController] ageVerificationStatePreference in + let state = ageVerificationStatePreference?.get(AgeVerificationState.self) ?? AgeVerificationState.default + if state.verificationPassed { + completion() + } else { + let infoScreen = AgeVerificationScreen(context: context, completion: { [weak parentController] check, availability in + if check { + let scanScreen = FaceScanScreen(context: context, availability: availability, completion: { [weak parentController] passed in + if passed { + let _ = updateAgeVerificationState(engine: context.engine, { _ in + return AgeVerificationState(verificationPassed: passed) + }).start() + completion() + + let navigationController = parentController?.navigationController + Queue.mainQueue().after(2.0) { + let controller = UndoOverlayController(presentationData: presentationData, content: .actionSucceeded(title: presentationData.strings.AgeVerification_Success_Title, text: presentationData.strings.AgeVerification_Success_Text, cancel: nil, destructive: false), action: { _ in return true }) + (navigationController?.viewControllers.last as? ViewController)?.present(controller, in: .window(.root)) + } + } else { + let controller = UndoOverlayController(presentationData: presentationData, content: .universal(animation: "anim_banned", scale: 0.066, colors: [:], title: presentationData.strings.AgeVerification_Fail_Title, text: presentationData.strings.AgeVerification_Fail_Text, customUndoText: nil, timeout: nil), action: { _ in return true }) + parentController?.present(controller, in: .window(.root)) + } + }) + parentController?.push(scanScreen) + } + }) + parentController?.push(infoScreen) + } + }) +} + +public func updateAgeVerificationState(engine: TelegramEngine, _ f: @escaping (AgeVerificationState) -> AgeVerificationState) -> Signal { + return engine.preferences.update(id: ApplicationSpecificPreferencesKeys.ageVerificationState, { entry in + let currentSettings: AgeVerificationState + if let entry = entry?.get(AgeVerificationState.self) { + currentSettings = entry + } else { + currentSettings = .default + } + return SharedPreferencesEntry(f(currentSettings)) + }) +} + +public struct AgeVerificationState: Equatable, Codable { + public var verificationPassed: Bool + + public static var `default`: AgeVerificationState { + return AgeVerificationState(verificationPassed: false) + } + + public init(verificationPassed: Bool) { + self.verificationPassed = verificationPassed + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: StringCodingKey.self) + + self.verificationPassed = (try container.decode(Int32.self, forKey: "verificationPassed")) != 0 + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: StringCodingKey.self) + + try container.encode((self.verificationPassed ? 1 : 0) as Int32, forKey: "verificationPassed") + } +} diff --git a/submodules/TelegramUI/Components/FaceScanScreen/Sources/FaceScanScreen.swift b/submodules/TelegramUI/Components/FaceScanScreen/Sources/FaceScanScreen.swift new file mode 100644 index 0000000000..334469152d --- /dev/null +++ b/submodules/TelegramUI/Components/FaceScanScreen/Sources/FaceScanScreen.swift @@ -0,0 +1,794 @@ +import Foundation +import UIKit +import AccountContext +import AsyncDisplayKit +import Display +import SwiftSignalKit +import Camera +import CoreImage +import TelegramPresentationData +import TelegramCore +import Markdown +import TextFormat +import PresentationDataUtils +import ComponentFlow +import ViewControllerComponent +import Vision +import AVFoundation +import AppBundle +import ZipArchive +import PlainButtonComponent +import MultilineTextComponent + +private let requiredAge = 18 + +final class FaceScanScreenComponent: Component { + typealias EnvironmentType = ViewControllerComponentContainer.Environment + + let context: AccountContext + let availability: Signal + + init( + context: AccountContext, + availability: Signal + ) { + self.context = context + self.availability = availability + } + + static func ==(lhs: FaceScanScreenComponent, rhs: FaceScanScreenComponent) -> Bool { + return true + } + + final class View: UIView, AVCaptureVideoDataOutputSampleBufferDelegate { + private let captureSession = AVCaptureSession() + private lazy var previewLayer = AVCaptureVideoPreviewLayer(session: self.captureSession) + private let videoDataOutput = AVCaptureVideoDataOutput() + private let videoDataOutputQueue = DispatchQueue(label: "VideoDataOutput", qos: .userInitiated, attributes: [], autoreleaseFrequency: .workItem) + private var faceDetectionRequest: VNDetectFaceRectanglesRequest! + + private let overlayView = UIView() + private let cutoutLayer = CAShapeLayer() + private let frameView = FrameView() + + private let numberOfStripes = 16 + private var completedAngles: Set = [] + + private let instruction = ComponentView() + private let cancel = ComponentView() + + private var currentFaceImage: CIImage? + + private enum State { + case waitingForFace + case positioning + case readyToStart + case tracking + case completed + } + private var processState: State = .waitingForFace + private var transitioningToViewFinder = false + + private var lastFaceYaw: NSNumber? + private var lastFacePitch: NSNumber? + private var lastFaceRoll: NSNumber? + private var centerYaw: Double = 0 + private var centerPitch: Double = 0 + private var centerRoll: Double = 0 + private let motionThreshold: Double = 0.07 + + private var faceDetectionTimer: Foundation.Timer? + private var segmentTimer: Foundation.Timer? + private var currentSegment: Int? + private let segmentDwellTime: TimeInterval = 0.05 + private let positioningTime: TimeInterval = 1.0 + + private var horizontalGuideLines: [CAShapeLayer] = [] + private var verticalGuideLines: [CAShapeLayer] = [] + private let maxGuideLines = 30 + private let guideLineFadeDuration: TimeInterval = 0.3 + private var lastGuideLineUpdate: Date = Date() + private let guideLineUpdateInterval: TimeInterval = 0.01 + private var lastGuideLineYaw: Double = 0 + private var lastGuideLinePitch: Double = 0 + private let angleThreshold: Double = 0.01 + + private var ageModel: VNCoreMLModel? + private var ages: [Double] = [] + + private var availabilityDisposable: Disposable? + + private var isUpdating: Bool = false + + private var component: FaceScanScreenComponent? + private(set) weak var state: EmptyComponentState? + private var environment: EnvironmentType? + + override init(frame: CGRect) { + super.init(frame: frame) + + self.backgroundColor = .black + + self.previewLayer.backgroundColor = UIColor.red.cgColor + self.previewLayer.videoGravity = .resizeAspectFill + self.layer.addSublayer(previewLayer) + + self.overlayView.backgroundColor = UIColor.black + self.addSubview(overlayView) + + self.cutoutLayer.fillRule = .evenOdd + self.overlayView.layer.mask = self.cutoutLayer + + self.addSubview(self.frameView) + } + + required init?(coder: NSCoder) { + preconditionFailure() + } + + deinit { + self.availabilityDisposable?.dispose() + } + + private func setupModel(availability: Signal) { + self.availabilityDisposable = (availability + |> deliverOnMainQueue).start(next: { [weak self] availability in + guard let self else { + return + } + if case let .available(path, isLegacy) = availability { + if isLegacy { + if let model = try? AgeNetLegacy(contentsOf: URL(fileURLWithPath: path)).model { + self.ageModel = try? VNCoreMLModel(for: model) + } + } else if #available(iOS 15.0, *) { + if let model = try? AgeNet(contentsOf: URL(fileURLWithPath: path)).model { + self.ageModel = try? VNCoreMLModel(for: model) + } + } + } + }) + } + + private func extractFaceImage(from pixelBuffer: CVPixelBuffer, faceObservation: VNFaceObservation) -> CIImage? { + let ciImage = CIImage(cvPixelBuffer: pixelBuffer) + let imageSize = ciImage.extent.size + let visionRect = faceObservation.boundingBox + + let boundingBox = CGRect(x: 1.0 - visionRect.maxY, y: 1.0 - visionRect.maxX, width: visionRect.height, height: visionRect.width) + let faceRect = CGRect( + x: boundingBox.minX * imageSize.width, + y: boundingBox.minY * imageSize.height, + width: boundingBox.width * imageSize.width, + height: boundingBox.height * imageSize.height + ) + + let padding: CGFloat = 0.1 + let paddingX = faceRect.width * padding + let paddingY = faceRect.height * padding + + let paddedRect = CGRect( + x: max(0, faceRect.minX - paddingX), + y: max(0, faceRect.minY - paddingY), + width: min(imageSize.width - max(0, faceRect.minX - paddingX), faceRect.width + 2 * paddingX), + height: min(imageSize.height - max(0, faceRect.minY - paddingY), faceRect.height + 2 * paddingY) + ) + + let croppedImage = ciImage.cropped(to: paddedRect) + let rotatedImage = croppedImage.oriented(.leftMirrored) + + return rotatedImage + } + + private func setupCamera() { + guard let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .front) else { + return + } + + do { + let input = try AVCaptureDeviceInput(device: device) + + self.captureSession.beginConfiguration() + self.captureSession.sessionPreset = .high + + if self.captureSession.canAddInput(input) { + self.captureSession.addInput(input) + } + + if self.captureSession.canAddOutput(self.videoDataOutput) { + self.captureSession.addOutput(self.videoDataOutput) + self.videoDataOutput.alwaysDiscardsLateVideoFrames = true + self.videoDataOutput.setSampleBufferDelegate(self, queue: self.videoDataOutputQueue) + } + + self.captureSession.commitConfiguration() + } catch { + print("Failed to setup camera: \(error)") + } + + Queue.concurrentDefaultQueue().async { + self.captureSession.startRunning() + } + } + + private func setupVision() { + self.faceDetectionRequest = VNDetectFaceRectanglesRequest { [weak self] request, error in + guard error == nil else { return } + Queue.mainQueue().async { + self?.handleFaceDetection(request) + } + } + } + + private func handleFaceDetection(_ request: VNRequest) { + guard #available(iOS 15.0, *) else { + return + } + guard let observations = request.results as? [VNFaceObservation], let face = observations.first else { + if self.processState == .tracking || self.processState == .readyToStart { + self.resetTracking() + } + self.currentFaceImage = nil + return + } + + guard let yaw = face.yaw, + let pitch = face.pitch, + let roll = face.roll else { return } + + let faceCenter = CGPoint(x: face.boundingBox.midX, y: face.boundingBox.midY) + let targetCenter = CGPoint(x: 0.5, y: 0.5) + let distance = sqrt(pow(faceCenter.x - targetCenter.x, 2) + pow(faceCenter.y - targetCenter.y, 2)) + + if distance < 0.35 { + switch processState { + case .waitingForFace: + self.processState = .positioning + self.faceDetectionTimer = Timer.scheduledTimer(withTimeInterval: self.positioningTime, repeats: false) { [weak self] _ in + self?.processState = .readyToStart + + self?.state?.updated(transition: .spring(duration: 0.3)) + } + case .positioning: + break + case .readyToStart: + self.centerYaw = yaw.doubleValue + self.centerPitch = pitch.doubleValue + self.centerRoll = roll.doubleValue + self.processState = .tracking + case .tracking: + self.trackHeadOrientation(yaw: yaw, pitch: pitch, roll: roll) + case .completed: + break + } + } else if self.processState == .tracking { + self.resetTracking() + } + } + + private func trackHeadOrientation(yaw: NSNumber, pitch: NSNumber, roll: NSNumber) { + let relativeYaw = yaw.doubleValue - self.centerYaw + let relativePitch = pitch.doubleValue - self.centerPitch + + //self.updateTrailingGuideLines(yaw: relativeYaw, pitch: relativePitch) + + if let lastYaw = self.lastFaceYaw, let lastPitch = self.lastFacePitch { + let yawChange = abs(yaw.doubleValue - lastYaw.doubleValue) + let pitchChange = abs(pitch.doubleValue - lastPitch.doubleValue) + + if yawChange < 0.02 && pitchChange < 0.02 { + return + } + } + + let mirroredYaw = -relativeYaw + let flippedPitch = relativePitch + + let angle = atan2(mirroredYaw, flippedPitch) + .pi + let normalizedAngle = angle / (2 * .pi) + let segmentIndex = Int(normalizedAngle * Double(self.numberOfStripes)) % self.numberOfStripes + + let rotationMagnitude = sqrt(relativeYaw * relativeYaw + relativePitch * relativePitch) + if rotationMagnitude > self.motionThreshold { + if self.currentSegment != segmentIndex { + self.currentSegment = segmentIndex + + self.segmentTimer?.invalidate() + self.segmentTimer = Timer.scheduledTimer(withTimeInterval: self.segmentDwellTime, repeats: false) { _ in + self.fillSegment(segmentIndex) + } + } + } + + self.lastFaceYaw = yaw + self.lastFacePitch = pitch + self.lastFaceRoll = roll + } + + private func fillSegment(_ segmentIndex: Int) { + guard !self.completedAngles.contains(segmentIndex) else { + return + } + self.completedAngles.insert(segmentIndex) + + if self.completedAngles.count >= self.numberOfStripes { + Queue.mainQueue().after(0.3, { + self.processState = .completed + self.state?.updated(transition: .spring(duration: 0.3)) + + Queue.mainQueue().after(1.0) { + if !self.ages.isEmpty { + let averageAge = self.ages.reduce(0, +) / Double(self.ages.count) + if let environment = self.environment, let controller = environment.controller() as? FaceScanScreen { + controller.completion(averageAge >= Double(requiredAge)) + controller.dismiss(animated: true) + } + } else { + self.completedAngles.removeAll() + self.processState = .tracking + self.state?.updated(transition: .spring(duration: 0.3)) + } + } + }) + } + self.state?.updated(transition: .spring(duration: 0.3)) + } + + private func resetTracking() { + self.faceDetectionTimer?.invalidate() + self.segmentTimer?.invalidate() + + if self.processState != .waitingForFace && self.processState != .positioning { + self.transitioningToViewFinder = true + } + self.processState = .waitingForFace + self.completedAngles.removeAll() + self.currentSegment = nil + self.lastFaceYaw = nil + self.lastFacePitch = nil + self.lastFaceRoll = nil + + self.currentFaceImage = nil + + self.state?.updated(transition: .spring(duration: 0.3)) + } + + private var tick = 0 + func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) { + guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return } + + let requestHandler = VNImageRequestHandler(cvPixelBuffer: pixelBuffer, orientation: .leftMirrored) + do { + try requestHandler.perform([self.faceDetectionRequest]) + + if let observations = self.faceDetectionRequest.results, let faceObservation = observations.first { + tick += 1 + if tick < 33 { + return + } + tick = 0 + + self.currentFaceImage = self.extractFaceImage(from: pixelBuffer, faceObservation: faceObservation) + self.processFace() + } + } catch { + print("Failed to perform request: \(error)") + } + } + + public func processFace() { + guard let faceImage = self.currentFaceImage else { + return + } + + guard let model = self.ageModel else { + return + } + + let request = VNCoreMLRequest(model: model) { [weak self] request, error in + if let results = request.results as? [VNCoreMLFeatureValueObservation], let ageObservation = results.last { + let age = ageObservation.featureValue.multiArrayValue?[0].doubleValue ?? 0 + + Queue.mainQueue().async { + self?.ages.append(age) + } + } + } + + let handler = VNImageRequestHandler(ciImage: faceImage) + DispatchQueue.global(qos: .userInteractive).async { + do { + try handler.perform([request]) + } catch { + print(error) + } + } + } + + func update(component: FaceScanScreenComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + self.isUpdating = true + defer { + self.isUpdating = false + } + + if self.component == nil { + self.setupModel(availability: component.availability) + self.setupCamera() + self.setupVision() + } + + self.component = component + self.state = state + let environment = environment[EnvironmentType.self].value + self.environment = environment + + let theme = environment.theme + let strings = environment.strings + + self.overlayView.frame = CGRect(origin: .zero, size: availableSize) + self.cutoutLayer.frame = CGRect(origin: .zero, size: availableSize) + + let path = CGMutablePath(rect: overlayView.bounds, transform: nil) + let radius: CGFloat = 130.0 + + let widthRadius = ceil(radius * 1.05) + let heightRadius = widthRadius //radius //floor(widthRadius * 1.17778) + + let center = CGPoint(x: availableSize.width / 2, y: environment.statusBarHeight + 10.0 + widthRadius * 1.3) + + var previewScale = 1.0 + if self.processState == .tracking || self.processState == .readyToStart || self.processState == .completed || self.transitioningToViewFinder { + let circlePath = CGPath(roundedRect: CGRect(x: center.x - radius, y: center.y - radius, width: radius * 2, height: radius * 2), cornerWidth: radius, cornerHeight: radius, transform: nil) + path.addPath(circlePath) + + previewScale = 0.75 + } else { + let rectanglePath = CGPath(roundedRect: CGRect(x: center.x - widthRadius, y: center.y - heightRadius, width: widthRadius * 2, height: heightRadius * 2), cornerWidth: 20, cornerHeight: 20, transform: nil) + path.addPath(rectanglePath) + } + transition.setShapeLayerPath(layer: self.cutoutLayer, path: path) + + self.previewLayer.bounds = CGRect(origin: .zero, size: availableSize) + self.previewLayer.position = CGPoint(x: availableSize.width / 2.0, y: availableSize.height / 2.0 - 200) + transition.setTransform(layer: self.previewLayer, transform: CATransform3DMakeScale(previewScale, previewScale, 1.0)) + + let frameViewSize = CGSize(width: 330.0, height: 330.0) + let frameViewFrame = CGRect(x: (availableSize.width - frameViewSize.width) / 2.0, y: center.y - frameViewSize.height * 0.5, width: frameViewSize.width, height: frameViewSize.height) + self.frameView.frame = frameViewFrame + self.frameView.update(size: frameViewFrame.size) + + //TODO:localize + var instructionString = environment.strings.FaceScan_Instruction_Position + switch self.processState { + case .waitingForFace, .positioning: + self.frameView.update(state: .viewFinder, intermediateCompletion: { [weak self] in + if let self { + self.transitioningToViewFinder = false + self.state?.updated(transition: .spring(duration: 0.3)) + } + }, transition: .spring(duration: 0.3)) + instructionString = environment.strings.FaceScan_Instruction_Position + case .readyToStart: + self.frameView.update(state: .segments(Set()), transition: .spring(duration: 0.3)) + instructionString = environment.strings.FaceScan_Instruction_Rotate + case .tracking: + self.frameView.update(state: .segments(self.completedAngles), transition: .spring(duration: 0.3)) + instructionString = environment.strings.FaceScan_Instruction_Rotate + case .completed: + self.frameView.update(state: .success, transition: .spring(duration: 0.3)) + instructionString = "" + } + + let instructionSize = self.instruction.update( + transition: .immediate, + component: AnyComponent( + MultilineTextComponent( + text: .plain(NSAttributedString(string: instructionString, font: Font.semibold(20.0), textColor: .white)), + horizontalAlignment: .center, + maximumNumberOfLines: 3, + lineSpacing: 0.1 + ) + ), + environment: {}, + containerSize: availableSize + ) + let instructionFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((availableSize.width - instructionSize.width) / 2.0), y: 484.0), size: instructionSize) + if let instructionView = self.instruction.view { + if instructionView.superview == nil { + self.addSubview(instructionView) + } + instructionView.frame = instructionFrame + } + + let cancelSize = self.cancel.update( + transition: .immediate, + component: AnyComponent( + PlainButtonComponent( + content: AnyComponent( + MultilineTextComponent( + text: .plain(NSAttributedString(string: strings.Common_Cancel, font: Font.regular(17.0), textColor: theme.list.itemAccentColor)) + ) + ), + action: { [weak self] in + guard let self, let environment = self.environment, let controller = environment.controller() as? FaceScanScreen else { + return + } + controller.dismiss(animated: true) + }, + animateScale: false + ) + ), + environment: {}, + containerSize: availableSize + ) + let cancelFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((availableSize.width - cancelSize.width) / 2.0), y: availableSize.height - cancelSize.height - environment.safeInsets.bottom - 22.0), size: cancelSize) + if let cancelView = self.cancel.view { + if cancelView.superview == nil { + self.addSubview(cancelView) + } + cancelView.frame = cancelFrame + transition.setAlpha(view: cancelView, alpha: self.processState == .completed ? 0.0 : 1.0) + } + + return availableSize + } + } + + 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 FaceScanScreen: ViewControllerComponentContainer { + private let context: AccountContext + fileprivate let completion: (Bool) -> Void + + public init( + context: AccountContext, + availability: Signal, + completion: @escaping (Bool) -> Void + ) { + self.context = context + self.completion = completion + + super.init(context: context, component: FaceScanScreenComponent( + context: context, + availability: availability + ), navigationBarAppearance: .none, theme: .default, updatedPresentationData: nil) + + self.title = "" + + self.statusBar.statusBarStyle = .White + self.navigationPresentation = .standaloneFlatModal + + self.supportedOrientations = ViewControllerSupportedOrientations(regularSize: .all, compactSize: .portrait) + } + + required public init(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + self.animateIn() + } + + private func animateIn() { + guard let layout = self.validLayout else { + return + } + self.view.clipsToBounds = true + self.view.layer.cornerRadius = layout.deviceMetrics.screenCornerRadius + + self.view.layer.animatePosition(from: CGPoint(x: self.view.layer.position.x, y: self.view.layer.position.y + self.view.layer.bounds.size.height), to: self.view.layer.position, duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, completion: { _ in + self.view.clipsToBounds = false + }) + } + + private func animateOut(completion: (() -> Void)? = nil) { + guard let layout = self.validLayout else { + return + } + self.view.clipsToBounds = true + self.view.layer.cornerRadius = layout.deviceMetrics.screenCornerRadius + + self.view.layer.animatePosition(from: self.view.layer.position, to: CGPoint(x: self.view.layer.position.x, y: self.view.layer.position.y + self.view.layer.bounds.size.height), duration: 0.2, timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, removeOnCompletion: false, completion: { _ in + completion?() + }) + } + + public override func dismiss(animated flag: Bool, completion: (() -> Void)? = nil) { + if flag { + self.animateOut(completion: { + super.dismiss(animated: false, completion: completion) + }) + } else { + super.dismiss(animated: flag, completion: completion) + } + } +} + +extension FaceScanScreenComponent.View { + private func updateTrailingGuideLines(yaw: Double, pitch: Double) { + let now = Date() + + guard now.timeIntervalSince(lastGuideLineUpdate) >= guideLineUpdateInterval else { + return + } + self.lastGuideLineUpdate = now + + let radius: CGFloat = 128.0 + let center = CGPoint(x: self.bounds.width / 2, y: self.frameView.center.y) + + let maxRotation: Double = 0.5 + let normalizedYaw = min(max(yaw / maxRotation, -1.0), 1.0) * 1.5 + let normalizedPitch = min(max(pitch / maxRotation, -1.0), 1.0) * 1.5 + + let yawChange = abs(yaw - lastGuideLineYaw) + let pitchChange = abs(pitch - lastGuideLinePitch) + + let rotationMagnitude = sqrt(yaw * yaw + pitch * pitch) + if rotationMagnitude > 0.01 && (yawChange > angleThreshold || pitchChange > angleThreshold) { + if abs(pitch) > 0.01 { + createHorizontalGuideLine(center: center, radius: radius, curvature: normalizedPitch) + } + + if abs(yaw) > 0.01 { + createVerticalGuideLine(center: center, radius: radius, curvature: normalizedYaw) + } + + lastGuideLineYaw = yaw + lastGuideLinePitch = pitch + } + + cleanupOldGuideLines() + } + + private func createHorizontalGuideLine(center: CGPoint, radius: CGFloat, curvature: Double) { + let lineLayer = CAShapeLayer() + let path = createCurvedHorizontalPath(center: center, radius: radius, curvature: curvature) + + lineLayer.path = path.cgPath + lineLayer.strokeColor = UIColor(rgb: 0xb4f5ff).cgColor + lineLayer.lineWidth = 3.0 + lineLayer.fillColor = UIColor.clear.cgColor + lineLayer.lineCap = .round + lineLayer.opacity = 0.3 + + lineLayer.shadowColor = UIColor(rgb: 0xb4f5ff).cgColor + lineLayer.shadowRadius = 2 + lineLayer.shadowOpacity = 0.5 + lineLayer.shadowOffset = .zero + + self.layer.addSublayer(lineLayer) + horizontalGuideLines.append(lineLayer) + + animateLinefadeOut(lineLayer) + + DispatchQueue.main.asyncAfter(deadline: .now() + guideLineFadeDuration + 0.1) { [weak self] in + if let index = self?.horizontalGuideLines.firstIndex(of: lineLayer) { + self?.horizontalGuideLines.remove(at: index) + } + lineLayer.removeFromSuperlayer() + } + } + + private func createVerticalGuideLine(center: CGPoint, radius: CGFloat, curvature: Double) { + let lineLayer = CAShapeLayer() + let path = createCurvedVerticalPath(center: center, radius: radius, curvature: curvature) + + lineLayer.path = path.cgPath + lineLayer.strokeColor = UIColor(rgb: 0xb4f5ff).cgColor + lineLayer.lineWidth = 3.0 + lineLayer.fillColor = UIColor.clear.cgColor + lineLayer.lineCap = .round + lineLayer.opacity = 0.3 + + lineLayer.shadowColor = UIColor(rgb: 0xb4f5ff).cgColor + lineLayer.shadowRadius = 2 + lineLayer.shadowOpacity = 0.5 + lineLayer.shadowOffset = .zero + + self.layer.addSublayer(lineLayer) + verticalGuideLines.append(lineLayer) + + animateLinefadeOut(lineLayer) + + DispatchQueue.main.asyncAfter(deadline: .now() + guideLineFadeDuration + 0.1) { [weak self] in + if let index = self?.verticalGuideLines.firstIndex(of: lineLayer) { + self?.verticalGuideLines.remove(at: index) + } + lineLayer.removeFromSuperlayer() + } + } + + private func createCurvedHorizontalPath(center: CGPoint, radius: CGFloat, curvature: Double) -> UIBezierPath { + let path = UIBezierPath() + + let startPoint = CGPoint(x: center.x - radius, y: center.y) + let endPoint = CGPoint(x: center.x + radius, y: center.y) + + if abs(curvature) < 0.05 { + path.move(to: startPoint) + path.addLine(to: endPoint) + } else { + let curveOffset = CGFloat(curvature) * radius * 0.6 + let controlPoint = CGPoint(x: center.x, y: center.y + curveOffset) + + path.move(to: startPoint) + path.addQuadCurve(to: endPoint, controlPoint: controlPoint) + } + + return path + } + + private func createCurvedVerticalPath(center: CGPoint, radius: CGFloat, curvature: Double) -> UIBezierPath { + let path = UIBezierPath() + + let startPoint = CGPoint(x: center.x, y: center.y - radius) + let endPoint = CGPoint(x: center.x, y: center.y + radius) + + if abs(curvature) < 0.05 { + path.move(to: startPoint) + path.addLine(to: endPoint) + } else { + let curveOffset = CGFloat(curvature) * radius * 0.6 + let controlPoint = CGPoint(x: center.x + curveOffset, y: center.y) + + path.move(to: startPoint) + path.addQuadCurve(to: endPoint, controlPoint: controlPoint) + } + + return path + } + + private func animateLinefadeOut(_ layer: CAShapeLayer) { + let opacityAnimation = CABasicAnimation(keyPath: "opacity") + opacityAnimation.fromValue = 0.5 + opacityAnimation.toValue = 0.0 + opacityAnimation.duration = guideLineFadeDuration + opacityAnimation.timingFunction = CAMediaTimingFunction(name: .easeOut) + opacityAnimation.fillMode = .forwards + opacityAnimation.isRemovedOnCompletion = false + + let shadowAnimation = CABasicAnimation(keyPath: "shadowOpacity") + shadowAnimation.fromValue = 0.5 + shadowAnimation.toValue = 0.0 + shadowAnimation.duration = guideLineFadeDuration + shadowAnimation.timingFunction = CAMediaTimingFunction(name: .easeOut) + shadowAnimation.fillMode = .forwards + shadowAnimation.isRemovedOnCompletion = false + + layer.add(opacityAnimation, forKey: "fadeOut") + layer.add(shadowAnimation, forKey: "shadowFadeOut") + } + + private func cleanupOldGuideLines() { + while horizontalGuideLines.count > maxGuideLines { + let oldestLine = horizontalGuideLines.removeFirst() + oldestLine.removeFromSuperlayer() + } + + while verticalGuideLines.count > maxGuideLines { + let oldestLine = verticalGuideLines.removeFirst() + oldestLine.removeFromSuperlayer() + } + } + + private func clearAllGuideLines() { + for line in horizontalGuideLines { + line.removeFromSuperlayer() + } + self.horizontalGuideLines.removeAll() + + for line in verticalGuideLines { + line.removeFromSuperlayer() + } + self.verticalGuideLines.removeAll() + } +} diff --git a/submodules/TelegramUI/Components/FaceScanScreen/Sources/FrameView.swift b/submodules/TelegramUI/Components/FaceScanScreen/Sources/FrameView.swift new file mode 100644 index 0000000000..d6bf89770b --- /dev/null +++ b/submodules/TelegramUI/Components/FaceScanScreen/Sources/FrameView.swift @@ -0,0 +1,412 @@ +import UIKit +import Display +import ComponentFlow + +final class FrameView: UIView { + enum State: Equatable { + case viewFinder + case segments(Set) + case success + case failure + } + + private let viewFinderLayer = ViewFinderLayer() + private let transitionLayer = TransitionLayer() + private let segmentsLayer = SegmentsLayer() + + private var currentState: State = .viewFinder + private var scheduledState: State? + private var isTransitioning = false + + private var currentLayout: CGSize? + + override init(frame: CGRect) { + super.init(frame: frame) + + self.backgroundColor = .clear + + self.transitionLayer.isHidden = true + self.segmentsLayer.isHidden = true + + self.layer.addSublayer(self.viewFinderLayer) + self.layer.addSublayer(self.transitionLayer) + self.layer.addSublayer(self.segmentsLayer) + } + + required init?(coder: NSCoder) { + preconditionFailure() + } + + func update(state: State, intermediateCompletion: (() -> Void)? = nil, transition: ComponentTransition) { + guard !self.isTransitioning else { + self.scheduledState = state + return + } + + let previousState = self.currentState + self.currentState = state + + switch state { + case .viewFinder: + switch previousState { + case .viewFinder: + break + case .segments: + self.isTransitioning = true + self.segmentsLayer.animateOut(transition: transition) { + self.segmentsLayer.isHidden = true + self.transitionLayer.isHidden = false + self.transitionLayer.animateOut(transition: transition) { + self.transitionLayer.isHidden = true + self.viewFinderLayer.isHidden = false + intermediateCompletion?() + self.viewFinderLayer.animateIn(transition: transition) { + self.isTransitioning = false + self.maybeApplyScheduledState() + } + } + } + case .success: + break + case .failure: + break + } + case let .segments(segments): + switch previousState { + case .viewFinder: + self.isTransitioning = true + self.viewFinderLayer.animateOut(transition: transition) { + self.viewFinderLayer.isHidden = true + self.transitionLayer.isHidden = false + self.transitionLayer.animateIn(transition: transition) { + self.transitionLayer.isHidden = true + self.segmentsLayer.isHidden = false + self.segmentsLayer.animateIn (transition: transition) { + self.isTransitioning = false + self.maybeApplyScheduledState() + } + } + } + case .segments: + self.segmentsLayer.update(segments: segments, transition: transition) + case .success: + break + case .failure: + break + } + case .success: + self.isTransitioning = true + self.segmentsLayer.animateOut(transition: transition) { + self.segmentsLayer.isHidden = true + self.transitionLayer.isHidden = false + self.transitionLayer.update(color: UIColor(rgb: 0x65c466)) + self.transitionLayer.animateOut(transition: transition) { + self.isTransitioning = false + self.maybeApplyScheduledState() + } + } + case .failure: + break + } + } + + func maybeApplyScheduledState() { + if !self.isTransitioning, let state = self.scheduledState { + self.scheduledState = nil + self.update(state: state, transition: .spring(duration: 0.3)) + } + } + + func update(size: CGSize) { + guard self.currentLayout != size else { + return + } + self.currentLayout = size + + let bounds = CGRect(origin: .zero, size: size) + + //let center = CGPoint(x: size.width / 2.0, y: size.height / 2.0) + //let viewFinderWidth = bounds.width - 34.0 + //let viewFinderSize = CGSize(width: viewFinderWidth, height: floor(viewFinderWidth * 1.17778)) + + let viewFinderFrame = bounds.insetBy(dx: 29.0, dy: 29.0) //viewFinderSize.centered(around: center) + self.viewFinderLayer.update(size: viewFinderFrame.size, closed: self.currentState != .viewFinder, transition: .immediate) + self.viewFinderLayer.frame = viewFinderFrame + + let transitionFrame = bounds.insetBy(dx: 29.0, dy: 29.0) //viewFinderSize.centered(around: center) + self.transitionLayer.update(size: transitionFrame.size) + self.transitionLayer.frame = transitionFrame + + let segmentsFrame = bounds.insetBy(dx: 15.0, dy: 15.0) + self.segmentsLayer.update(size: segmentsFrame.size) + self.segmentsLayer.frame = segmentsFrame + } +} + +private let numberOfSegments = 64 +private let lineWidth: CGFloat = 4.0 + +final class ViewFinderLayer: SimpleLayer { + private let viewFinderTopLeftLine = SimpleShapeLayer() + private let viewFinderTopRightLine = SimpleShapeLayer() + private let viewFinderBottomLeftLine = SimpleShapeLayer() + private let viewFinderBottomRightLine = SimpleShapeLayer() + + private var viewFinderLines: [SimpleShapeLayer] { + return [ + self.viewFinderTopLeftLine, + self.viewFinderTopRightLine, + self.viewFinderBottomLeftLine, + self.viewFinderBottomRightLine + ] + } + + override init() { + super.init() + + for line in self.viewFinderLines { + line.strokeColor = UIColor.white.cgColor + line.fillColor = UIColor.clear.cgColor + line.lineWidth = lineWidth + line.lineCap = .round + self.addSublayer(line) + } + } + + required init?(coder: NSCoder) { + preconditionFailure() + } + + private var validLayout: CGSize? + + func animateOut(transition: ComponentTransition, completion: @escaping () -> Void) { + guard let size = self.validLayout else { + return + } + self.update(size: size, closed: true, transition: transition, completion: completion) + } + + func animateIn(transition: ComponentTransition, completion: @escaping () -> Void) { + guard let size = self.validLayout else { + return + } + self.update(size: size, closed: false, transition: transition, completion: completion) + } + + func update(size: CGSize, closed: Bool, transition: ComponentTransition, completion: (() -> Void)? = nil) { + self.validLayout = size + + let cornerRadius = closed ? size.width / 2.0 : 18.0 + + let lineLength = size.width / 2.0 - cornerRadius + let targetLineLength = 34.0 + let fraction = targetLineLength / lineLength + let strokeFraction = (1.0 - fraction) / 2.0 + let strokeStart = closed ? 0.0 : strokeFraction + let strokeEnd = closed ? 1.0 : 1.0 - strokeFraction + + let topLeftPath = CGMutablePath() + topLeftPath.move(to: CGPoint(x: 0.0, y: size.height / 2.0)) + topLeftPath.addArc(center: CGPoint(x: cornerRadius, y: cornerRadius), radius: cornerRadius, startAngle: -.pi, endAngle: -.pi / 2.0, clockwise: false) + topLeftPath.addLine(to: CGPoint(x: size.width / 2.0, y: 0.0)) + + transition.setShapeLayerPath(layer: self.viewFinderTopLeftLine, path: topLeftPath, completion: { _ in + completion?() + }) + transition.setShapeLayerStrokeStart(layer: self.viewFinderTopLeftLine, strokeStart: strokeStart) + transition.setShapeLayerStrokeEnd(layer: self.viewFinderTopLeftLine, strokeEnd: strokeEnd) + + let topRightPath = CGMutablePath() + topRightPath.move(to: CGPoint(x: size.width / 2.0, y: 0.0)) + topRightPath.addArc(center: CGPoint(x: size.width - cornerRadius, y: cornerRadius), radius: cornerRadius, startAngle: -.pi / 2.0, endAngle: 0.0, clockwise: false) + topRightPath.addLine(to: CGPoint(x: size.width, y: size.height / 2.0)) + + transition.setShapeLayerPath(layer: self.viewFinderTopRightLine, path: topRightPath) + transition.setShapeLayerStrokeStart(layer: self.viewFinderTopRightLine, strokeStart: strokeStart) + transition.setShapeLayerStrokeEnd(layer: self.viewFinderTopRightLine, strokeEnd: strokeEnd) + + let bottomRightPath = CGMutablePath() + bottomRightPath.move(to: CGPoint(x: size.width, y: size.height / 2.0)) + bottomRightPath.addArc(center: CGPoint(x: size.width - cornerRadius, y: size.height - cornerRadius), radius: cornerRadius, startAngle: 0.0, endAngle: .pi / 2.0, clockwise: false) + bottomRightPath.addLine(to: CGPoint(x: size.width / 2.0, y: size.height)) + + transition.setShapeLayerPath(layer: self.viewFinderBottomRightLine, path: bottomRightPath) + transition.setShapeLayerStrokeStart(layer: self.viewFinderBottomRightLine, strokeStart: strokeStart) + transition.setShapeLayerStrokeEnd(layer: self.viewFinderBottomRightLine, strokeEnd: strokeEnd) + + let bottomLeftPath = CGMutablePath() + bottomLeftPath.move(to: CGPoint(x: size.width / 2.0, y: size.height)) + bottomLeftPath.addArc(center: CGPoint(x: cornerRadius, y: size.height - cornerRadius), radius: cornerRadius, startAngle: .pi / 2.0, endAngle: .pi, clockwise: false) + bottomLeftPath.addLine(to: CGPoint(x: 0.0, y: size.height / 2.0)) + + transition.setShapeLayerPath(layer: self.viewFinderBottomLeftLine, path: bottomLeftPath) + transition.setShapeLayerStrokeStart(layer: self.viewFinderBottomLeftLine, strokeStart: strokeStart) + transition.setShapeLayerStrokeEnd(layer: self.viewFinderBottomLeftLine, strokeEnd: strokeEnd) + + for line in self.viewFinderLines { + line.frame = CGRect(origin: .zero, size: size) + } + } +} + + + +final class TransitionLayer: SimpleLayer { + private var segmentLayers: [SimpleShapeLayer] = [] + + func animateIn(transition: ComponentTransition, completion: @escaping () -> Void) { + var i = 0 + for layer in self.segmentLayers { + transition.setShapeLayerStrokeStart(layer: layer, strokeStart: 0.499) + transition.setShapeLayerStrokeEnd(layer: layer, strokeEnd: 0.501, completion: i == 0 ? { _ in completion() } : nil) + i += 1 + } + } + + func animateOut(transition: ComponentTransition, completion: @escaping () -> Void) { + var i = 0 + for layer in self.segmentLayers { + transition.setShapeLayerStrokeStart(layer: layer, strokeStart: 0.0) + transition.setShapeLayerStrokeEnd(layer: layer, strokeEnd: 1.0, completion: i == 0 ? { _ in completion() } : nil) + i += 1 + } + } + + func setupIfNeeded(size: CGSize) { + guard self.segmentLayers.isEmpty else { + return + } + + let center = CGPoint(x: size.width / 2.0, y: size.height / 2.0) + let radius: CGFloat = size.width / 2.0 + let gapInDegrees: CGFloat = 0.0 + let gapInRadians: CGFloat = gapInDegrees * .pi / 180.0 + + let totalGapAngle = CGFloat(numberOfSegments) * gapInRadians + let totalSegmentAngle = 2 * .pi - totalGapAngle + let segmentAngle = totalSegmentAngle / CGFloat(numberOfSegments) + + for i in 0 ..< numberOfSegments { + let startAngle = -segmentAngle * 0.5 + (CGFloat(i) * (segmentAngle + gapInRadians)) - .pi / 2 + let endAngle = startAngle + segmentAngle + + let path = UIBezierPath(arcCenter: center, + radius: radius, + startAngle: startAngle, + endAngle: endAngle, + clockwise: true) + + let stripeLayer = SimpleShapeLayer() + stripeLayer.path = path.cgPath + stripeLayer.strokeColor = UIColor(rgb: 0xaaaaaa).cgColor + stripeLayer.lineWidth = lineWidth + stripeLayer.fillColor = UIColor.clear.cgColor + stripeLayer.lineCap = .round + + self.addSublayer(stripeLayer) + self.segmentLayers.append(stripeLayer) + } + } + + func update(color: UIColor) { + for layer in self.segmentLayers { + layer.strokeColor = color.cgColor + } + } + + func update(size: CGSize) { + self.setupIfNeeded(size: size) + } +} + +final class SegmentsLayer: SimpleLayer { + private var segmentLayers: [SimpleShapeLayer] = [] + + func animateIn(transition: ComponentTransition, completion: @escaping () -> Void) { + var i = 0 + for layer in self.segmentLayers { + transition.setShapeLayerStrokeStart(layer: layer, strokeStart: 0.0) + transition.setShapeLayerStrokeEnd(layer: layer, strokeEnd: 0.32, completion: i == 0 ? { _ in completion() } : nil) + i += 1 + } + } + + func animateOut(transition: ComponentTransition, completion: @escaping () -> Void) { + var i = 0 + for layer in self.segmentLayers { + transition.setShapeLayerStrokeStart(layer: layer, strokeStart: 0.0) + transition.setShapeLayerStrokeEnd(layer: layer, strokeEnd: 0.001, completion: i == 0 ? { _ in completion() } : nil) + i += 1 + } + } + + func setupIfNeeded(size: CGSize) { + guard self.segmentLayers.isEmpty else { + return + } + + let center = CGPoint(x: size.width / 2.0, y: size.height / 2.0) + let innerRadius: CGFloat = size.width / 2.0 - 13.0 + let outerRadius: CGFloat = size.width / 2.0 + 13.0 + let gapInDegrees: CGFloat = 2.0 + let gapInRadians: CGFloat = gapInDegrees * .pi / 180.0 + + let totalGapAngle = CGFloat(numberOfSegments) * gapInRadians + let totalSegmentAngle = 2 * .pi - totalGapAngle + let segmentAngle = totalSegmentAngle / CGFloat(numberOfSegments) + + for i in 0 ..< numberOfSegments { + let angle = (CGFloat(i) * (segmentAngle + gapInRadians)) - .pi / 2 + + let startPoint = CGPoint( + x: center.x + innerRadius * cos(angle), + y: center.y + innerRadius * sin(angle) + ) + + let endPoint = CGPoint( + x: center.x + outerRadius * cos(angle), + y: center.y + outerRadius * sin(angle) + ) + + let path = UIBezierPath() + path.move(to: startPoint) + path.addLine(to: endPoint) + + let stripeLayer = SimpleShapeLayer() + stripeLayer.path = path.cgPath + stripeLayer.strokeColor = UIColor(rgb: 0xaaaaaa).cgColor + stripeLayer.lineWidth = lineWidth + stripeLayer.fillColor = UIColor.clear.cgColor + stripeLayer.lineCap = .round + stripeLayer.strokeStart = 0.0 + stripeLayer.strokeEnd = 0.001 + + self.addSublayer(stripeLayer) + self.segmentLayers.append(stripeLayer) + } + } + + func update(segments: Set, transition: ComponentTransition) { + var mappedSegments = Set() + for value in segments { + for i in 0 ..< 4 { + mappedSegments.insert(value * 4 + i) + } + } + + for i in 0 ..< numberOfSegments { + let stripeLayer = self.segmentLayers[i] + if mappedSegments.contains(i) { + transition.setShapeLayerStrokeEnd(layer: stripeLayer, strokeEnd: 1.0) + transition.setShapeLayerStrokeColor(layer: stripeLayer, color: UIColor(rgb: 0x00ca48)) + } else { + transition.setShapeLayerStrokeEnd(layer: stripeLayer, strokeEnd: 0.32) + transition.setShapeLayerStrokeColor(layer: stripeLayer, color: UIColor(rgb: 0xaaaaaa)) + } + } + } + + func update(size: CGSize) { + self.setupIfNeeded(size: size) + } +} 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/Gifts/GiftOptionsScreen/Sources/GiftOptionsScreen.swift b/submodules/TelegramUI/Components/Gifts/GiftOptionsScreen/Sources/GiftOptionsScreen.swift index 998507837e..747e0ec836 100644 --- a/submodules/TelegramUI/Components/Gifts/GiftOptionsScreen/Sources/GiftOptionsScreen.swift +++ b/submodules/TelegramUI/Components/Gifts/GiftOptionsScreen/Sources/GiftOptionsScreen.swift @@ -460,9 +460,9 @@ final class GiftOptionsScreenComponent: Component { } if case let .generic(gift) = gift { if let perUserLimit = gift.perUserLimit, perUserLimit.remains == 0 { - //TODO:localize + let text = environment.strings.Gift_Options_Gift_BuyLimitReached(perUserLimit.total) let presentationData = component.context.sharedContext.currentPresentationData.with { $0 } - let controller = UndoOverlayController(presentationData: presentationData, content: .sticker(context: component.context, file: gift.file, loop: true, title: nil, text: "You've already sent \(perUserLimit.total) of these gifts, and it's the limit.", undoText: nil, customAction: nil), action: { _ in return false }) + let controller = UndoOverlayController(presentationData: presentationData, content: .sticker(context: component.context, file: gift.file, loop: true, title: nil, text: text, undoText: nil, customAction: nil), action: { _ in return false }) mainController.present(controller, in: .current) return } 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..400e9140a8 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,76 @@ 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 hasVisibility { + if let pane, case .all = pane.currentCollection { + items.append(.action(ContextMenuActionItem(text: strings.PeerInfo_Gifts_AddCollection, 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: strings.PeerInfo_Gifts_AddGifts, 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: strings.PeerInfo_Gifts_ShareCollection, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Forward"), color: theme.contextMenu.primaryColor) + }, action: { [weak self] _, f in + f(.default) + //TODO:release + self?.openShareLink(url: "https://t.me/") + }))) + } + + if canReorder { + 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: strings.PeerInfo_Gifts_DeleteCollection, 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 +11434,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..b1ec546109 --- /dev/null +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/AddGiftsScreen.swift @@ -0,0 +1,501 @@ +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 + + let buttonString = environment.strings.AddGifts_AddGifts(Int32(giftsListView.selectedItems.count)) + 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) + + + self.title = presentationData.strings.AddGifts_Title + 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..ef195fb43d --- /dev/null +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/GiftsListView.swift @@ -0,0 +1,1237 @@ +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)) + + let emptyResultsTitleSize = self.emptyResultsTitle.update( + transition: .immediate, + component: AnyComponent( + MultilineTextComponent( + text: .plain(NSAttributedString(string: presentationData.strings.PeerInfo_Gifts_EmptyCollection_Title, 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: presentationData.strings.PeerInfo_Gifts_EmptyCollection_Text, 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: presentationData.strings.PeerInfo_Gifts_EmptyCollection_Action, 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..ab1595fa59 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,177 @@ 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: params.presentationData.strings.PeerInfo_Gifts_CollectionLimitReached_Title, text: params.presentationData.strings.PeerInfo_Gifts_CollectionLimitReached_Text, actions: [TextAlertAction(type: .defaultAction, title: params.presentationData.strings.Common_OK, action: {})]) + self.parentController?.present(alertController, in: .window(.root)) + return + } + + let promptController = promptController(sharedContext: self.context.sharedContext, updatedPresentationData: nil, text: params.presentationData.strings.PeerInfo_Gifts_CreateCollection_Title, titleFont: .bold, subtitle: params.presentationData.strings.PeerInfo_Gifts_CreateCollection_Text, value: "", placeholder: params.presentationData.strings.PeerInfo_Gifts_CreateCollection_Placeholder, 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) { + guard let params = self.currentParams else { + return + } + let actionSheet = ActionSheetController(presentationData: params.presentationData) + actionSheet.setItemGroups([ + ActionSheetItemGroup(items: [ + ActionSheetTextItem(title: params.presentationData.strings.PeerInfo_Gifts_RemoveCollectionConfirmation), + ActionSheetButtonItem(title: params.presentationData.strings.PeerInfo_Gifts_RemoveCollectionAction, color: .destructive, action: { [weak self, weak actionSheet] in + actionSheet?.dismissAnimated() + + self?.setCurrentCollection(collection: .all) + let _ = self?.profileGiftsCollections.deleteCollection(id: id).start() + }) + ]), + ActionSheetItemGroup(items: [ + ActionSheetButtonItem(title: params.presentationData.strings.Common_Cancel, color: .accent, font: .bold, action: { [weak actionSheet] in + actionSheet?.dismissAnimated() + }) + ]) + ]) + self.parentController?.present(actionSheet, in: .window(.root)) + } + + 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 params = self.currentParams, let collection = self.collections?.first(where: { $0.id == id }) else { + return + } + + let promptController = promptController(sharedContext: self.context.sharedContext, updatedPresentationData: nil, text: params.presentationData.strings.PeerInfo_Gifts_RenameCollection_Title, titleFont: .bold, value: collection.title, placeholder: params.presentationData.strings.PeerInfo_Gifts_CreateCollection_Placeholder, 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 +334,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 +356,291 @@ 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) + default: + profileGifts = self.profileGifts + } + if case .ready = profileGifts.currentState?.dataState { + profileGifts.reload() + } + + 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] = [] + + items.append(.action(ContextMenuActionItem(text: params.presentationData.strings.PeerInfo_Gifts_AddGifts, 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: params.presentationData.strings.PeerInfo_Gifts_RenameCollection, 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: params.presentationData.strings.PeerInfo_Gifts_ShareCollection, 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 + //TODO:release + let _ = self + }))) + + items.append(.action(ContextMenuActionItem(text: params.presentationData.strings.PeerInfo_Gifts_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: params.presentationData.strings.PeerInfo_Gifts_DeleteCollection, 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 +651,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 +670,74 @@ 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 + + let buttonTitle: String + if self.peerId == self.context.account.peerId { + if case .all = self.currentCollection { + buttonTitle = params.presentationData.strings.PeerInfo_Gifts_Send + } else { + buttonTitle = params.presentationData.strings.PeerInfo_Gifts_AddGiftsButton } - } - - 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 +800,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 +833,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 +867,94 @@ 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 + } + + let profileGifts: ProfileGiftsContext + switch self.currentCollection { + case let .collection(id): + profileGifts = self.profileGiftsCollections.giftsContextForCollection(id: id) + default: + profileGifts = self.profileGifts } 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: strings.PeerInfo_Gifts_Context_AddToCollection, 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: strings.PeerInfo_Gifts_Context_NewCollection, 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,12 +964,12 @@ 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 } - self.profileGifts.updateStarGiftPinnedToTop(reference: reference, pinnedToTop: pinnedToTop) + profileGifts.updateStarGiftPinnedToTop(reference: reference, pinnedToTop: pinnedToTop) let toastTitle: String? let toastText: String @@ -1128,7 +989,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 { @@ -1272,7 +1140,7 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr } if let reference = gift.reference { let added = !gift.savedToProfile - self.profileGifts.updateStarGiftAddedToProfile(reference: reference, added: added) + profileGifts.updateStarGiftAddedToProfile(reference: reference, added: added) var animationFile: TelegramMediaFile? switch gift.gift { @@ -1327,8 +1195,8 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr showSelf = true } let transferStars = gift.transferStars ?? 0 - let controller = context.sharedContext.makePremiumGiftController(context: context, source: .starGiftTransfer(birthdays, reference, uniqueGift, transferStars, gift.canExportDate, showSelf), completion: { [weak self] peerIds in - guard let self, let peerId = peerIds.first else { + let controller = context.sharedContext.makePremiumGiftController(context: context, source: .starGiftTransfer(birthdays, reference, uniqueGift, transferStars, gift.canExportDate, showSelf), completion: { peerIds in + guard let peerId = peerIds.first else { return .complete() } Queue.mainQueue().after(1.5, { @@ -1336,7 +1204,7 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr context.starsContext?.load(force: true) } }) - return self.profileGifts.transferStarGift(prepaid: transferStars == 0, reference: reference, peerId: peerId) + return profileGifts.transferStarGift(prepaid: transferStars == 0, reference: reference, peerId: peerId) }) self.parentController?.push(controller) }) @@ -1345,12 +1213,23 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr } } + if case let .collection(id) = self.currentCollection { + items.append(.action(ContextMenuActionItem(text: strings.PeerInfo_Gifts_Context_RemoveFromCollection, 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 +1238,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 +1285,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 +1482,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..46ccd0e59d 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) @@ -667,14 +670,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 && !"".isEmpty ? 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,7 +765,7 @@ 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), + actionTitle: component.starsContext.ton ? "Withdraw via Fragment" : (withdrawAvailable ? environment.strings.Stars_Intro_BuyShort : environment.strings.Stars_Intro_Buy), actionAvailable: (!component.starsContext.ton && !premiumConfiguration.areStarsDisabled && !premiumConfiguration.isPremiumDisabled), actionIsEnabled: true, actionIcon: component.starsContext.ton ? nil : PresentationResourcesItemList.itemListRoundTopupIcon(environment.theme), @@ -1270,51 +1352,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/Chat/ChatControllerOpenPeer.swift b/submodules/TelegramUI/Sources/Chat/ChatControllerOpenPeer.swift index be3414b66d..951d94b1f2 100644 --- a/submodules/TelegramUI/Sources/Chat/ChatControllerOpenPeer.swift +++ b/submodules/TelegramUI/Sources/Chat/ChatControllerOpenPeer.swift @@ -122,9 +122,10 @@ import PeerNameColorScreen import ChatEmptyNode import ChatMediaInputStickerGridItem import AdsInfoScreen +import FaceScanScreen extension ChatControllerImpl { - func openPeer(peer: EnginePeer?, navigation: ChatControllerInteractionNavigateToPeer, fromMessage: MessageReference?, fromReactionMessageId: MessageId? = nil, expandAvatar: Bool = false, peerTypes: ReplyMarkupButtonAction.PeerTypes? = nil) { + func openPeer(peer: EnginePeer?, navigation: ChatControllerInteractionNavigateToPeer, fromMessage: MessageReference?, fromReactionMessageId: MessageId? = nil, expandAvatar: Bool = false, peerTypes: ReplyMarkupButtonAction.PeerTypes? = nil, skipAgeVerification: Bool = false) { let _ = self.presentVoiceMessageDiscardAlert(action: { if case let .peer(currentPeerId) = self.chatLocation, peer?.id == currentPeerId { switch navigation { @@ -223,11 +224,23 @@ extension ChatControllerImpl { } }) } else { - if case let .channel(channel) = peer, channel.isForumOrMonoForum { - self.effectiveNavigationController?.pushViewController(ChatListControllerImpl(context: self.context, location: .forum(peerId: channel.id), controlsHistoryPreload: false, enableDebugActions: false)) - } else { - self.effectiveNavigationController?.pushViewController(ChatControllerImpl(context: self.context, chatLocation: .peer(id: peer.id), subject: subject)) - } + let _ = (requireAgeVerification(context: self.context, peer: peer) + |> deliverOnMainQueue).start(next: { [weak self] require in + guard let self else { + return + } + if require && !skipAgeVerification { + presentAgeVerification(context: self.context, parentController: self, completion: { + self.openPeer(peer: peer, navigation: navigation, fromMessage: fromMessage, fromReactionMessageId: fromReactionMessageId, expandAvatar: expandAvatar, peerTypes: peerTypes) + }) + } else { + if case let .channel(channel) = peer, channel.isForumOrMonoForum { + self.effectiveNavigationController?.pushViewController(ChatListControllerImpl(context: self.context, location: .forum(peerId: channel.id), controlsHistoryPreload: false, enableDebugActions: false)) + } else { + self.effectiveNavigationController?.pushViewController(ChatControllerImpl(context: self.context, chatLocation: .peer(id: peer.id), subject: subject)) + } + } + }) } case let .withBotStartPayload(botStart): self.effectiveNavigationController?.pushViewController(ChatControllerImpl(context: self.context, chatLocation: .peer(id: peer.id), botStart: botStart)) diff --git a/submodules/TelegramUI/Sources/Chat/ChatControllerOpenTodoContextMenu.swift b/submodules/TelegramUI/Sources/Chat/ChatControllerOpenTodoContextMenu.swift index 5b16755848..36860380ad 100644 --- a/submodules/TelegramUI/Sources/Chat/ChatControllerOpenTodoContextMenu.swift +++ b/submodules/TelegramUI/Sources/Chat/ChatControllerOpenTodoContextMenu.swift @@ -129,7 +129,7 @@ extension ChatControllerImpl { } if "".isEmpty { - //TODO:localize + //TODO:release items.append(.action(ContextMenuActionItem(text: self.presentationData.strings.Chat_Todo_ReplyToItem, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Reply"), color: theme.actionSheet.primaryTextColor) }, action: { [weak self] c, _ in diff --git a/submodules/TelegramUI/Sources/ChatAgeRestrictionAlertController.swift b/submodules/TelegramUI/Sources/ChatAgeRestrictionAlertController.swift index aab9ba81e9..0cc95eb7d8 100644 --- a/submodules/TelegramUI/Sources/ChatAgeRestrictionAlertController.swift +++ b/submodules/TelegramUI/Sources/ChatAgeRestrictionAlertController.swift @@ -259,7 +259,7 @@ private final class ChatAgeRestrictionAlertContentNode: AlertContentNode { } } -public func chatAgeRestrictionAlertController(context: AccountContext, updatedPresentationData: (initial: PresentationData, signal: Signal)?, completion: @escaping (Bool) -> Void) -> AlertController { +public func chatAgeRestrictionAlertController(context: AccountContext, updatedPresentationData: (initial: PresentationData, signal: Signal)?, parentController: ViewController, completion: @escaping (Bool) -> Void) -> AlertController { let theme = defaultDarkColorPresentationTheme let presentationData: PresentationData if let updatedPresentationData { @@ -268,7 +268,7 @@ public func chatAgeRestrictionAlertController(context: AccountContext, updatedPr presentationData = context.sharedContext.currentPresentationData.with { $0 } } let strings = presentationData.strings - + var dismissImpl: ((Bool) -> Void)? var getContentNodeImpl: (() -> ChatAgeRestrictionAlertContentNode?)? let actions: [TextAlertAction] = [TextAlertAction(type: .defaultAction, title: strings.SensitiveContent_ViewAnyway, action: { diff --git a/submodules/TelegramUI/Sources/ChatController.swift b/submodules/TelegramUI/Sources/ChatController.swift index aea795e301..82a1c85707 100644 --- a/submodules/TelegramUI/Sources/ChatController.swift +++ b/submodules/TelegramUI/Sources/ChatController.swift @@ -140,6 +140,7 @@ import PromptUI import SuggestedPostApproveAlert import AVFoundation import BalanceNeededScreen +import FaceScanScreen public final class ChatControllerOverlayPresentationData { public let expandData: (ASDisplayNode?, () -> Void) @@ -4770,10 +4771,20 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G guard let self else { return } - let controller = chatAgeRestrictionAlertController(context: self.context, updatedPresentationData: self.updatedPresentationData, completion: { _ in - reveal() - }) - self.present(controller, in: .window(.root)) + if requireAgeVerification(context: context) { + presentAgeVerification(context: context, parentController: self, completion: { + let _ = updateRemoteContentSettingsConfiguration(postbox: context.account.postbox, network: context.account.network, sensitiveContentEnabled: true).start() + reveal() + }) + } else { + let controller = chatAgeRestrictionAlertController(context: self.context, updatedPresentationData: self.updatedPresentationData, parentController: self, completion: { alwaysShow in + if alwaysShow { + let _ = updateRemoteContentSettingsConfiguration(postbox: context.account.postbox, network: context.account.network, sensitiveContentEnabled: true).start() + } + reveal() + }) + self.present(controller, in: .window(.root)) + } }, playMessageEffect: { [weak self] message in guard let self else { return diff --git a/submodules/TelegramUI/Sources/NavigateToChatController.swift b/submodules/TelegramUI/Sources/NavigateToChatController.swift index 87cb864f00..ac62e776b3 100644 --- a/submodules/TelegramUI/Sources/NavigateToChatController.swift +++ b/submodules/TelegramUI/Sources/NavigateToChatController.swift @@ -20,12 +20,18 @@ import ChatControllerInteraction import SavedMessagesScreen import WallpaperGalleryScreen import ChatMessageNotificationItem +import FaceScanScreen public func navigateToChatControllerImpl(_ params: NavigateToChatControllerParams) { if case let .peer(peer) = params.chatLocation { let _ = params.context.engine.peers.ensurePeerIsLocallyAvailable(peer: peer).startStandalone() } + var requiresAgeVerification: Signal = .single(false) + if !params.skipAgeVerification, case let .peer(peer) = params.chatLocation { + requiresAgeVerification = requireAgeVerification(context: params.context, peer: peer) + } + var viewForumAsMessages: Signal = .single(false) if case let .peer(peer) = params.chatLocation, case let .channel(channel) = peer, channel.flags.contains(.isMonoforum) { if let linkedMonoforumId = channel.linkedMonoforumId { @@ -67,9 +73,18 @@ public func navigateToChatControllerImpl(_ params: NavigateToChatControllerParam } } - let _ = (viewForumAsMessages - |> take(1) - |> deliverOnMainQueue).start(next: { viewForumAsMessages in + let _ = combineLatest( + queue: Queue.mainQueue(), + viewForumAsMessages |> take(1), + requiresAgeVerification + ).start(next: { viewForumAsMessages, requiresAgeVerification in + if requiresAgeVerification, let parentController = params.navigationController.viewControllers.last as? ViewController { + presentAgeVerification(context: params.context, parentController: parentController, completion: { + navigateToChatControllerImpl(params.withSkipAgeVerification(true)) + }) + return + } + if case let .peer(peer) = params.chatLocation, case let .channel(channel) = peer, channel.flags.contains(.isForum), !viewForumAsMessages { for controller in params.navigationController.viewControllers.reversed() { var chatListController: ChatListControllerImpl? @@ -118,9 +133,7 @@ public func navigateToChatControllerImpl(_ params: NavigateToChatControllerParam controller.activateSearch(query: activateMessageSearch.1) } - if let chatListCompletion { - chatListCompletion(controller) - } + chatListCompletion(controller) }) return diff --git a/submodules/TelegramUI/Sources/TelegramRootController.swift b/submodules/TelegramUI/Sources/TelegramRootController.swift index c6f68a0004..683e3bb911 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/TelegramUIPreferences/Sources/PostboxKeys.swift b/submodules/TelegramUIPreferences/Sources/PostboxKeys.swift index ec477ff08c..f10c89be0f 100644 --- a/submodules/TelegramUIPreferences/Sources/PostboxKeys.swift +++ b/submodules/TelegramUIPreferences/Sources/PostboxKeys.swift @@ -8,6 +8,7 @@ private enum ApplicationSpecificPreferencesKeyValues: Int32 { case chatListFilterSettings = 18 case widgetSettings = 19 case mediaAutoSaveSettings = 20 + case ageVerificationState = 21 } public struct ApplicationSpecificPreferencesKeys { @@ -16,6 +17,7 @@ public struct ApplicationSpecificPreferencesKeys { public static let chatListFilterSettings = applicationSpecificPreferencesKey(ApplicationSpecificPreferencesKeyValues.chatListFilterSettings.rawValue) public static let widgetSettings = applicationSpecificPreferencesKey(ApplicationSpecificPreferencesKeyValues.widgetSettings.rawValue) public static let mediaAutoSaveSettings = applicationSpecificPreferencesKey(ApplicationSpecificPreferencesKeyValues.mediaAutoSaveSettings.rawValue) + public static let ageVerificationState = applicationSpecificPreferencesKey(ApplicationSpecificPreferencesKeyValues.ageVerificationState.rawValue) } private enum ApplicationSpecificSharedDataKeyValues: Int32 { 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) diff --git a/versions.json b/versions.json index 62f1abf20e..e4f292734f 100644 --- a/versions.json +++ b/versions.json @@ -1,5 +1,5 @@ { - "app": "11.13.2", + "app": "11.14", "xcode": "16.2", "bazel": "8.3.1:0cac3a67dc5429c68272dc6944104952e9e4cf84b29d126a5ff3fbaa59045217", "macos": "15"