diff --git a/Telegram/Telegram-iOS/PremiumSteam.alticon/PremiumSteam@2x.png b/Telegram/Telegram-iOS/PremiumSteam.alticon/PremiumSteam@2x.png index abdd22ae69..4e0cf791fb 100644 Binary files a/Telegram/Telegram-iOS/PremiumSteam.alticon/PremiumSteam@2x.png and b/Telegram/Telegram-iOS/PremiumSteam.alticon/PremiumSteam@2x.png differ diff --git a/Telegram/Telegram-iOS/PremiumSteam.alticon/PremiumSteam@3x.png b/Telegram/Telegram-iOS/PremiumSteam.alticon/PremiumSteam@3x.png index 8a0de8458b..0d59ed2b68 100644 Binary files a/Telegram/Telegram-iOS/PremiumSteam.alticon/PremiumSteam@3x.png and b/Telegram/Telegram-iOS/PremiumSteam.alticon/PremiumSteam@3x.png differ diff --git a/Telegram/Telegram-iOS/en.lproj/Localizable.strings b/Telegram/Telegram-iOS/en.lproj/Localizable.strings index 212ff08558..1282055264 100644 --- a/Telegram/Telegram-iOS/en.lproj/Localizable.strings +++ b/Telegram/Telegram-iOS/en.lproj/Localizable.strings @@ -10110,3 +10110,13 @@ Sorry for the inconvenience."; "Appearance.AppIconSteam" = "Steam"; "Notification.GiftLink" = "You received a gift"; + +"MESSAGE_GIFTCODE" = "%1$@ sent you a Gift Code for %2$@ months of Telegram Premium"; +"MESSAGE_GIVEAWAY" = "%1$@ sent you a giveaway of %2$@x %3$@mo Premium subscriptions"; +"CHANNEL_MESSAGE_GIVEAWAY" = "%1$@ posted a giveaway of %2$@x %3$@mo Premium subscriptions"; +"CHAT_MESSAGE_GIVEAWAY" = "%1$@ sent a giveaway of %3$@x %4$@mo Premium subscriptions to the group %2$@"; +"PINNED_GIVEAWAY" = "%1$@ pinned a giveaway"; +"REACT_GIVEAWAY" = "%1$@ reacted %2$@ to your giveaway"; +"CHAT_REACT_GIVEAWAY" = "%1$@ reacted %3$@ in group %2$@ to your giveaway"; + +"Notification.GiveawayStarted" = "%1$@ just started a giveaway of Telegram Premium subscriptions for its followers."; diff --git a/submodules/CountrySelectionUI/Sources/AuthorizationSequenceCountrySelectionControllerNode.swift b/submodules/CountrySelectionUI/Sources/AuthorizationSequenceCountrySelectionControllerNode.swift index 8e5cadf795..9c9cd26cf9 100644 --- a/submodules/CountrySelectionUI/Sources/AuthorizationSequenceCountrySelectionControllerNode.swift +++ b/submodules/CountrySelectionUI/Sources/AuthorizationSequenceCountrySelectionControllerNode.swift @@ -60,7 +60,7 @@ private func loadCountryCodes() -> [(String, Int)] { private let countryCodes: [(String, Int)] = loadCountryCodes() -func localizedCountryNamesAndCodes(strings: PresentationStrings) -> [((String, String), String, [Int])] { +public func localizedCountryNamesAndCodes(strings: PresentationStrings) -> [((String, String), String, [Int])] { let locale = localeWithStrings(strings) var result: [((String, String), String, [Int])] = [] for country in AuthorizationSequenceCountrySelectionController.countries() { @@ -159,7 +159,7 @@ private func matchStringTokens(_ tokens: [Data], with other: [Data]) -> Bool { return false } -private func searchCountries(items: [((String, String), String, [Int])], query: String) -> [((String, String), String, Int)] { +public func searchCountries(items: [((String, String), String, [Int])], query: String) -> [((String, String), String, Int)] { let queryTokens = stringTokens(query.lowercased()) var result: [((String, String), String, Int)] = [] diff --git a/submodules/CountrySelectionUI/Sources/CountryList.swift b/submodules/CountrySelectionUI/Sources/CountryList.swift index 5d2dd965c3..87efdf89bb 100644 --- a/submodules/CountrySelectionUI/Sources/CountryList.swift +++ b/submodules/CountrySelectionUI/Sources/CountryList.swift @@ -1,5 +1,6 @@ import Foundation import AppBundle +import TelegramStringFormatting public func emojiFlagForISOCountryCode(_ countryCode: String) -> String { if countryCode.count != 2 { @@ -18,12 +19,7 @@ public func emojiFlagForISOCountryCode(_ countryCode: String) -> String { return "" } - let base : UInt32 = 127397 - var s = "" - for v in countryCode.unicodeScalars { - s.unicodeScalars.append(UnicodeScalar(base + v.value)!) - } - return String(s) + return flagEmoji(countryCode: countryCode) } private func loadCountriesInfo() -> [(Int, String, String)] { diff --git a/submodules/LocationUI/Sources/LocationActionListItem.swift b/submodules/LocationUI/Sources/LocationActionListItem.swift index 9a838c35d8..aa5ead7449 100644 --- a/submodules/LocationUI/Sources/LocationActionListItem.swift +++ b/submodules/LocationUI/Sources/LocationActionListItem.swift @@ -9,6 +9,7 @@ import ItemListUI import LocationResources import AppBundle import LiveLocationTimerNode +import TelegramStringFormatting public enum LocationActionListItemIcon: Equatable { case location @@ -280,14 +281,6 @@ final class LocationActionListItemNode: ListViewItemNode { strongSelf.iconNode.isHidden = true strongSelf.venueIconNode.isHidden = false - func flagEmoji(countryCode: String) -> String { - let base : UInt32 = 127397 - var flagString = "" - for v in countryCode.uppercased().unicodeScalars { - flagString.unicodeScalars.append(UnicodeScalar(base + v.value)!) - } - return flagString - } let type = venue.venue?.type var flag: String? if let venue = venue.venue, venue.provider == "city", let countryCode = venue.id { diff --git a/submodules/PremiumUI/BUILD b/submodules/PremiumUI/BUILD index 2570ad141d..39b445da50 100644 --- a/submodules/PremiumUI/BUILD +++ b/submodules/PremiumUI/BUILD @@ -106,6 +106,7 @@ swift_library( "//submodules/TelegramUI/Components/ShareWithPeersScreen", "//submodules/TelegramUI/Components/ButtonComponent", "//submodules/TelegramUI/Components/Utils/RoundedRectWithTailPath", + "//submodules/CountrySelectionUI", ], visibility = [ "//visibility:public", diff --git a/submodules/PremiumUI/Sources/AppIconsDemoComponent.swift b/submodules/PremiumUI/Sources/AppIconsDemoComponent.swift index 8d21a8df56..595b5bb4e8 100644 --- a/submodules/PremiumUI/Sources/AppIconsDemoComponent.swift +++ b/submodules/PremiumUI/Sources/AppIconsDemoComponent.swift @@ -38,6 +38,7 @@ final class AppIconsDemoComponent: Component { private var component: AppIconsDemoComponent? private var containerView: UIView + private var axisView = UIView() private var imageViews: [UIImageView] = [] private var isVisible = false @@ -49,6 +50,7 @@ final class AppIconsDemoComponent: Component { super.init(frame: frame) self.addSubview(self.containerView) + self.containerView.addSubview(self.axisView) } required init?(coder: NSCoder) { @@ -62,7 +64,11 @@ final class AppIconsDemoComponent: Component { self.containerView.frame = CGRect(origin: CGPoint(x: -availableSize.width / 2.0, y: 0.0), size: CGSize(width: availableSize.width * 2.0, height: availableSize.height)) + self.axisView.bounds = CGRect(origin: .zero, size: availableSize) + self.axisView.center = CGPoint(x: availableSize.width, y: availableSize.height / 2.0) + if self.imageViews.isEmpty { + var i = 0 for icon in component.appIcons { let image: UIImage? switch icon.imageName { @@ -89,31 +95,37 @@ final class AppIconsDemoComponent: Component { imageView.layer.cornerCurve = .continuous } imageView.image = image - self.containerView.addSubview(imageView) + if i == 0 { + self.containerView.addSubview(imageView) + } else { + self.axisView.addSubview(imageView) + } self.imageViews.append(imageView) + + i += 1 } } } + let radius: CGFloat = availableSize.width * 0.33 + let angleIncrement: CGFloat = 2 * .pi / CGFloat(self.imageViews.count - 1) + var i = 0 for view in self.imageViews { let position: CGPoint - switch i { - case 0: - position = CGPoint(x: availableSize.width * 0.5, y: availableSize.height * 0.333) - case 1: - position = CGPoint(x: availableSize.width * 0.333, y: availableSize.height * 0.667) - case 2: - position = CGPoint(x: availableSize.width * 0.667, y: availableSize.height * 0.667) - default: - position = CGPoint(x: availableSize.width * 0.5, y: availableSize.height * 0.5) - } - - if !self.animating { - view.center = position.offsetBy(dx: availableSize.width / 2.0, dy: 0.0) + if i == 0 { + position = CGPoint(x: availableSize.width, y: availableSize.height / 2.0) + } else { + let angle = CGFloat(i - 1) * angleIncrement + let xPosition = radius * cos(angle) + availableSize.width / 2.0 + let yPosition = radius * sin(angle) + availableSize.height / 2.0 + + position = CGPoint(x: xPosition, y: yPosition) } + view.center = position + i += 1 } @@ -131,6 +143,48 @@ final class AppIconsDemoComponent: Component { } self.isVisible = isDisplaying + let rotationDuration: Double = 12.0 + if isDisplaying { + if self.axisView.layer.animation(forKey: "rotationAnimation") == nil { + let rotationAnimation = CABasicAnimation(keyPath: "transform.rotation") + rotationAnimation.fromValue = 0.0 + rotationAnimation.toValue = 2.0 * CGFloat.pi + rotationAnimation.duration = rotationDuration + rotationAnimation.repeatCount = Float.infinity + self.axisView.layer.add(rotationAnimation, forKey: "rotationAnimation") + + var i = 0 + for view in self.imageViews { + if i == 0 { + let animation = CABasicAnimation(keyPath: "transform.scale") + animation.duration = 2.0 + animation.fromValue = 1.0 + animation.toValue = 1.15 + animation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + animation.autoreverses = true + animation.repeatCount = .infinity + view.layer.add(animation, forKey: "scale") + } else { + view.transform = CGAffineTransformMakeScale(0.8, 0.8) + + let rotationAnimation = CABasicAnimation(keyPath: "transform.rotation") + rotationAnimation.fromValue = 0.0 + rotationAnimation.toValue = -2.0 * CGFloat.pi + rotationAnimation.duration = rotationDuration + rotationAnimation.repeatCount = Float.infinity + view.layer.add(rotationAnimation, forKey: "rotationAnimation") + } + + i += 1 + } + } + } else { + self.axisView.layer.removeAllAnimations() + for view in self.imageViews { + view.layer.removeAllAnimations() + } + } + return availableSize } @@ -138,38 +192,37 @@ final class AppIconsDemoComponent: Component { func animateIn(availableSize: CGSize) { self.animating = true + let radius: CGFloat = availableSize.width * 2.5 + let angleIncrement: CGFloat = 2 * .pi / CGFloat(self.imageViews.count - 1) + var i = 0 for view in self.imageViews { - let from: CGPoint - let delay: Double - switch i { - case 0: - from = CGPoint(x: -availableSize.width * 0.333, y: -availableSize.height * 0.8) - delay = 0.1 - case 1: - from = CGPoint(x: -availableSize.width * 0.55, y: availableSize.height * 0.75) - delay = 0.15 - case 2: - from = CGPoint(x: availableSize.width * 0.9, y: availableSize.height * 0.75) - delay = 0.0 - default: - from = CGPoint(x: availableSize.width * 0.5, y: availableSize.height * 0.5) - delay = 0.0 - } - - let initialPosition = view.layer.position - view.layer.position = initialPosition.offsetBy(dx: from.x, dy: from.y) - - Queue.mainQueue().after(delay) { - view.layer.position = initialPosition - view.layer.animateScale(from: 3.0, to: 1.0, duration: 0.5, delay: 0.0, timingFunction: kCAMediaTimingFunctionSpring) - view.layer.animatePosition(from: from, to: CGPoint(), duration: 0.5, delay: 0.0, timingFunction: kCAMediaTimingFunctionSpring, additive: true) + if i > 0 { + let delay: Double = 0.033 * Double(i - 1) - if i == 2 { - self.animating = false + let angle = CGFloat(i - 1) * angleIncrement + let xPosition = radius * cos(angle) + let yPosition = radius * sin(angle) + + let from = CGPoint(x: xPosition, y: yPosition) + let initialPosition = view.layer.position + view.layer.position = initialPosition.offsetBy(dx: xPosition, dy: yPosition) + view.alpha = 0.0 + + Queue.mainQueue().after(delay) { + view.alpha = 1.0 + view.layer.position = initialPosition + view.layer.animateScale(from: 3.0, to: 0.8, duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring) + view.layer.animatePosition(from: from, to: CGPoint(), duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, additive: true) + view.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15) + + if i == self.imageViews.count - 1 { + self.animating = false + } } + } else { + } - i += 1 } } diff --git a/submodules/PremiumUI/Sources/CreateGiveawayController.swift b/submodules/PremiumUI/Sources/CreateGiveawayController.swift index ccaf5fba75..8c824cd475 100644 --- a/submodules/PremiumUI/Sources/CreateGiveawayController.swift +++ b/submodules/PremiumUI/Sources/CreateGiveawayController.swift @@ -19,6 +19,7 @@ import ItemListPeerActionItem import ShareWithPeersScreen import InAppPurchaseManager import UndoUI +import CountrySelectionUI private final class CreateGiveawayControllerArguments { let context: AccountContext @@ -26,17 +27,23 @@ private final class CreateGiveawayControllerArguments { let dismissInput: () -> Void let openPeersSelection: () -> Void let openChannelsSelection: () -> Void + let openCountriesSelection: () -> Void let openPremiumIntro: () -> Void let scrollToDate: () -> Void + let setItemIdWithRevealedOptions: (EnginePeer.Id?, EnginePeer.Id?) -> Void + let removeChannel: (EnginePeer.Id) -> Void - init(context: AccountContext, updateState: @escaping ((CreateGiveawayControllerState) -> CreateGiveawayControllerState) -> Void, dismissInput: @escaping () -> Void, openPeersSelection: @escaping () -> Void, openChannelsSelection: @escaping () -> Void, openPremiumIntro: @escaping () -> Void, scrollToDate: @escaping () -> Void) { + init(context: AccountContext, updateState: @escaping ((CreateGiveawayControllerState) -> CreateGiveawayControllerState) -> Void, dismissInput: @escaping () -> Void, openPeersSelection: @escaping () -> Void, openChannelsSelection: @escaping () -> Void, openCountriesSelection: @escaping () -> Void, openPremiumIntro: @escaping () -> Void, scrollToDate: @escaping () -> Void, setItemIdWithRevealedOptions: @escaping (EnginePeer.Id?, EnginePeer.Id?) -> Void, removeChannel: @escaping (EnginePeer.Id) -> Void) { self.context = context self.updateState = updateState self.dismissInput = dismissInput self.openPeersSelection = openPeersSelection self.openChannelsSelection = openChannelsSelection + self.openCountriesSelection = openCountriesSelection self.openPremiumIntro = openPremiumIntro self.scrollToDate = scrollToDate + self.setItemIdWithRevealedOptions = setItemIdWithRevealedOptions + self.removeChannel = removeChannel } } @@ -76,13 +83,13 @@ private enum CreateGiveawayEntry: ItemListNodeEntry { case subscriptionsInfo(PresentationTheme, String) case channelsHeader(PresentationTheme, String) - case channel(Int32, PresentationTheme, EnginePeer, Int32?) + case channel(Int32, PresentationTheme, EnginePeer, Int32?, Bool) case channelAdd(PresentationTheme, String) case channelsInfo(PresentationTheme, String) case usersHeader(PresentationTheme, String) - case usersAll(PresentationTheme, String, Bool) - case usersNew(PresentationTheme, String, Bool) + case usersAll(PresentationTheme, String, String, Bool) + case usersNew(PresentationTheme, String, String, Bool) case usersInfo(PresentationTheme, String) case timeHeader(PresentationTheme, String) @@ -133,7 +140,7 @@ private enum CreateGiveawayEntry: ItemListNodeEntry { return 6 case .channelsHeader: return 7 - case let .channel(index, _, _, _): + case let .channel(index, _, _, _, _): return 8 + index case .channelAdd: return 100 @@ -220,8 +227,8 @@ private enum CreateGiveawayEntry: ItemListNodeEntry { } else { return false } - case let .channel(lhsIndex, lhsTheme, lhsPeer, lhsBoosts): - if case let .channel(rhsIndex, rhsTheme, rhsPeer, rhsBoosts) = rhs, lhsIndex == rhsIndex, lhsTheme === rhsTheme, lhsPeer == rhsPeer, lhsBoosts == rhsBoosts { + case let .channel(lhsIndex, lhsTheme, lhsPeer, lhsBoosts, lhsIsRevealed): + if case let .channel(rhsIndex, rhsTheme, rhsPeer, rhsBoosts, rhsIsRevealed) = rhs, lhsIndex == rhsIndex, lhsTheme === rhsTheme, lhsPeer == rhsPeer, lhsBoosts == rhsBoosts, lhsIsRevealed == rhsIsRevealed { return true } else { return false @@ -244,14 +251,14 @@ private enum CreateGiveawayEntry: ItemListNodeEntry { } else { return false } - case let .usersAll(lhsTheme, lhsText, lhsSelected): - if case let .usersAll(rhsTheme, rhsText, rhsSelected) = rhs, lhsTheme === rhsTheme, lhsText == rhsText, lhsSelected == rhsSelected { + case let .usersAll(lhsTheme, lhsText, lhsSubtitle, lhsSelected): + if case let .usersAll(rhsTheme, rhsText, rhsSubtitle, rhsSelected) = rhs, lhsTheme === rhsTheme, lhsText == rhsText, lhsSubtitle == rhsSubtitle, lhsSelected == rhsSelected { return true } else { return false } - case let .usersNew(lhsTheme, lhsText, lhsSelected): - if case let .usersNew(rhsTheme, rhsText, rhsSelected) = rhs, lhsTheme === rhsTheme, lhsText == rhsText, lhsSelected == rhsSelected { + case let .usersNew(lhsTheme, lhsText, lhsSubtitle, lhsSelected): + if case let .usersNew(rhsTheme, rhsText, rhsSubtitle, rhsSelected) = rhs, lhsTheme === rhsTheme, lhsText == rhsText, lhsSubtitle == rhsSubtitle, lhsSelected == rhsSelected { return true } else { return false @@ -369,10 +376,14 @@ private enum CreateGiveawayEntry: ItemListNodeEntry { return ItemListTextItem(presentationData: presentationData, text: .plain(text), sectionId: self.section) case let .channelsHeader(_, text): return ItemListSectionHeaderItem(presentationData: presentationData, text: text, sectionId: self.section) - case let .channel(_, _, peer, boosts): - return ItemListPeerItem(presentationData: presentationData, dateTimeFormat: PresentationDateTimeFormat(), nameDisplayOrder: presentationData.nameDisplayOrder, context: arguments.context, peer: peer, presence: nil, text: boosts.flatMap { .text("this channel will receive \($0) boosts", .secondary) } ?? .none, label: .none, editing: ItemListPeerItemEditing(editable: false, editing: false, revealed: false), switchValue: nil, enabled: true, selectable: peer.id != arguments.context.account.peerId, sectionId: self.section, action: { + case let .channel(_, _, peer, boosts, isRevealed): + return ItemListPeerItem(presentationData: presentationData, dateTimeFormat: PresentationDateTimeFormat(), nameDisplayOrder: presentationData.nameDisplayOrder, context: arguments.context, peer: peer, presence: nil, text: boosts.flatMap { .text("this channel will receive \($0) boosts", .secondary) } ?? .none, label: .none, editing: ItemListPeerItemEditing(editable: boosts == nil, editing: false, revealed: isRevealed), switchValue: nil, enabled: true, selectable: peer.id != arguments.context.account.peerId, sectionId: self.section, action: { // arguments.openPeer(peer) - }, setPeerIdWithRevealedOptions: { _, _ in }, removePeer: { _ in }) + }, setPeerIdWithRevealedOptions: { lhs, rhs in + arguments.setItemIdWithRevealedOptions(lhs, rhs) + }, removePeer: { id in + arguments.removeChannel(id) + }) case let .channelAdd(theme, text): return ItemListPeerActionItem(presentationData: presentationData, icon: PresentationResourcesItemList.roundPlusIconImage(theme), title: text, alwaysPlain: false, hasSeparator: true, sectionId: self.section, height: .compactPeerList, color: .accent, editing: false, action: { arguments.openChannelsSelection() @@ -381,21 +392,35 @@ private enum CreateGiveawayEntry: ItemListNodeEntry { return ItemListTextItem(presentationData: presentationData, text: .plain(text), sectionId: self.section) case let .usersHeader(_, text): return ItemListSectionHeaderItem(presentationData: presentationData, text: text, sectionId: self.section) - case let .usersAll(_, title, isSelected): - return GiftOptionItem(presentationData: presentationData, context: arguments.context, title: title, subtitle: nil, isSelected: isSelected, sectionId: self.section, action: { + case let .usersAll(_, title, subtitle, isSelected): + return GiftOptionItem(presentationData: presentationData, context: arguments.context, title: title, subtitle: subtitle, subtitleActive: true, isSelected: isSelected, sectionId: self.section, action: { + var openSelection = false arguments.updateState { state in var updatedState = state + if !updatedState.onlyNewEligible { + openSelection = true + } updatedState.onlyNewEligible = false return updatedState } + if openSelection { + arguments.openCountriesSelection() + } }) - case let .usersNew(_, title, isSelected): - return GiftOptionItem(presentationData: presentationData, context: arguments.context, title: title, subtitle: nil, isSelected: isSelected, sectionId: self.section, action: { + case let .usersNew(_, title, subtitle, isSelected): + return GiftOptionItem(presentationData: presentationData, context: arguments.context, title: title, subtitle: subtitle, subtitleActive: true, isSelected: isSelected, sectionId: self.section, action: { + var openSelection = false arguments.updateState { state in var updatedState = state + if updatedState.onlyNewEligible { + openSelection = true + } updatedState.onlyNewEligible = true return updatedState } + if openSelection { + arguments.openCountriesSelection() + } }) case let .usersInfo(_, text): return ItemListTextItem(presentationData: presentationData, text: .plain(text), sectionId: self.section) @@ -474,7 +499,7 @@ private struct PremiumGiftProduct: Equatable { } } -private func createGiveawayControllerEntries(peerId: EnginePeer.Id, subject: CreateGiveawaySubject, state: CreateGiveawayControllerState, presentationData: PresentationData, peers: [EnginePeer.Id: EnginePeer], products: [PremiumGiftProduct], defaultPrice: (Int64, NSDecimalNumber)) -> [CreateGiveawayEntry] { +private func createGiveawayControllerEntries(peerId: EnginePeer.Id, subject: CreateGiveawaySubject, state: CreateGiveawayControllerState, presentationData: PresentationData, locale: Locale, peers: [EnginePeer.Id: EnginePeer], products: [PremiumGiftProduct], defaultPrice: (Int64, NSDecimalNumber)) -> [CreateGiveawayEntry] { var entries: [CreateGiveawayEntry] = [] switch subject { @@ -517,7 +542,7 @@ private func createGiveawayControllerEntries(peerId: EnginePeer.Id, subject: Cre let channels = [peerId] + state.channels for channelId in channels { if let channel = peers[channelId] { - entries.append(.channel(index, presentationData.theme, channel, channel.id == peerId ? state.subscriptions : nil)) + entries.append(.channel(index, presentationData.theme, channel, channel.id == peerId ? state.subscriptions : nil, false)) } index += 1 } @@ -525,8 +550,19 @@ private func createGiveawayControllerEntries(peerId: EnginePeer.Id, subject: Cre entries.append(.channelsInfo(presentationData.theme, "Choose the channels users need to be subscribed to take part in the giveaway")) entries.append(.usersHeader(presentationData.theme, "USERS ELIGIBLE FOR THE GIVEAWAY".uppercased())) - entries.append(.usersAll(presentationData.theme, "All subscribers", !state.onlyNewEligible)) - entries.append(.usersNew(presentationData.theme, "Only new subscribers", state.onlyNewEligible)) + + let countriesText: String + if state.countries.count > 2 { + countriesText = "from \(state.countries.count) countries" + } else if !state.countries.isEmpty { + let allCountries = state.countries.map { locale.localizedString(forRegionCode: $0) ?? $0 }.joined(separator: " and ") + countriesText = "from \(allCountries)" + } else { + countriesText = "from all countries" + } + + entries.append(.usersAll(presentationData.theme, "All subscribers", countriesText, !state.onlyNewEligible)) + entries.append(.usersNew(presentationData.theme, "Only new subscribers", countriesText, state.onlyNewEligible)) entries.append(.usersInfo(presentationData.theme, "Choose if you want to limit the giveaway only to those who joined the channel after the giveaway started.")) entries.append(.timeHeader(presentationData.theme, "DATE WHEN GIVEAWAY ENDS".uppercased())) @@ -602,6 +638,7 @@ private struct CreateGiveawayControllerState: Equatable { var onlyNewEligible: Bool var time: Int32 var pickingTimeLimit = false + var revealedItemId: EnginePeer.Id? = nil var updating = false } @@ -634,6 +671,7 @@ public func createGiveawayController(context: AccountContext, updatedPresentatio var buyActionImpl: (() -> Void)? var openPeersSelectionImpl: (() -> Void)? var openChannelsSelectionImpl: (() -> Void)? + var openCountriesSelectionImpl: (() -> Void)? var openPremiumIntroImpl: (() -> Void)? var presentControllerImpl: ((ViewController) -> Void)? var pushControllerImpl: ((ViewController) -> Void)? @@ -649,14 +687,33 @@ public func createGiveawayController(context: AccountContext, updatedPresentatio openPeersSelectionImpl?() }, openChannelsSelection: { openChannelsSelectionImpl?() + }, openCountriesSelection: { + openCountriesSelectionImpl?() }, openPremiumIntro: { openPremiumIntroImpl?() }, scrollToDate: { scrollToDateImpl?() + }, setItemIdWithRevealedOptions: { itemId, fromItemId in + updateState { state in + var updatedState = state + if (itemId == nil && fromItemId == state.revealedItemId) || (itemId != nil && fromItemId == nil) { + updatedState.revealedItemId = itemId + } + return updatedState + } + }, + removeChannel: { id in + updateState { state in + var updatedState = state + updatedState.channels = updatedState.channels.filter { $0 != id } + return updatedState + } }) let presentationData = updatedPresentationData?.signal ?? context.sharedContext.presentationData + let locale = localeWithStrings(context.sharedContext.currentPresentationData.with { $0 }.strings) + let productsAndDefaultPrice: Signal<([PremiumGiftProduct], (Int64, NSDecimalNumber)), NoError> = combineLatest( .single([]) |> then(context.engine.payments.premiumGiftCodeOptions(peerId: peerId)), context.inAppPurchaseManager?.availableProducts ?? .single([]) @@ -724,8 +781,16 @@ public func createGiveawayController(context: AccountContext, updatedPresentatio let previousState = previousState.swap(state) var animateChanges = false - if let previousState = previousState, previousState.pickingTimeLimit != state.pickingTimeLimit || previousState.mode != state.mode { - animateChanges = true + if let previousState = previousState { + if previousState.pickingTimeLimit != state.pickingTimeLimit { + animateChanges = true + } + if previousState.mode != state.mode { + animateChanges = true + } + if previousState.channels.count > state.channels.count { + animateChanges = true + } } var peers: [EnginePeer.Id: EnginePeer] = [:] @@ -736,7 +801,7 @@ public func createGiveawayController(context: AccountContext, updatedPresentatio } let controllerState = ItemListControllerState(presentationData: ItemListPresentationData(presentationData), title: .text(""), leftNavigationButton: leftNavigationButton, rightNavigationButton: nil, backNavigationButton: ItemListBackButton(title: presentationData.strings.Common_Back), animateChanges: true) - let listState = ItemListNodeState(presentationData: ItemListPresentationData(presentationData), entries: createGiveawayControllerEntries(peerId: peerId, subject: subject, state: state, presentationData: presentationData, peers: peers, products: products, defaultPrice: defaultPrice), style: .blocks, emptyStateItem: nil, headerItem: headerItem, footerItem: footerItem, crossfadeState: false, animateChanges: animateChanges) + let listState = ItemListNodeState(presentationData: ItemListPresentationData(presentationData), entries: createGiveawayControllerEntries(peerId: peerId, subject: subject, state: state, presentationData: presentationData, locale: locale, peers: peers, products: products, defaultPrice: defaultPrice), style: .blocks, emptyStateItem: nil, headerItem: headerItem, footerItem: footerItem, crossfadeState: false, animateChanges: animateChanges) return (controllerState, (listState, arguments)) } @@ -996,6 +1061,30 @@ public func createGiveawayController(context: AccountContext, updatedPresentatio }) } + openCountriesSelectionImpl = { + let state = stateValue.with { $0 } + + let stateContext = CountriesMultiselectionScreen.StateContext( + context: context, + subject: .countries, + initialSelectedCountries: state.countries + ) + let _ = (stateContext.ready |> filter { $0 } |> take(1) |> deliverOnMainQueue).startStandalone(next: { _ in + let controller = CountriesMultiselectionScreen( + context: context, + stateContext: stateContext, + completion: { countries in + updateState { state in + var updatedState = state + updatedState.countries = countries + return updatedState + } + } + ) + pushControllerImpl?(controller) + }) + } + openPremiumIntroImpl = { let controller = context.sharedContext.makePremiumIntroController(context: context, source: .settings, forceDark: false, dismissed: nil) pushControllerImpl?(controller) @@ -1023,5 +1112,8 @@ public func createGiveawayController(context: AccountContext, updatedPresentatio }) } + let countriesConfiguration = context.currentCountriesConfiguration.with { $0 } + AuthorizationSequenceCountrySelectionController.setupCountryCodes(countries: countriesConfiguration.countries, codesByPrefix: countriesConfiguration.countriesByPrefix) + return controller } diff --git a/submodules/PremiumUI/Sources/PremiumGiftCodeScreen.swift b/submodules/PremiumUI/Sources/PremiumGiftCodeScreen.swift index d6da2891ef..bf12f9b14c 100644 --- a/submodules/PremiumUI/Sources/PremiumGiftCodeScreen.swift +++ b/submodules/PremiumUI/Sources/PremiumGiftCodeScreen.swift @@ -264,6 +264,14 @@ private final class PremiumGiftCodeSheetContent: CombinedComponent { ) ) )) + } else if giftCode.isGiveaway { + tableItems.append(.init( + id: "to", + title: "To", + component: AnyComponent( + MultilineTextComponent(text: .plain(NSAttributedString(string: "No recipient", font: tableFont, textColor: tableTextColor))) + ) + )) } let giftTitle: String if giftCode.months == 12 { @@ -279,7 +287,12 @@ private final class PremiumGiftCodeSheetContent: CombinedComponent { ) )) - let giftReason = giftCode.isGiveaway ? "Giveaway" : "You were selected by the channel" + let giftReason: String + if giftCode.toPeerId == nil { + giftReason = "Incomplete Giveaway" + } else { + giftReason = giftCode.isGiveaway ? "Giveaway" : "You were selected by the channel" + } tableItems.append(.init( id: "reason", title: "Reason", diff --git a/submodules/PremiumUI/Sources/PremiumIntroScreen.swift b/submodules/PremiumUI/Sources/PremiumIntroScreen.swift index 8336a4d824..c08a532aeb 100644 --- a/submodules/PremiumUI/Sources/PremiumIntroScreen.swift +++ b/submodules/PremiumUI/Sources/PremiumIntroScreen.swift @@ -24,6 +24,7 @@ import UniversalMediaPlayer import CheckNode import AnimationCache import MultiAnimationRenderer +import TelegramNotices public enum PremiumSource: Equatable { public static func == (lhs: PremiumSource, rhs: PremiumSource) -> Bool { @@ -1428,12 +1429,15 @@ private final class PremiumIntroScreenContentComponent: CombinedComponent { var selectedProductId: String? var validPurchases: [InAppPurchaseManager.ReceiptPurchase] = [] + var newPerks: [String] = [] + var isPremium: Bool? private var disposable: Disposable? private(set) var configuration = PremiumIntroConfiguration.defaultValue private var stickersDisposable: Disposable? + private var newPerksDisposable: Disposable? private var preloadDisposableSet = DisposableSet() var price: String? { @@ -1511,12 +1515,27 @@ private final class PremiumIntroScreenContentComponent: CombinedComponent { } } }) + + + self.newPerksDisposable = (ApplicationSpecificNotice.dismissedPremiumAppIconsBadge(accountManager: context.sharedContext.accountManager) + |> deliverOnMainQueue).startStrict(next: { [weak self] dismissedPremiumAppIconsBadge in + guard let self else { + return + } + var newPerks: [String] = [] + if !dismissedPremiumAppIconsBadge { + newPerks.append(PremiumPerk.appIcons.identifier) + } + self.newPerks = newPerks + self.updated() + }) } deinit { self.disposable?.dispose() self.preloadDisposableSet.dispose() self.stickersDisposable?.dispose() + self.newPerksDisposable?.dispose() } } @@ -1807,7 +1826,7 @@ private final class PremiumIntroScreenContentComponent: CombinedComponent { subtitleColor: subtitleColor, arrowColor: arrowColor, accentColor: accentColor, - badge: perk.identifier == "stories" ? strings.Premium_New : nil + badge: state.newPerks.contains(perk.identifier) ? strings.Premium_New : nil ) ) ), @@ -1837,6 +1856,7 @@ private final class PremiumIntroScreenContentComponent: CombinedComponent { demoSubject = .animatedUserpics case .appIcons: demoSubject = .appIcons + let _ = ApplicationSpecificNotice.setDismissedPremiumAppIconsBadge(accountManager: accountContext.sharedContext.accountManager).startStandalone() case .animatedEmoji: demoSubject = .animatedEmoji case .emojiStatus: diff --git a/submodules/TelegramApi/Sources/Api0.swift b/submodules/TelegramApi/Sources/Api0.swift index b4f95b3848..35cfa09182 100644 --- a/submodules/TelegramApi/Sources/Api0.swift +++ b/submodules/TelegramApi/Sources/Api0.swift @@ -1153,8 +1153,8 @@ fileprivate let parsers: [Int32 : (BufferReader) -> Any?] = { dict[1042605427] = { return Api.payments.BankCardData.parse_bankCardData($0) } dict[-1222446760] = { return Api.payments.CheckedGiftCode.parse_checkedGiftCode($0) } dict[-1362048039] = { return Api.payments.ExportedInvoice.parse_exportedInvoice($0) } - dict[2054937690] = { return Api.payments.GiveawayInfo.parse_giveawayInfo($0) } - dict[952312868] = { return Api.payments.GiveawayInfo.parse_giveawayInfoResults($0) } + dict[1130879648] = { return Api.payments.GiveawayInfo.parse_giveawayInfo($0) } + dict[13456752] = { return Api.payments.GiveawayInfo.parse_giveawayInfoResults($0) } dict[-1610250415] = { return Api.payments.PaymentForm.parse_paymentForm($0) } dict[1891958275] = { return Api.payments.PaymentReceipt.parse_paymentReceipt($0) } dict[1314881805] = { return Api.payments.PaymentResult.parse_paymentResult($0) } diff --git a/submodules/TelegramApi/Sources/Api28.swift b/submodules/TelegramApi/Sources/Api28.swift index 3cfc5a52b9..15ed48860f 100644 --- a/submodules/TelegramApi/Sources/Api28.swift +++ b/submodules/TelegramApi/Sources/Api28.swift @@ -656,24 +656,27 @@ public extension Api.payments { } public extension Api.payments { enum GiveawayInfo: TypeConstructorDescription { - case giveawayInfo(flags: Int32, joinedTooEarlyDate: Int32?, adminDisallowedChatId: Int64?) - case giveawayInfoResults(flags: Int32, giftCodeSlug: String?, finishDate: Int32, winnersCount: Int32, activatedCount: Int32) + case giveawayInfo(flags: Int32, startDate: Int32, joinedTooEarlyDate: Int32?, adminDisallowedChatId: Int64?, disallowedCountry: String?) + case giveawayInfoResults(flags: Int32, startDate: Int32, giftCodeSlug: String?, finishDate: Int32, winnersCount: Int32, activatedCount: Int32) public func serialize(_ buffer: Buffer, _ boxed: Swift.Bool) { switch self { - case .giveawayInfo(let flags, let joinedTooEarlyDate, let adminDisallowedChatId): + case .giveawayInfo(let flags, let startDate, let joinedTooEarlyDate, let adminDisallowedChatId, let disallowedCountry): if boxed { - buffer.appendInt32(2054937690) + buffer.appendInt32(1130879648) } serializeInt32(flags, buffer: buffer, boxed: false) + serializeInt32(startDate, buffer: buffer, boxed: false) if Int(flags) & Int(1 << 1) != 0 {serializeInt32(joinedTooEarlyDate!, buffer: buffer, boxed: false)} if Int(flags) & Int(1 << 2) != 0 {serializeInt64(adminDisallowedChatId!, buffer: buffer, boxed: false)} + if Int(flags) & Int(1 << 4) != 0 {serializeString(disallowedCountry!, buffer: buffer, boxed: false)} break - case .giveawayInfoResults(let flags, let giftCodeSlug, let finishDate, let winnersCount, let activatedCount): + case .giveawayInfoResults(let flags, let startDate, let giftCodeSlug, let finishDate, let winnersCount, let activatedCount): if boxed { - buffer.appendInt32(952312868) + buffer.appendInt32(13456752) } serializeInt32(flags, buffer: buffer, boxed: false) + serializeInt32(startDate, buffer: buffer, boxed: false) if Int(flags) & Int(1 << 0) != 0 {serializeString(giftCodeSlug!, buffer: buffer, boxed: false)} serializeInt32(finishDate, buffer: buffer, boxed: false) serializeInt32(winnersCount, buffer: buffer, boxed: false) @@ -684,10 +687,10 @@ public extension Api.payments { public func descriptionFields() -> (String, [(String, Any)]) { switch self { - case .giveawayInfo(let flags, let joinedTooEarlyDate, let adminDisallowedChatId): - return ("giveawayInfo", [("flags", flags as Any), ("joinedTooEarlyDate", joinedTooEarlyDate as Any), ("adminDisallowedChatId", adminDisallowedChatId as Any)]) - case .giveawayInfoResults(let flags, let giftCodeSlug, let finishDate, let winnersCount, let activatedCount): - return ("giveawayInfoResults", [("flags", flags as Any), ("giftCodeSlug", giftCodeSlug as Any), ("finishDate", finishDate as Any), ("winnersCount", winnersCount as Any), ("activatedCount", activatedCount as Any)]) + case .giveawayInfo(let flags, let startDate, let joinedTooEarlyDate, let adminDisallowedChatId, let disallowedCountry): + return ("giveawayInfo", [("flags", flags as Any), ("startDate", startDate as Any), ("joinedTooEarlyDate", joinedTooEarlyDate as Any), ("adminDisallowedChatId", adminDisallowedChatId as Any), ("disallowedCountry", disallowedCountry as Any)]) + case .giveawayInfoResults(let flags, let startDate, let giftCodeSlug, let finishDate, let winnersCount, let activatedCount): + return ("giveawayInfoResults", [("flags", flags as Any), ("startDate", startDate as Any), ("giftCodeSlug", giftCodeSlug as Any), ("finishDate", finishDate as Any), ("winnersCount", winnersCount as Any), ("activatedCount", activatedCount as Any)]) } } @@ -695,14 +698,20 @@ public extension Api.payments { var _1: Int32? _1 = reader.readInt32() var _2: Int32? - if Int(_1!) & Int(1 << 1) != 0 {_2 = reader.readInt32() } - var _3: Int64? - if Int(_1!) & Int(1 << 2) != 0 {_3 = reader.readInt64() } + _2 = reader.readInt32() + var _3: Int32? + if Int(_1!) & Int(1 << 1) != 0 {_3 = reader.readInt32() } + var _4: Int64? + if Int(_1!) & Int(1 << 2) != 0 {_4 = reader.readInt64() } + var _5: String? + if Int(_1!) & Int(1 << 4) != 0 {_5 = parseString(reader) } let _c1 = _1 != nil - let _c2 = (Int(_1!) & Int(1 << 1) == 0) || _2 != nil - let _c3 = (Int(_1!) & Int(1 << 2) == 0) || _3 != nil - if _c1 && _c2 && _c3 { - return Api.payments.GiveawayInfo.giveawayInfo(flags: _1!, joinedTooEarlyDate: _2, adminDisallowedChatId: _3) + let _c2 = _2 != nil + let _c3 = (Int(_1!) & Int(1 << 1) == 0) || _3 != nil + let _c4 = (Int(_1!) & Int(1 << 2) == 0) || _4 != nil + let _c5 = (Int(_1!) & Int(1 << 4) == 0) || _5 != nil + if _c1 && _c2 && _c3 && _c4 && _c5 { + return Api.payments.GiveawayInfo.giveawayInfo(flags: _1!, startDate: _2!, joinedTooEarlyDate: _3, adminDisallowedChatId: _4, disallowedCountry: _5) } else { return nil @@ -711,21 +720,24 @@ public extension Api.payments { public static func parse_giveawayInfoResults(_ reader: BufferReader) -> GiveawayInfo? { var _1: Int32? _1 = reader.readInt32() - var _2: String? - if Int(_1!) & Int(1 << 0) != 0 {_2 = parseString(reader) } - var _3: Int32? - _3 = reader.readInt32() + var _2: Int32? + _2 = reader.readInt32() + var _3: String? + if Int(_1!) & Int(1 << 0) != 0 {_3 = parseString(reader) } var _4: Int32? _4 = reader.readInt32() var _5: Int32? _5 = reader.readInt32() + var _6: Int32? + _6 = reader.readInt32() let _c1 = _1 != nil - let _c2 = (Int(_1!) & Int(1 << 0) == 0) || _2 != nil - let _c3 = _3 != nil + let _c2 = _2 != nil + let _c3 = (Int(_1!) & Int(1 << 0) == 0) || _3 != nil let _c4 = _4 != nil let _c5 = _5 != nil - if _c1 && _c2 && _c3 && _c4 && _c5 { - return Api.payments.GiveawayInfo.giveawayInfoResults(flags: _1!, giftCodeSlug: _2, finishDate: _3!, winnersCount: _4!, activatedCount: _5!) + let _c6 = _6 != nil + if _c1 && _c2 && _c3 && _c4 && _c5 && _c6 { + return Api.payments.GiveawayInfo.giveawayInfoResults(flags: _1!, startDate: _2!, giftCodeSlug: _3, finishDate: _4!, winnersCount: _5!, activatedCount: _6!) } else { return nil diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Payments/GiftCodes.swift b/submodules/TelegramCore/Sources/TelegramEngine/Payments/GiftCodes.swift index a29c55ff4f..1304e70ce0 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Payments/GiftCodes.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Payments/GiftCodes.swift @@ -56,6 +56,7 @@ public enum PremiumGiveawayInfo: Equatable { public enum DisallowReason: Equatable { case joinedTooEarly(Int32) case channelAdmin(EnginePeer.Id) + case disallowedCountry(String) } case notQualified @@ -70,8 +71,8 @@ public enum PremiumGiveawayInfo: Equatable { case refunded } - case ongoing(status: OngoingStatus) - case finished(status: ResultStatus, finishDate: Int32, winnersCount: Int32, activatedCount: Int32) + case ongoing(startDate: Int32, status: OngoingStatus) + case finished(status: ResultStatus, startDate: Int32, finishDate: Int32, winnersCount: Int32, activatedCount: Int32) } public struct PrepaidGiveaway: Equatable { @@ -95,19 +96,21 @@ func _internal_getPremiumGiveawayInfo(account: Account, peerId: EnginePeer.Id, m |> map { result -> PremiumGiveawayInfo? in if let result { switch result { - case let .giveawayInfo(flags, joinedTooEarlyDate, adminDisallowedChatId): + case let .giveawayInfo(flags, startDate, joinedTooEarlyDate, adminDisallowedChatId, disallowedCountry): if (flags & (1 << 3)) != 0 { - return .ongoing(status: .almostOver) + return .ongoing(startDate: startDate, status: .almostOver) } else if (flags & (1 << 0)) != 0 { - return .ongoing(status: .participating) + return .ongoing(startDate: startDate, status: .participating) + } else if let disallowedCountry = disallowedCountry { + return .ongoing(startDate: startDate, status: .notAllowed(.disallowedCountry(disallowedCountry))) } else if let joinedTooEarlyDate = joinedTooEarlyDate { - return .ongoing(status: .notAllowed(.joinedTooEarly(joinedTooEarlyDate))) + return .ongoing(startDate: startDate, status: .notAllowed(.joinedTooEarly(joinedTooEarlyDate))) } else if let adminDisallowedChatId = adminDisallowedChatId { - return .ongoing(status: .notAllowed(.channelAdmin(EnginePeer.Id(namespace: Namespaces.Peer.CloudChannel, id: EnginePeer.Id.Id._internalFromInt64Value(adminDisallowedChatId))))) + return .ongoing(startDate: startDate, status: .notAllowed(.channelAdmin(EnginePeer.Id(namespace: Namespaces.Peer.CloudChannel, id: EnginePeer.Id.Id._internalFromInt64Value(adminDisallowedChatId))))) } else { - return .ongoing(status: .notQualified) + return .ongoing(startDate: startDate, status: .notQualified) } - case let .giveawayInfoResults(flags, giftCodeSlug, finishDate, winnersCount, activatedCount): + case let .giveawayInfoResults(flags, startDate, giftCodeSlug, finishDate, winnersCount, activatedCount): let status: PremiumGiveawayInfo.ResultStatus if let giftCodeSlug = giftCodeSlug { status = .won(slug: giftCodeSlug) @@ -116,7 +119,7 @@ func _internal_getPremiumGiveawayInfo(account: Account, peerId: EnginePeer.Id, m } else { status = .notWon } - return .finished(status: status, finishDate: finishDate, winnersCount: winnersCount, activatedCount: activatedCount) + return .finished(status: status, startDate: startDate, finishDate: finishDate, winnersCount: winnersCount, activatedCount: activatedCount) } } else { return nil diff --git a/submodules/TelegramNotices/Sources/Notices.swift b/submodules/TelegramNotices/Sources/Notices.swift index 5c07373ada..8534780507 100644 --- a/submodules/TelegramNotices/Sources/Notices.swift +++ b/submodules/TelegramNotices/Sources/Notices.swift @@ -183,6 +183,7 @@ private enum ApplicationSpecificGlobalNotice: Int32 { case displayStoryUnmuteTooltip = 49 case chatReplyOptionsTip = 50 case displayStoryInteractionGuide = 51 + case dismissedPremiumAppIconsBadge = 52 var key: ValueBoxKey { let v = ValueBoxKey(length: 4) @@ -444,6 +445,10 @@ private struct ApplicationSpecificNoticeKeys { static func displayStoryInteractionGuide() -> NoticeEntryKey { return NoticeEntryKey(namespace: noticeNamespace(namespace: globalNamespace), key: ApplicationSpecificGlobalNotice.displayStoryInteractionGuide.key) } + + static func dismissedPremiumAppIconsBadge() -> NoticeEntryKey { + return NoticeEntryKey(namespace: noticeNamespace(namespace: globalNamespace), key: ApplicationSpecificGlobalNotice.dismissedPremiumAppIconsBadge.key) + } } public struct ApplicationSpecificNotice { @@ -1717,4 +1722,25 @@ public struct ApplicationSpecificNotice { } |> take(1) } + + public static func setDismissedPremiumAppIconsBadge(accountManager: AccountManager) -> Signal { + return accountManager.transaction { transaction -> Void in + if let entry = CodableEntry(ApplicationSpecificBoolNotice()) { + transaction.setNotice(ApplicationSpecificNoticeKeys.dismissedPremiumAppIconsBadge(), entry) + } + } + |> ignoreValues + } + + public static func dismissedPremiumAppIconsBadge(accountManager: AccountManager) -> Signal { + return accountManager.noticeEntry(key: ApplicationSpecificNoticeKeys.dismissedPremiumAppIconsBadge()) + |> map { view -> Bool in + if let _ = view.value?.get(ApplicationSpecificBoolNotice.self) { + return true + } else { + return false + } + } + |> take(1) + } } diff --git a/submodules/TelegramStringFormatting/Sources/Geo.swift b/submodules/TelegramStringFormatting/Sources/Geo.swift index 6824af398b..91b7b36e0e 100644 --- a/submodules/TelegramStringFormatting/Sources/Geo.swift +++ b/submodules/TelegramStringFormatting/Sources/Geo.swift @@ -44,3 +44,12 @@ public func stringForDistance(strings: PresentationStrings, distance: CLLocation return distanceFormatter.string(fromDistance: distance) } + +public func flagEmoji(countryCode: String) -> String { + let base : UInt32 = 127397 + var flagString = "" + for v in countryCode.uppercased().unicodeScalars { + flagString.unicodeScalars.append(UnicodeScalar(base + v.value)!) + } + return flagString +} diff --git a/submodules/TelegramStringFormatting/Sources/MessageContentKind.swift b/submodules/TelegramStringFormatting/Sources/MessageContentKind.swift index ad09669c77..d2173e818c 100644 --- a/submodules/TelegramStringFormatting/Sources/MessageContentKind.swift +++ b/submodules/TelegramStringFormatting/Sources/MessageContentKind.swift @@ -354,6 +354,8 @@ public func mediaContentKind(_ media: EngineMedia, message: EngineMessage? = nil } case .story: return .story + case .giveaway: + return .giveaway default: return nil } diff --git a/submodules/TelegramStringFormatting/Sources/ServiceMessageStrings.swift b/submodules/TelegramStringFormatting/Sources/ServiceMessageStrings.swift index fbf17cfae4..ee3e0d9fb4 100644 --- a/submodules/TelegramStringFormatting/Sources/ServiceMessageStrings.swift +++ b/submodules/TelegramStringFormatting/Sources/ServiceMessageStrings.swift @@ -922,6 +922,10 @@ public func universalServiceMessageString(presentationData: (PresentationTheme, resultTitleString = strings.Conversation_StoryExpiredMentionTextOutgoing(compactPeerName) } attributedString = addAttributesToStringWithRanges(resultTitleString._tuple, body: bodyAttributes, argumentAttributes: [0: boldAttributes]) + } else if let _ = media as? TelegramMediaGiveaway { + let compactAuthorName = message.author?.compactDisplayTitle ?? "" + let resultTitleString = strings.Notification_GiveawayStarted(compactAuthorName) + attributedString = addAttributesToStringWithRanges(resultTitleString._tuple, body: bodyAttributes, argumentAttributes: [0: boldAttributes]) } } diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageBubbleContentNode/Sources/ChatMessageBubbleContentNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageBubbleContentNode/Sources/ChatMessageBubbleContentNode.swift index ce9ca5971e..5df60dd23e 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageBubbleContentNode/Sources/ChatMessageBubbleContentNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageBubbleContentNode/Sources/ChatMessageBubbleContentNode.swift @@ -32,6 +32,7 @@ public struct ChatMessageBubbleContentProperties { public let shareButtonOffset: CGPoint? public let hidesHeaders: Bool public let avatarOffset: CGFloat? + public let isDetached: Bool public init( hidesSimpleAuthorHeader: Bool, @@ -41,7 +42,8 @@ public struct ChatMessageBubbleContentProperties { forceAlignment: ChatMessageBubbleContentAlignment, shareButtonOffset: CGPoint? = nil, hidesHeaders: Bool = false, - avatarOffset: CGFloat? = nil + avatarOffset: CGFloat? = nil, + isDetached: Bool = false ) { self.hidesSimpleAuthorHeader = hidesSimpleAuthorHeader self.headerSpacing = headerSpacing @@ -51,6 +53,7 @@ public struct ChatMessageBubbleContentProperties { self.shareButtonOffset = shareButtonOffset self.hidesHeaders = hidesHeaders self.avatarOffset = avatarOffset + self.isDetached = isDetached } } @@ -169,6 +172,7 @@ open class ChatMessageBubbleContentNode: ASDisplayNode { return false } + public weak var itemNode: ChatMessageItemNodeProtocol? public weak var bubbleBackgroundNode: ChatMessageBackground? public weak var bubbleBackdropNode: ChatMessageBubbleBackdrop? diff --git a/submodules/TelegramUI/Components/LottieComponent/Sources/LottieComponent.swift b/submodules/TelegramUI/Components/LottieComponent/Sources/LottieComponent.swift index e9ee252dc8..b7111f6d38 100644 --- a/submodules/TelegramUI/Components/LottieComponent/Sources/LottieComponent.swift +++ b/submodules/TelegramUI/Components/LottieComponent/Sources/LottieComponent.swift @@ -233,14 +233,14 @@ public final class LottieComponent: Component { } } - public func playOnce(delay: Double = 0.0, completion: (() -> Void)? = nil) { + public func playOnce(delay: Double = 0.0, force: Bool = false, completion: (() -> Void)? = nil) { self.playOnceCompletion = completion guard let _ = self.animationInstance, let animationFrameRange = self.animationFrameRange else { self.scheduledPlayOnce = true return } - if !self.isEffectivelyVisible { + if !self.isEffectivelyVisible && !force { self.scheduledPlayOnce = true return } diff --git a/submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreen.swift b/submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreen.swift index 32ecd4de3c..60472d13d4 100644 --- a/submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreen.swift +++ b/submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreen.swift @@ -36,6 +36,7 @@ import LocationUI import LegacyMediaPickerUI import ReactionSelectionNode import VolumeSliderContextItem +import TelegramStringFormatting enum DrawingScreenType { case drawing @@ -3065,16 +3066,7 @@ public final class MediaEditorScreen: ViewController, UIDropInteractionDelegate if !self.didSetupStaticEmojiPack { self.staticEmojiPack.set(self.context.engine.stickers.loadedStickerPack(reference: .name("staticemoji"), forceActualized: false)) } - - func flag(countryCode: String) -> String { - let base : UInt32 = 127397 - var flagString = "" - for v in countryCode.uppercased().unicodeScalars { - flagString.unicodeScalars.append(UnicodeScalar(base + v.value)!) - } - return flagString - } - + var location: CLLocationCoordinate2D? if let subject = self.subject { if case let .asset(asset) = subject { @@ -3095,7 +3087,7 @@ public final class MediaEditorScreen: ViewController, UIDropInteractionDelegate if let self { let emojiFile: Signal if let countryCode { - let flagEmoji = flag(countryCode: countryCode) + let flag = flagEmoji(countryCode: countryCode) emojiFile = self.staticEmojiPack.get() |> filter { result in if case .result = result { @@ -3114,7 +3106,7 @@ public final class MediaEditorScreen: ViewController, UIDropInteractionDelegate break } } - if let displayText, displayText.hasPrefix(flagEmoji) { + if let displayText, displayText.hasPrefix(flag) { return true } else { return false diff --git a/submodules/TelegramUI/Components/ShareWithPeersScreen/BUILD b/submodules/TelegramUI/Components/ShareWithPeersScreen/BUILD index 6660ba5098..b5a0f4e959 100644 --- a/submodules/TelegramUI/Components/ShareWithPeersScreen/BUILD +++ b/submodules/TelegramUI/Components/ShareWithPeersScreen/BUILD @@ -41,6 +41,7 @@ swift_library( "//submodules/OverlayStatusController", "//submodules/UndoUI", "//submodules/TemporaryCachedPeerDataManager", + "//submodules/CountrySelectionUI", ], visibility = [ "//visibility:public", diff --git a/submodules/TelegramUI/Components/ShareWithPeersScreen/Sources/CountriesMultiselectionScreen.swift b/submodules/TelegramUI/Components/ShareWithPeersScreen/Sources/CountriesMultiselectionScreen.swift new file mode 100644 index 0000000000..e3b6f4b196 --- /dev/null +++ b/submodules/TelegramUI/Components/ShareWithPeersScreen/Sources/CountriesMultiselectionScreen.swift @@ -0,0 +1,1196 @@ +import Foundation +import UIKit +import Display +import AsyncDisplayKit +import ComponentFlow +import SwiftSignalKit +import ViewControllerComponent +import ComponentDisplayAdapters +import TelegramPresentationData +import AccountContext +import TelegramCore +import Postbox +import MultilineTextComponent +import PresentationDataUtils +import ButtonComponent +import AnimatedCounterComponent +import TokenListTextField +import TelegramStringFormatting +import LottieComponent +import UndoUI +import CountrySelectionUI + +final class CountriesMultiselectionScreenComponent: Component { + typealias EnvironmentType = ViewControllerComponentContainer.Environment + + let context: AccountContext + let stateContext: CountriesMultiselectionScreen.StateContext + let completion: ([String]) -> Void + + init( + context: AccountContext, + stateContext: CountriesMultiselectionScreen.StateContext, + completion: @escaping ([String]) -> Void + ) { + self.context = context + self.stateContext = stateContext + self.completion = completion + } + + static func ==(lhs: CountriesMultiselectionScreenComponent, rhs: CountriesMultiselectionScreenComponent) -> Bool { + if lhs.context !== rhs.context { + return false + } + if lhs.stateContext !== rhs.stateContext { + return false + } + return true + } + + private struct ItemLayout: Equatable { + struct Section: Equatable { + var id: Int + var insets: UIEdgeInsets + var itemHeight: CGFloat + var itemCount: Int + + var totalHeight: CGFloat + + init( + id: Int, + insets: UIEdgeInsets, + itemHeight: CGFloat, + itemCount: Int + ) { + self.id = id + self.insets = insets + self.itemHeight = itemHeight + self.itemCount = itemCount + + self.totalHeight = insets.top + itemHeight * CGFloat(itemCount) + insets.bottom + } + } + + var containerSize: CGSize + var containerInset: CGFloat + var bottomInset: CGFloat + var topInset: CGFloat + var sideInset: CGFloat + var navigationHeight: CGFloat + var sections: [Section] + + var contentHeight: CGFloat + + init(containerSize: CGSize, containerInset: CGFloat, bottomInset: CGFloat, topInset: CGFloat, sideInset: CGFloat, navigationHeight: CGFloat, sections: [Section]) { + self.containerSize = containerSize + self.containerInset = containerInset + self.bottomInset = bottomInset + self.topInset = topInset + self.sideInset = sideInset + self.navigationHeight = navigationHeight + self.sections = sections + + var contentHeight: CGFloat = 0.0 + contentHeight += navigationHeight + for section in sections { + contentHeight += section.totalHeight + } + contentHeight += bottomInset + self.contentHeight = contentHeight + } + } + + private final class ScrollView: UIScrollView { + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + return super.hitTest(point, with: event) + } + + override func touchesShouldCancel(in view: UIView) -> Bool { + return true + } + } + + final class AnimationHint { + let contentReloaded: Bool + + init( + contentReloaded: Bool + ) { + self.contentReloaded = contentReloaded + } + } + + final class View: UIView, UIScrollViewDelegate { + private let containerView: UIView + private let backgroundView: UIImageView + + private let navigationContainerView: UIView + private let navigationBackgroundView: BlurredBackgroundView + private let navigationTitle = ComponentView() + private let navigationLeftButton = ComponentView() + private let navigationRightButton = ComponentView() + private let navigationSeparatorLayer: SimpleLayer + private let navigationTextFieldState = TokenListTextField.ExternalState() + private let navigationTextField = ComponentView() + private let textFieldSeparatorLayer: SimpleLayer + + private let emptyResultsTitle = ComponentView() + private let emptyResultsText = ComponentView() + private let emptyResultsAnimation = ComponentView() + + private let scrollView: ScrollView + private let scrollContentClippingView: SparseContainerView + private let scrollContentView: UIView + + private let indexNode: CollectionIndexNode + + private let bottomBackgroundView: BlurredBackgroundView + private let bottomSeparatorLayer: SimpleLayer + private let actionButton = ComponentView() + + private let countryTemplateItem = ComponentView() + + private let itemContainerView: UIView + private var visibleSectionHeaders: [Int: ComponentView] = [:] + private var visibleItems: [AnyHashable: ComponentView] = [:] + + private var ignoreScrolling: Bool = false + private var isDismissed: Bool = false + + private var selectedCountries: [String] = [] + + private var component: CountriesMultiselectionScreenComponent? + private weak var state: EmptyComponentState? + private var environment: ViewControllerComponentContainer.Environment? + private var itemLayout: ItemLayout? + + private var topOffsetDistance: CGFloat? + + private var defaultStateValue: CountriesMultiselectionScreen.State? + private var stateDisposable: Disposable? + + private var searchStateContext: CountriesMultiselectionScreen.StateContext? + private var searchStateDisposable: Disposable? + + private let postingAvailabilityDisposable = MetaDisposable() + + private let hapticFeedback = HapticFeedback() + + private var effectiveStateValue: CountriesMultiselectionScreen.State? { + return self.searchStateContext?.stateValue ?? self.defaultStateValue + } + + override init(frame: CGRect) { + self.containerView = SparseContainerView() + + self.backgroundView = UIImageView() + + self.navigationContainerView = SparseContainerView() + self.navigationBackgroundView = BlurredBackgroundView(color: .clear, enableBlur: true) + self.navigationSeparatorLayer = SimpleLayer() + self.textFieldSeparatorLayer = SimpleLayer() + + self.bottomBackgroundView = BlurredBackgroundView(color: .clear, enableBlur: true) + self.bottomSeparatorLayer = SimpleLayer() + + self.scrollView = ScrollView() + + self.scrollContentClippingView = SparseContainerView() + self.scrollContentClippingView.clipsToBounds = true + + self.scrollContentView = UIView() + + self.itemContainerView = UIView() + self.itemContainerView.clipsToBounds = true + self.itemContainerView.layer.cornerRadius = 10.0 + + self.indexNode = CollectionIndexNode() + + super.init(frame: frame) + + self.addSubview(self.containerView) + self.containerView.addSubview(self.backgroundView) + + self.scrollView.delaysContentTouches = true + self.scrollView.canCancelContentTouches = true + self.scrollView.clipsToBounds = false + if #available(iOSApplicationExtension 11.0, iOS 11.0, *) { + self.scrollView.contentInsetAdjustmentBehavior = .never + } + if #available(iOS 13.0, *) { + self.scrollView.automaticallyAdjustsScrollIndicatorInsets = false + } + self.scrollView.showsVerticalScrollIndicator = false + self.scrollView.showsHorizontalScrollIndicator = false + self.scrollView.alwaysBounceHorizontal = false + self.scrollView.alwaysBounceVertical = true + self.scrollView.scrollsToTop = false + self.scrollView.delegate = self + self.scrollView.clipsToBounds = true + + self.containerView.addSubview(self.scrollContentClippingView) + self.scrollContentClippingView.addSubview(self.scrollView) + + self.scrollView.addSubview(self.scrollContentView) + + self.scrollContentView.addSubview(self.itemContainerView) + + self.containerView.addSubview(self.navigationContainerView) + self.navigationContainerView.addSubview(self.navigationBackgroundView) + self.navigationContainerView.layer.addSublayer(self.navigationSeparatorLayer) + + self.containerView.addSubview(self.bottomBackgroundView) + self.containerView.layer.addSublayer(self.bottomSeparatorLayer) + + self.containerView.addSubnode(self.indexNode) + + self.indexNode.indexSelected = { [weak self] section in + guard let self, let sections = self.effectiveStateValue?.sections, let itemLayout = self.itemLayout else { + return + } + + guard let index = sections.firstIndex(where: { $0.0 == section }) else { + return + } + + var contentOffset: CGFloat = 0.0 + for i in 0 ..< index { + let section = itemLayout.sections[i] + contentOffset += section.totalHeight + } + + self.scrollView.setContentOffset(CGPoint(x: 0.0, y: min(contentOffset, self.scrollView.contentSize.height - self.scrollView.bounds.height + self.scrollView.contentInset.bottom)), animated: false) + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + self.stateDisposable?.dispose() + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + if !self.ignoreScrolling { + self.updateScrolling(transition: .immediate) + } + } + + func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer) { + guard let itemLayout = self.itemLayout, let topOffsetDistance = self.topOffsetDistance else { + return + } + + if scrollView.contentOffset.y <= -100.0 && velocity.y <= -2.0 { + } else { + var topOffset = -self.scrollView.bounds.minY + itemLayout.topInset + if topOffset > 0.0 { + topOffset = max(0.0, topOffset) + + if topOffset < topOffsetDistance { + //targetContentOffset.pointee.y = scrollView.contentOffset.y + //scrollView.setContentOffset(CGPoint(x: 0.0, y: itemLayout.topInset), animated: true) + } + } + } + } + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + if !self.bounds.contains(point) { + return nil + } + + if let result = self.navigationContainerView.hitTest(self.convert(point, to: self.navigationContainerView), with: event) { + return result + } + + let result = super.hitTest(point, with: event) + return result + } + + @objc private func dimTapGesture(_ recognizer: UITapGestureRecognizer) { + if case .ended = recognizer.state { + guard let environment = self.environment, let controller = environment.controller() as? CountriesMultiselectionScreen else { + return + } + controller.requestDismiss() + } + } + + private func updateScrolling(transition: Transition) { + guard let component = self.component, let environment = self.environment, let itemLayout = self.itemLayout else { + return + } + guard let stateValue = self.effectiveStateValue else { + return + } + + var topOffset = -self.scrollView.bounds.minY + itemLayout.topInset + topOffset = max(0.0, topOffset) + transition.setTransform(layer: self.backgroundView.layer, transform: CATransform3DMakeTranslation(0.0, topOffset + itemLayout.containerInset, 0.0)) + transition.setPosition(view: self.navigationContainerView, position: CGPoint(x: 0.0, y: topOffset + itemLayout.containerInset)) + + let bottomDistance = itemLayout.contentHeight - self.scrollView.bounds.maxY + let bottomAlphaDistance: CGFloat = 30.0 + var bottomAlpha: CGFloat = bottomDistance / bottomAlphaDistance + bottomAlpha = max(0.0, min(1.0, bottomAlpha)) + + var visibleBounds = self.scrollView.bounds + visibleBounds.origin.y -= itemLayout.topInset + visibleBounds.size.height += itemLayout.topInset + + var visibleFrame = self.scrollView.frame + visibleFrame.origin.x = 0.0 + visibleFrame.origin.y -= itemLayout.topInset + visibleFrame.size.height += itemLayout.topInset + + var validIds: [AnyHashable] = [] + var validSectionHeaders: [AnyHashable] = [] + var sectionOffset: CGFloat = itemLayout.navigationHeight + + for sectionIndex in 0 ..< itemLayout.sections.count { + let section = itemLayout.sections[sectionIndex] + + var minSectionHeader: UIView? + do { + var sectionHeaderFrame = CGRect(origin: CGPoint(x: itemLayout.sideInset, y: itemLayout.containerInset + sectionOffset - self.scrollView.bounds.minY + itemLayout.topInset), size: CGSize(width: itemLayout.containerSize.width, height: section.insets.top)) + + let sectionHeaderMinY = topOffset + itemLayout.containerInset + itemLayout.navigationHeight + let sectionHeaderMaxY = itemLayout.containerInset + sectionOffset - self.scrollView.bounds.minY + itemLayout.topInset + section.totalHeight - 28.0 + + sectionHeaderFrame.origin.y = max(sectionHeaderFrame.origin.y, sectionHeaderMinY) + sectionHeaderFrame.origin.y = min(sectionHeaderFrame.origin.y, sectionHeaderMaxY) + + if visibleFrame.intersects(sectionHeaderFrame), self.searchStateContext == nil { + validSectionHeaders.append(section.id) + let sectionHeader: ComponentView + var sectionHeaderTransition = transition + if let current = self.visibleSectionHeaders[section.id] { + sectionHeader = current + } else { + if !transition.animation.isImmediate { + sectionHeaderTransition = .immediate + } + sectionHeader = ComponentView() + self.visibleSectionHeaders[section.id] = sectionHeader + } + + let sectionTitle = stateValue.sections[sectionIndex].0 + let _ = sectionHeader.update( + transition: sectionHeaderTransition, + component: AnyComponent(SectionHeaderComponent( + theme: environment.theme, + style: .plain, + title: sectionTitle, + actionTitle: nil, + action: nil + )), + environment: {}, + containerSize: sectionHeaderFrame.size + ) + if let sectionHeaderView = sectionHeader.view { + if sectionHeaderView.superview == nil { + self.scrollContentClippingView.addSubview(sectionHeaderView) + + if !transition.animation.isImmediate { + sectionHeaderView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25) + } + } + let sectionXOffset = self.scrollView.frame.minX + if minSectionHeader == nil { + minSectionHeader = sectionHeaderView + } + sectionHeaderTransition.setFrame(view: sectionHeaderView, frame: sectionHeaderFrame.offsetBy(dx: sectionXOffset, dy: 0.0)) + } + } + } + + let (_, countries) = stateValue.sections[sectionIndex] + for i in 0 ..< countries.count { + let itemFrame = CGRect(origin: CGPoint(x: itemLayout.sideInset, y: sectionOffset + section.insets.top + CGFloat(i) * section.itemHeight), size: CGSize(width: itemLayout.containerSize.width, height: section.itemHeight)) + if !visibleBounds.intersects(itemFrame) { + continue + } + + let country = countries[i] + let itemId = AnyHashable(country.id) + validIds.append(itemId) + + var itemTransition = transition + let visibleItem: ComponentView + if let current = self.visibleItems[itemId] { + visibleItem = current + } else { + visibleItem = ComponentView() + if !transition.animation.isImmediate { + itemTransition = .immediate + } + self.visibleItems[itemId] = visibleItem + } + + let isSelected = self.selectedCountries.contains(country.id) + let _ = visibleItem.update( + transition: itemTransition, + component: AnyComponent(CountryListItemComponent( + context: component.context, + theme: environment.theme, + title: "\(country.flag) \(country.name)", + selectionState: .editing(isSelected: isSelected, isTinted: false), + hasNext: true, + action: { [weak self] in + guard let self, let environment = self.environment, let controller = environment.controller() as? CountriesMultiselectionScreen else { + return + } + let update = { + let transition = Transition(animation: .curve(duration: 0.35, curve: .spring)) + self.state?.updated(transition: transition) + + if self.searchStateContext != nil { + if let navigationTextFieldView = self.navigationTextField.view as? TokenListTextField.View { + navigationTextFieldView.clearText() + } + } + } + + let index = self.selectedCountries.firstIndex(of: country.id) + let toggleCountry = { + if let index { + self.selectedCountries.remove(at: index) + } else { + self.selectedCountries.append(country.id) + } + update() + } + + let limit = component.context.userLimits.maxGiveawayCountriesCount + if self.selectedCountries.count >= limit, index == nil { + self.hapticFeedback.error() + + let presentationData = component.context.sharedContext.currentPresentationData.with { $0 } + controller.present(UndoOverlayController(presentationData: presentationData, content: .info(title: nil, text: "You can select maximum \(limit) countries.", timeout: nil), elevatedLayout: false, position: .bottom, animateInAsReplacement: false, action: { _ in return false }), in: .current) + return + } + toggleCountry() + }) + ), + environment: {}, + containerSize: itemFrame.size + ) + if let itemView = visibleItem.view { + if itemView.superview == nil { + self.itemContainerView.addSubview(itemView) + } + itemTransition.setFrame(view: itemView, frame: itemFrame) + } + } + sectionOffset += section.totalHeight + } + + var removeIds: [AnyHashable] = [] + for (id, item) in self.visibleItems { + if !validIds.contains(id) { + removeIds.append(id) + if let itemView = item.view { + if !transition.animation.isImmediate { + 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.visibleItems.removeValue(forKey: id) + } + + var removeSectionHeaderIds: [Int] = [] + for (id, item) in self.visibleSectionHeaders { + if !validSectionHeaders.contains(id) { + removeSectionHeaderIds.append(id) + if let itemView = item.view { + if !transition.animation.isImmediate { + itemView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, removeOnCompletion: false, completion: { _ in + itemView.removeFromSuperview() + }) + } else { + itemView.removeFromSuperview() + } + } + } + } + for id in removeSectionHeaderIds { + self.visibleSectionHeaders.removeValue(forKey: id) + } + + let fadeTransition = Transition.easeInOut(duration: 0.25) + if let searchStateContext = self.searchStateContext, case let .countriesSearch(query) = searchStateContext.subject, let value = searchStateContext.stateValue, value.sections.isEmpty { + let sideInset: CGFloat = 44.0 + let emptyAnimationHeight = 148.0 + let topInset: CGFloat = topOffset + itemLayout.containerInset + 40.0 + let bottomInset: CGFloat = max(environment.safeInsets.bottom, environment.inputHeight) + let visibleHeight = visibleFrame.height + let emptyAnimationSpacing: CGFloat = 8.0 + let emptyTextSpacing: CGFloat = 8.0 + + let emptyResultsTitleSize = self.emptyResultsTitle.update( + transition: .immediate, + component: AnyComponent( + MultilineTextComponent( + text: .plain(NSAttributedString(string: environment.strings.Contacts_Search_NoResults, font: Font.semibold(17.0), textColor: environment.theme.list.itemSecondaryTextColor)), + horizontalAlignment: .center + ) + ), + environment: {}, + containerSize: visibleFrame.size + ) + let emptyResultsTextSize = self.emptyResultsText.update( + transition: .immediate, + component: AnyComponent( + MultilineTextComponent( + text: .plain(NSAttributedString(string: environment.strings.Contacts_Search_NoResultsQueryDescription(query).string, font: Font.regular(15.0), textColor: environment.theme.list.itemSecondaryTextColor)), + horizontalAlignment: .center, + maximumNumberOfLines: 0 + ) + ), + environment: {}, + containerSize: CGSize(width: visibleFrame.width - sideInset * 2.0, height: visibleFrame.height) + ) + 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 + emptyResultsTextSize.height + emptyTextSpacing + let emptyAnimationY = topInset + floorToScreenPixels((visibleHeight - topInset - bottomInset - emptyTotalHeight) / 2.0) + + let emptyResultsAnimationFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((visibleFrame.width - emptyResultsAnimationSize.width) / 2.0), y: emptyAnimationY), size: emptyResultsAnimationSize) + + let emptyResultsTitleFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((visibleFrame.width - emptyResultsTitleSize.width) / 2.0), y: emptyResultsAnimationFrame.maxY + emptyAnimationSpacing), size: emptyResultsTitleSize) + + let emptyResultsTextFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((visibleFrame.width - emptyResultsTextSize.width) / 2.0), y: emptyResultsTitleFrame.maxY + emptyTextSpacing), size: emptyResultsTextSize) + + 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.scrollView.addSubview(view) + view.playOnce() + } + view.bounds = CGRect(origin: .zero, size: emptyResultsAnimationFrame.size) + transition.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.scrollView.addSubview(view) + } + view.bounds = CGRect(origin: .zero, size: emptyResultsTitleFrame.size) + transition.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.scrollView.addSubview(view) + } + view.bounds = CGRect(origin: .zero, size: emptyResultsTextFrame.size) + transition.setPosition(view: view, position: emptyResultsTextFrame.center) + } + } else { + if let view = self.emptyResultsAnimation.view { + fadeTransition.setAlpha(view: view, alpha: 0.0, completion: { _ in + view.removeFromSuperview() + }) + } + if let view = self.emptyResultsTitle.view { + fadeTransition.setAlpha(view: view, alpha: 0.0, completion: { _ in + view.removeFromSuperview() + }) + } + if let view = self.emptyResultsText.view { + fadeTransition.setAlpha(view: view, alpha: 0.0, completion: { _ in + view.removeFromSuperview() + }) + } + } + } + + func update(component: CountriesMultiselectionScreenComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + guard !self.isDismissed else { + return availableSize + } + let animationHint = transition.userData(AnimationHint.self) + + var contentTransition = transition + if let animationHint, animationHint.contentReloaded, !transition.animation.isImmediate { + contentTransition = .immediate + } + + let environment = environment[ViewControllerComponentContainer.Environment.self].value + let themeUpdated = self.environment?.theme !== environment.theme + + let resetScrolling = self.scrollView.bounds.width != availableSize.width + + let sideInset: CGFloat = 0.0 + + let containerWidth: CGFloat + if environment.metrics.isTablet { + containerWidth = 414.0 + } else { + containerWidth = availableSize.width + } + let containerSideInset = floorToScreenPixels((availableSize.width - containerWidth) / 2.0) + + if self.component == nil { + var applyState = false + self.defaultStateValue = component.stateContext.stateValue + self.selectedCountries = Array(component.stateContext.initialSelectedCountries) + + self.stateDisposable = (component.stateContext.state + |> deliverOnMainQueue).start(next: { [weak self] stateValue in + guard let self else { + return + } + self.defaultStateValue = stateValue + if applyState { + self.state?.updated(transition: .immediate) + } + }) + applyState = true + } + + self.component = component + self.state = state + self.environment = environment + + if themeUpdated { + self.scrollView.indicatorStyle = environment.theme.overallDarkAppearance ? .white : .black + + self.backgroundView.image = generateImage(CGSize(width: 20.0, height: 20.0), rotatedContext: { size, context in + context.clear(CGRect(origin: CGPoint(), size: size)) + + context.setFillColor(environment.theme.list.plainBackgroundColor.cgColor) + context.fillEllipse(in: CGRect(origin: CGPoint(), size: size)) + context.fill(CGRect(origin: CGPoint(x: 0.0, y: size.height * 0.5), size: CGSize(width: size.width, height: size.height * 0.5))) + })?.stretchableImage(withLeftCapWidth: 10, topCapHeight: 19) + + self.navigationBackgroundView.updateColor(color: environment.theme.rootController.navigationBar.blurredBackgroundColor, transition: .immediate) + self.navigationSeparatorLayer.backgroundColor = environment.theme.rootController.navigationBar.separatorColor.cgColor + self.bottomBackgroundView.updateColor(color: environment.theme.rootController.navigationBar.blurredBackgroundColor, transition: .immediate) + self.bottomSeparatorLayer.backgroundColor = environment.theme.rootController.navigationBar.separatorColor.cgColor + + self.textFieldSeparatorLayer.backgroundColor = environment.theme.rootController.navigationBar.separatorColor.cgColor + } + + + let itemsContainerWidth = containerWidth + + var tokens: [TokenListTextField.Token] = [] + for countryId in self.selectedCountries { + guard let stateValue = self.defaultStateValue else { + continue + } + + var tokenCountry: CountriesMultiselectionScreen.CountryItem? + outer: for (_, countries) in stateValue.sections { + for country in countries { + if country.id == countryId { + tokenCountry = country + break outer + } + } + } + + guard let tokenCountry else { + continue + } + + tokens.append(TokenListTextField.Token( + id: AnyHashable(countryId), + title: tokenCountry.name, + fixedPosition: nil, + content: .emoji(tokenCountry.flag) + )) + } + + let placeholder: String = "Search" + self.navigationTextField.parentState = state + let navigationTextFieldSize = self.navigationTextField.update( + transition: transition, + component: AnyComponent(TokenListTextField( + externalState: self.navigationTextFieldState, + context: component.context, + theme: environment.theme, + placeholder: placeholder, + tokens: tokens, + sideInset: sideInset, + deleteToken: { [weak self] tokenId in + guard let self else { + return + } + if let countryId = tokenId.base as? String { + self.selectedCountries.removeAll(where: { $0 == countryId }) + } + self.state?.updated(transition: Transition(animation: .curve(duration: 0.35, curve: .spring))) + } + )), + environment: {}, + containerSize: CGSize(width: containerWidth, height: 1000.0) + ) + + if !self.navigationTextFieldState.text.isEmpty { + if let searchStateContext = self.searchStateContext, searchStateContext.subject == .countriesSearch(query: self.navigationTextFieldState.text) { + } else { + self.searchStateDisposable?.dispose() + let searchStateContext = CountriesMultiselectionScreen.StateContext(context: component.context, subject: .countriesSearch(query: self.navigationTextFieldState.text)) + var applyState = false + self.searchStateDisposable = (searchStateContext.ready |> filter { $0 } |> take(1) |> deliverOnMainQueue).start(next: { [weak self] _ in + guard let self else { + return + } + self.searchStateContext = searchStateContext + if applyState { + self.state?.updated(transition: Transition(animation: .none).withUserData(AnimationHint(contentReloaded: true))) + } + }) + applyState = true + } + } else if let _ = self.searchStateContext { + self.searchStateContext = nil + self.searchStateDisposable?.dispose() + self.searchStateDisposable = nil + + contentTransition = contentTransition.withUserData(AnimationHint(contentReloaded: true)) + } + + let countryItemSize = self.countryTemplateItem.update( + transition: transition, + component: AnyComponent(CountryListItemComponent( + context: component.context, + theme: environment.theme, + title: "Title", + selectionState: .editing(isSelected: false, isTinted: false), + hasNext: true, + action: {} + )), + environment: {}, + containerSize: CGSize(width: itemsContainerWidth, height: 1000.0) + ) + + var sections: [ItemLayout.Section] = [] + if let stateValue = self.effectiveStateValue { + + var id: Int = 0 + for (_, countries) in stateValue.sections { + sections.append(ItemLayout.Section( + id: id, + insets: UIEdgeInsets(top: self.searchStateContext != nil ? 0.0 : 28.0, left: 0.0, bottom: 0.0, right: 0.0), + itemHeight: countryItemSize.height, + itemCount: countries.count + )) + id += 1 + } + } + + let containerInset: CGFloat = environment.statusBarHeight + + var navigationHeight: CGFloat = 56.0 + let navigationSideInset: CGFloat = 16.0 + var navigationButtonsWidth: CGFloat = 0.0 + + let navigationLeftButtonSize = self.navigationLeftButton.update( + transition: transition, + component: AnyComponent(Button( + content: AnyComponent(Text(text: environment.strings.Common_Cancel, font: Font.regular(17.0), color: environment.theme.rootController.navigationBar.accentTextColor)), + action: { [weak self] in + guard let self, let environment = self.environment, let controller = environment.controller() as? CountriesMultiselectionScreen else { + return + } + controller.requestDismiss() + } + ).minSize(CGSize(width: navigationHeight, height: navigationHeight))), + environment: {}, + containerSize: CGSize(width: availableSize.width, height: navigationHeight) + ) + let navigationLeftButtonFrame = CGRect(origin: CGPoint(x: containerSideInset + navigationSideInset, y: floor((navigationHeight - navigationLeftButtonSize.height) * 0.5)), size: navigationLeftButtonSize) + if let navigationLeftButtonView = self.navigationLeftButton.view { + if navigationLeftButtonView.superview == nil { + self.navigationContainerView.addSubview(navigationLeftButtonView) + } + transition.setFrame(view: navigationLeftButtonView, frame: navigationLeftButtonFrame) + } + navigationButtonsWidth += navigationLeftButtonSize.width + navigationSideInset + + let actionButtonTitle = "Save Countries" + let title = "Select Countries" + let subtitle = "select up to \(component.context.userLimits.maxGiveawayCountriesCount) countries" + + let titleComponent = AnyComponent( + List([ + AnyComponentWithIdentity( + id: "title", + component: AnyComponent(Text(text: title, font: Font.semibold(17.0), color: environment.theme.rootController.navigationBar.primaryTextColor)) + ), + AnyComponentWithIdentity( + id: "subtitle", + component: AnyComponent(Text(text: subtitle, font: Font.regular(13.0), color: environment.theme.rootController.navigationBar.secondaryTextColor)) + ) + ], + centerAlignment: true) + ) + + let navigationTitleSize = self.navigationTitle.update( + transition: .immediate, + component: titleComponent, + environment: {}, + containerSize: CGSize(width: containerWidth - navigationButtonsWidth, height: navigationHeight) + ) + let navigationTitleFrame = CGRect(origin: CGPoint(x: containerSideInset + floor((containerWidth - navigationTitleSize.width) * 0.5), y: floor((navigationHeight - navigationTitleSize.height) * 0.5)), size: navigationTitleSize) + if let navigationTitleView = self.navigationTitle.view { + if navigationTitleView.superview == nil { + self.navigationContainerView.addSubview(navigationTitleView) + } + transition.setPosition(view: navigationTitleView, position: navigationTitleFrame.center) + navigationTitleView.bounds = CGRect(origin: CGPoint(), size: navigationTitleFrame.size) + } + + let navigationTextFieldFrame = CGRect(origin: CGPoint(x: containerSideInset, y: navigationHeight), size: navigationTextFieldSize) + if let navigationTextFieldView = self.navigationTextField.view { + if navigationTextFieldView.superview == nil { + self.navigationContainerView.addSubview(navigationTextFieldView) + self.navigationContainerView.layer.addSublayer(self.textFieldSeparatorLayer) + } + transition.setFrame(view: navigationTextFieldView, frame: navigationTextFieldFrame) + transition.setFrame(layer: self.textFieldSeparatorLayer, frame: CGRect(origin: CGPoint(x: containerSideInset, y: navigationTextFieldFrame.maxY), size: CGSize(width: navigationTextFieldFrame.width, height: UIScreenPixel))) + } + navigationHeight += navigationTextFieldFrame.height + + self.navigationBackgroundView.update(size: CGSize(width: containerWidth, height: navigationHeight), cornerRadius: 10.0, maskedCorners: [.layerMinXMinYCorner, .layerMaxXMinYCorner], transition: transition.containedViewLayoutTransition) + transition.setFrame(view: self.navigationBackgroundView, frame: CGRect(origin: CGPoint(x: containerSideInset, y: 0.0), size: CGSize(width: containerWidth, height: navigationHeight))) + + transition.setFrame(layer: self.navigationSeparatorLayer, frame: CGRect(origin: CGPoint(x: containerSideInset, y: navigationHeight), size: CGSize(width: containerWidth, height: UIScreenPixel))) + + var bottomPanelHeight: CGFloat = 0.0 + var bottomPanelInset: CGFloat = 0.0 + + let badge = self.selectedCountries.count + + let actionButtonSize = self.actionButton.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) + ), + content: AnyComponentWithIdentity( + id: actionButtonTitle, + component: AnyComponent(ButtonTextContentComponent( + text: actionButtonTitle, + badge: badge, + textColor: environment.theme.list.itemCheckColors.foregroundColor, + badgeBackground: environment.theme.list.itemCheckColors.foregroundColor, + badgeForeground: environment.theme.list.itemCheckColors.fillColor, + combinedAlignment: true + )) + ), + isEnabled: true, + displaysProgress: false, + action: { [weak self] in + guard let self, let component = self.component, let controller = self.environment?.controller() as? CountriesMultiselectionScreen else { + return + } + + component.completion(self.selectedCountries) + + controller.dismissAllTooltips() + controller.dismiss() + } + )), + environment: {}, + containerSize: CGSize(width: containerWidth - navigationSideInset * 2.0, height: 50.0) + ) + + if environment.inputHeight != 0.0 { + bottomPanelHeight += environment.inputHeight + 8.0 + actionButtonSize.height + } else { + bottomPanelHeight += 10.0 + environment.safeInsets.bottom + actionButtonSize.height + } + let actionButtonFrame = CGRect(origin: CGPoint(x: containerSideInset + navigationSideInset, y: availableSize.height - bottomPanelHeight), size: actionButtonSize) + if let actionButtonView = self.actionButton.view { + if actionButtonView.superview == nil { + self.containerView.addSubview(actionButtonView) + } + transition.setFrame(view: actionButtonView, frame: actionButtonFrame) + } + + bottomPanelInset = 8.0 + transition.setFrame(view: self.bottomBackgroundView, frame: CGRect(origin: CGPoint(x: containerSideInset, y: availableSize.height - bottomPanelHeight - 8.0), size: CGSize(width: containerWidth, height: bottomPanelHeight + bottomPanelInset))) + self.bottomBackgroundView.update(size: self.bottomBackgroundView.bounds.size, transition: transition.containedViewLayoutTransition) + transition.setFrame(layer: self.bottomSeparatorLayer, frame: CGRect(origin: CGPoint(x: containerSideInset + sideInset, y: availableSize.height - bottomPanelHeight - bottomPanelInset - UIScreenPixel), size: CGSize(width: containerWidth, height: UIScreenPixel))) + + let itemContainerSize = CGSize(width: itemsContainerWidth, height: availableSize.height) + let itemLayout = ItemLayout(containerSize: itemContainerSize, containerInset: containerInset, bottomInset: 0.0, topInset: 0.0, sideInset: sideInset, navigationHeight: navigationHeight, sections: sections) + self.itemLayout = itemLayout + + contentTransition.setFrame(view: self.itemContainerView, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: containerWidth, height: itemLayout.contentHeight))) + + let scrollContentHeight = max(itemLayout.contentHeight + containerInset, availableSize.height - containerInset) + + transition.setFrame(view: self.scrollContentView, frame: CGRect(origin: CGPoint(x: 0.0, y: containerInset), size: CGSize(width: containerWidth, height: itemLayout.contentHeight))) + + transition.setPosition(view: self.backgroundView, position: CGPoint(x: availableSize.width / 2.0, y: availableSize.height / 2.0)) + transition.setBounds(view: self.backgroundView, bounds: CGRect(origin: CGPoint(x: containerSideInset, y: 0.0), size: CGSize(width: containerWidth, height: availableSize.height))) + + let scrollClippingInset: CGFloat = 0.0 + let scrollClippingFrame = CGRect(origin: CGPoint(x: 0.0, y: containerInset + scrollClippingInset), size: CGSize(width: availableSize.width, height: availableSize.height - scrollClippingInset)) + transition.setPosition(view: self.scrollContentClippingView, position: scrollClippingFrame.center) + transition.setBounds(view: self.scrollContentClippingView, bounds: CGRect(origin: CGPoint(x: scrollClippingFrame.minX, y: scrollClippingFrame.minY), size: scrollClippingFrame.size)) + + transition.setFrame(view: self.containerView, frame: CGRect(origin: .zero, size: availableSize)) + + self.ignoreScrolling = true + transition.setFrame(view: self.scrollView, frame: CGRect(origin: CGPoint(x: containerSideInset, y: 0.0), size: CGSize(width: containerWidth, height: availableSize.height))) + let contentSize = CGSize(width: containerWidth, height: scrollContentHeight) + if contentSize != self.scrollView.contentSize { + self.scrollView.contentSize = contentSize + } + let contentInset: UIEdgeInsets = UIEdgeInsets(top: 0.0, left: 0.0, bottom: bottomPanelHeight + bottomPanelInset, right: 0.0) + let indicatorInset = UIEdgeInsets(top: max(itemLayout.containerInset, environment.safeInsets.top + navigationHeight), left: 0.0, bottom: contentInset.bottom, right: 0.0) + if indicatorInset != self.scrollView.scrollIndicatorInsets { + self.scrollView.scrollIndicatorInsets = indicatorInset + } + if contentInset != self.scrollView.contentInset { + self.scrollView.contentInset = contentInset + } + if resetScrolling { + self.scrollView.bounds = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: containerWidth, height: availableSize.height)) + } + self.ignoreScrolling = false + self.updateScrolling(transition: contentTransition) + + let indexNodeFrame = CGRect(origin: CGPoint(x: availableSize.width - environment.safeInsets.right - 20.0, y: navigationHeight), size: CGSize(width: 20.0, height: availableSize.height - navigationHeight - contentInset.bottom)) + self.indexNode.frame = indexNodeFrame + + if let stateValue = self.effectiveStateValue { + let indexSections = stateValue.sections.map { $0.0 } + self.indexNode.update(size: CGSize(width: indexNodeFrame.width, height: indexNodeFrame.height), color: environment.theme.list.itemAccentColor, sections: indexSections, transition: .animated(duration: 0.2, curve: .easeInOut)) + self.indexNode.isUserInteractionEnabled = !indexSections.isEmpty + } + + return availableSize + } + } + + func makeView() -> View { + return View(frame: CGRect()) + } + + func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } +} + +public class CountriesMultiselectionScreen: ViewControllerComponentContainer { + private let context: AccountContext + + private var isCustomModal = true + private var isDismissed: Bool = false + + public var dismissed: () -> Void = {} + + public init( + context: AccountContext, + stateContext: StateContext, + completion: @escaping ([String]) -> Void + ) { + self.context = context + + super.init(context: context, component: CountriesMultiselectionScreenComponent( + context: context, + stateContext: stateContext, + completion: completion + ), navigationBarAppearance: .none, theme: .default) + + self.statusBar.statusBarStyle = .Ignore + self.navigationPresentation = .modal + self.blocksBackgroundWhenInOverlay = true + self.automaticallyControlPresentationContextLayout = false + self.lockOrientation = true + } + + required public init(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + if !self.isDismissed { + self.isDismissed = true + self.dismissed() + } + } + + override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { + super.containerLayoutUpdated(layout, transition: transition) + + var updatedLayout = layout + updatedLayout.intrinsicInsets.bottom += 66.0 + self.presentationContext.containerLayoutUpdated(updatedLayout, transition: transition) + } + + fileprivate func dismissAllTooltips() { + self.window?.forEachController { controller in + if let controller = controller as? UndoOverlayController { + controller.dismissWithCommitAction() + } + } + self.forEachController { controller in + if let controller = controller as? UndoOverlayController { + controller.dismissWithCommitAction() + } + return true + } + } + + func requestDismiss() { + self.dismissAllTooltips() + self.dismissed() + self.dismiss() + } + + override public func dismiss(completion: (() -> Void)? = nil) { + if !self.isDismissed { + self.isDismissed = true + + self.view.endEditing(true) + + self.dismiss(animated: true) + } + } +} + +public extension CountriesMultiselectionScreen { + struct CountryItem { + let id: String + let flag: String + let name: String + } + + final class State { + let sections: [(String, [CountryItem])] + + fileprivate init( + sections: [(String, [CountryItem])] + ) { + self.sections = sections + } + } + + final class StateContext { + public enum Subject: Equatable { + case countries + case countriesSearch(query: String) + } + + var stateValue: State? + + public let subject: Subject + public let initialSelectedCountries: [String] + + private var stateDisposable: Disposable? + private let stateSubject = Promise() + public var state: Signal { + return self.stateSubject.get() + } + + private let readySubject = ValuePromise(false, ignoreRepeated: true) + public var ready: Signal { + return self.readySubject.get() + } + + public init( + context: AccountContext, + subject: Subject = .countries, + initialSelectedCountries: [String] = [] + ) { + self.subject = subject + self.initialSelectedCountries = initialSelectedCountries + + let presentationData = context.sharedContext.currentPresentationData.with { $0 } + let countries = localizedCountryNamesAndCodes(strings: presentationData.strings).sorted { lhs, rhs in + return lhs.0.1.lowercased() < rhs.0.1.lowercased() + } + + switch subject { + case .countries: + var sections: [(String, [CountryItem])] = [] + + var currentSection: String? + var currentCountries: [CountryItem] = [] + for country in countries { + let section = String(country.0.1.prefix(1)) + if currentSection != section { + if let currentSection { + sections.append((currentSection, currentCountries)) + } + currentSection = section + currentCountries = [] + } + + currentCountries.append(CountryItem( + id: country.1, + flag: flagEmoji(countryCode: country.1), + name: country.0.1 + )) + } + + if let currentSection { + sections.append((currentSection, currentCountries)) + } + + let state = State( + sections: sections + ) + self.stateValue = state + self.stateSubject.set(.single(state)) + + self.readySubject.set(true) + case let .countriesSearch(query): + let results = searchCountries(items: countries, query: query) + + var resultCountries: [CountryItem] = [] + var existingIds = Set() + for country in results { + guard !existingIds.contains(country.1) else { + continue + } + resultCountries.append(CountryItem( + id: country.1, + flag: flagEmoji(countryCode: country.1), + name: country.0.1 + )) + existingIds.insert(country.1) + } + let state = State( + sections: [("", resultCountries)] + ) + self.stateValue = state + self.stateSubject.set(.single(state)) + + self.readySubject.set(true) + } + } + + deinit { + self.stateDisposable?.dispose() + } + } +} diff --git a/submodules/TelegramUI/Components/ShareWithPeersScreen/Sources/CountryListItemComponent.swift b/submodules/TelegramUI/Components/ShareWithPeersScreen/Sources/CountryListItemComponent.swift new file mode 100644 index 0000000000..66d8648e20 --- /dev/null +++ b/submodules/TelegramUI/Components/ShareWithPeersScreen/Sources/CountryListItemComponent.swift @@ -0,0 +1,225 @@ +import Foundation +import UIKit +import Display +import AsyncDisplayKit +import ComponentFlow +import SwiftSignalKit +import AccountContext +import TelegramCore +import MultilineTextComponent +import AvatarNode +import TelegramPresentationData +import CheckNode +import BundleIconComponent + +final class CountryListItemComponent: Component { + enum SelectionState: Equatable { + case none + case editing(isSelected: Bool, isTinted: Bool) + } + + let context: AccountContext + let theme: PresentationTheme + let title: String + let selectionState: SelectionState + let hasNext: Bool + let action: () -> Void + + init( + context: AccountContext, + theme: PresentationTheme, + title: String, + selectionState: SelectionState, + hasNext: Bool, + action: @escaping () -> Void + ) { + self.context = context + self.theme = theme + self.title = title + self.selectionState = selectionState + self.hasNext = hasNext + self.action = action + } + + static func ==(lhs: CountryListItemComponent, rhs: CountryListItemComponent) -> Bool { + if lhs.context !== rhs.context { + return false + } + if lhs.theme !== rhs.theme { + return false + } + if lhs.title != rhs.title { + return false + } + if lhs.selectionState != rhs.selectionState { + return false + } + if lhs.hasNext != rhs.hasNext { + return false + } + return true + } + + final class View: UIView { + private let containerButton: HighlightTrackingButton + + private let title = ComponentView() + private let separatorLayer: SimpleLayer + + private var checkLayer: CheckLayer? + + private var component: CountryListItemComponent? + private weak var state: EmptyComponentState? + + override init(frame: CGRect) { + self.separatorLayer = SimpleLayer() + + self.containerButton = HighlightTrackingButton() + + super.init(frame: frame) + + self.layer.addSublayer(self.separatorLayer) + self.addSubview(self.containerButton) + + self.containerButton.addTarget(self, action: #selector(self.pressed), for: .touchUpInside) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc private func pressed() { + guard let component = self.component else { + return + } + component.action() + } + + func update(component: CountryListItemComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + let themeUpdated = self.component?.theme !== component.theme + + var hasSelectionUpdated = false + if let previousComponent = self.component { + switch previousComponent.selectionState { + case .none: + if case .none = component.selectionState { + } else { + hasSelectionUpdated = true + } + case .editing: + if case .editing = component.selectionState { + } else { + hasSelectionUpdated = true + } + } + } + + self.component = component + self.state = state + + let contextInset: CGFloat = 0.0 + + let height: CGFloat = 44.0 + let verticalInset: CGFloat = 0.0 + var leftInset: CGFloat = 16.0 + let rightInset: CGFloat = contextInset * 2.0 + 8.0 + + if case let .editing(isSelected, isTinted) = component.selectionState { + leftInset += 44.0 + let checkSize: CGFloat = 22.0 + + let checkLayer: CheckLayer + if let current = self.checkLayer { + checkLayer = current + if themeUpdated { + var theme = CheckNodeTheme(theme: component.theme, style: .plain) + if isTinted { + theme.backgroundColor = theme.backgroundColor.mixedWith(component.theme.list.itemBlocksBackgroundColor, alpha: 0.5) + } + checkLayer.theme = theme + } + checkLayer.setSelected(isSelected, animated: !transition.animation.isImmediate) + } else { + var theme = CheckNodeTheme(theme: component.theme, style: .plain) + if isTinted { + theme.backgroundColor = theme.backgroundColor.mixedWith(component.theme.list.itemBlocksBackgroundColor, alpha: 0.5) + } + checkLayer = CheckLayer(theme: theme) + self.checkLayer = checkLayer + self.containerButton.layer.addSublayer(checkLayer) + checkLayer.frame = CGRect(origin: CGPoint(x: -checkSize, y: floor((height - verticalInset * 2.0 - checkSize) / 2.0)), size: CGSize(width: checkSize, height: checkSize)) + checkLayer.setSelected(isSelected, animated: false) + checkLayer.setNeedsDisplay() + } + transition.setFrame(layer: checkLayer, frame: CGRect(origin: CGPoint(x: floor((54.0 - checkSize) * 0.5), y: floor((height - verticalInset * 2.0 - checkSize) / 2.0)), size: CGSize(width: checkSize, height: checkSize))) + } else { + if let checkLayer = self.checkLayer { + self.checkLayer = nil + transition.setPosition(layer: checkLayer, position: CGPoint(x: -checkLayer.bounds.width * 0.5, y: checkLayer.position.y), completion: { [weak checkLayer] _ in + checkLayer?.removeFromSuperlayer() + }) + } + } + + let previousTitleFrame = self.title.view?.frame + var previousTitleContents: UIView? + if hasSelectionUpdated && !"".isEmpty { + previousTitleContents = self.title.view?.snapshotView(afterScreenUpdates: false) + } + + let titleSize = self.title.update( + transition: .immediate, + component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString(string: component.title, font: Font.regular(17.0), textColor: component.theme.list.itemPrimaryTextColor)) + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - leftInset - rightInset, height: 100.0) + ) + + let centralContentHeight: CGFloat = titleSize.height + + let titleFrame = CGRect(origin: CGPoint(x: leftInset, y: floor((height - verticalInset * 2.0 - centralContentHeight) / 2.0)), size: titleSize) + if let titleView = self.title.view { + if titleView.superview == nil { + titleView.isUserInteractionEnabled = false + self.containerButton.addSubview(titleView) + } + titleView.frame = titleFrame + if let previousTitleFrame, previousTitleFrame.origin.x != titleFrame.origin.x { + transition.animatePosition(view: titleView, from: CGPoint(x: previousTitleFrame.origin.x - titleFrame.origin.x, y: 0.0), to: CGPoint(), additive: true) + } + + if let previousTitleFrame, let previousTitleContents, previousTitleFrame.size != titleSize { + previousTitleContents.frame = CGRect(origin: previousTitleFrame.origin, size: previousTitleFrame.size) + self.addSubview(previousTitleContents) + + transition.setFrame(view: previousTitleContents, frame: CGRect(origin: titleFrame.origin, size: previousTitleFrame.size)) + transition.setAlpha(view: previousTitleContents, alpha: 0.0, completion: { [weak previousTitleContents] _ in + previousTitleContents?.removeFromSuperview() + }) + transition.animateAlpha(view: titleView, from: 0.0, to: 1.0) + } + } + + if themeUpdated { + self.separatorLayer.backgroundColor = component.theme.list.itemPlainSeparatorColor.cgColor + } + let separatorLeftInset = leftInset + 44.0 + transition.setFrame(layer: self.separatorLayer, frame: CGRect(origin: CGPoint(x: separatorLeftInset, y: height), size: CGSize(width: availableSize.width - separatorLeftInset, height: UIScreenPixel))) + self.separatorLayer.isHidden = !component.hasNext + + let containerFrame = CGRect(origin: CGPoint(x: contextInset, y: verticalInset), size: CGSize(width: availableSize.width - contextInset * 2.0, height: height - verticalInset * 2.0)) + transition.setFrame(view: self.containerButton, frame: containerFrame) + + return CGSize(width: availableSize.width, height: height) + } + } + + func makeView() -> View { + return View(frame: CGRect()) + } + + func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } +} diff --git a/submodules/TelegramUI/Components/ShareWithPeersScreen/Sources/ShareWithPeersScreen.swift b/submodules/TelegramUI/Components/ShareWithPeersScreen/Sources/ShareWithPeersScreen.swift index 0a8ec1e45a..e1aa986ed9 100644 --- a/submodules/TelegramUI/Components/ShareWithPeersScreen/Sources/ShareWithPeersScreen.swift +++ b/submodules/TelegramUI/Components/ShareWithPeersScreen/Sources/ShareWithPeersScreen.swift @@ -11,11 +11,8 @@ import AccountContext import TelegramCore import Postbox import MultilineTextComponent -import SolidRoundedButtonComponent import PresentationDataUtils import ButtonComponent -import PlainButtonComponent -import AnimatedCounterComponent import TokenListTextField import AvatarNode import LocalizedPeerData @@ -237,26 +234,6 @@ final class ShareWithPeersScreenComponent: Component { } } - final class PeerItem: Equatable { - let id: EnginePeer.Id - let peer: EnginePeer? - - init( - id: EnginePeer.Id, - peer: EnginePeer? - ) { - self.id = id - self.peer = peer - } - - static func ==(lhs: PeerItem, rhs: PeerItem) -> Bool { - if lhs === rhs { - return true - } - return false - } - } - enum OptionId: Int, Hashable { case screenshot = 0 case pin = 1 @@ -1465,6 +1442,9 @@ final class ShareWithPeersScreenComponent: Component { if peer.id.isGroupOrChannel { if case .channels = component.stateContext.subject, self.selectedPeers.count >= component.context.userLimits.maxGiveawayChannelsCount, index == nil { self.hapticFeedback.error() + + let presentationData = component.context.sharedContext.currentPresentationData.with { $0 } + controller.present(UndoOverlayController(presentationData: presentationData, content: .info(title: nil, text: "You can select maximum \(component.context.userLimits.maxGiveawayChannelsCount) channels.", timeout: nil), elevatedLayout: false, position: .bottom, animateInAsReplacement: false, action: { _ in return false }), in: .current) return } if case .channels = component.stateContext.subject { @@ -1492,6 +1472,9 @@ final class ShareWithPeersScreenComponent: Component { } else { if case .members = component.stateContext.subject, self.selectedPeers.count >= 10, index == nil { self.hapticFeedback.error() + + let presentationData = component.context.sharedContext.currentPresentationData.with { $0 } + controller.present(UndoOverlayController(presentationData: presentationData, content: .info(title: nil, text: "You can select maximum 10 subscribers.", timeout: nil), elevatedLayout: false, position: .bottom, animateInAsReplacement: false, action: { _ in return false }), in: .current) return } togglePeer() @@ -2993,6 +2976,10 @@ public class ShareWithPeersScreen: ViewControllerComponentContainer { override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { super.containerLayoutUpdated(layout, transition: transition) + + var updatedLayout = layout + updatedLayout.intrinsicInsets.bottom += 66.0 + self.presentationContext.containerLayoutUpdated(updatedLayout, transition: transition) } override public func viewDidAppear(_ animated: Bool) { diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryInteractionGuideComponent.swift b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryInteractionGuideComponent.swift index aa8da8d0dd..e491ae2d35 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryInteractionGuideComponent.swift +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryInteractionGuideComponent.swift @@ -45,6 +45,8 @@ final class StoryInteractionGuideComponent: Component { private let guideItems = ComponentView() private let proceedButton = ComponentView() + var currentIndex = 0 + override init(frame: CGRect) { self.effectView = UIVisualEffectView(effect: nil) @@ -91,6 +93,7 @@ final class StoryInteractionGuideComponent: Component { func update(component: StoryInteractionGuideComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { self.component = component + self.state = state let sideInset: CGFloat = 48.0 @@ -131,7 +134,15 @@ final class StoryInteractionGuideComponent: Component { context: component.context, title: "Go forward", text: "Tap the screen", - animationName: "story_forward" + animationName: "story_forward", + isPlaying: self.currentIndex == 0, + playbackCompleted: { [weak self] in + guard let self else { + return + } + self.currentIndex = 1 + self.state?.updated(transition: .easeInOut(duration: 0.3)) + } ) ) ), @@ -142,7 +153,15 @@ final class StoryInteractionGuideComponent: Component { context: component.context, title: "Pause and Seek", text: "Hold and move sideways", - animationName: "story_pause" + animationName: "story_pause", + isPlaying: self.currentIndex == 1, + playbackCompleted: { [weak self] in + guard let self else { + return + } + self.currentIndex = 2 + self.state?.updated(transition: .easeInOut(duration: 0.3)) + } ) ) ), @@ -153,7 +172,15 @@ final class StoryInteractionGuideComponent: Component { context: component.context, title: "Go back", text: "Tap the left edge", - animationName: "story_back" + animationName: "story_back", + isPlaying: self.currentIndex == 2, + playbackCompleted: { [weak self] in + guard let self else { + return + } + self.currentIndex = 3 + self.state?.updated(transition: .easeInOut(duration: 0.3)) + } ) ) ), @@ -164,14 +191,22 @@ final class StoryInteractionGuideComponent: Component { context: component.context, title: "Move between stories", text: "Swipe left or right", - animationName: "story_move" + animationName: "story_move", + isPlaying: self.currentIndex == 3, + playbackCompleted: { [weak self] in + guard let self else { + return + } + self.currentIndex = 0 + self.state?.updated(transition: .easeInOut(duration: 0.3)) + } ) ) ) ] let itemsSize = self.guideItems.update( - transition: .immediate, + transition: transition, component: AnyComponent(List(items)), environment: {}, containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: availableSize.height) @@ -225,17 +260,23 @@ private final class GuideItemComponent: Component { let title: String let text: String let animationName: String + let isPlaying: Bool + let playbackCompleted: () -> Void init( context: AccountContext, title: String, text: String, - animationName: String + animationName: String, + isPlaying: Bool, + playbackCompleted: @escaping () -> Void ) { self.context = context self.title = title self.text = text self.animationName = animationName + self.isPlaying = isPlaying + self.playbackCompleted = playbackCompleted } static func ==(lhs: GuideItemComponent, rhs: GuideItemComponent) -> Bool { @@ -248,6 +289,9 @@ private final class GuideItemComponent: Component { if lhs.animationName != rhs.animationName { return false } + if lhs.isPlaying != rhs.isPlaying { + return false + } return true } @@ -255,18 +299,33 @@ private final class GuideItemComponent: Component { private var component: GuideItemComponent? private weak var state: EmptyComponentState? + private let containerView = UIView() + private let selectionView = UIView() + private let animation = ComponentView() private let titleLabel = ComponentView() private let descriptionLabel = ComponentView() override init(frame: CGRect) { super.init(frame: frame) + + self.selectionView.backgroundColor = UIColor(rgb: 0xffffff, alpha: 0.1) + self.selectionView.clipsToBounds = true + self.selectionView.layer.cornerRadius = 16.0 + if #available(iOS 13.0, *) { + self.selectionView.layer.cornerCurve = .continuous + } + self.selectionView.alpha = 0.0 + + self.addSubview(self.containerView) + self.containerView.addSubview(self.selectionView) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + + private var isPlaying = false func update(component: GuideItemComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { self.component = component @@ -285,18 +344,40 @@ private final class GuideItemComponent: Component { startingPosition: .begin, size: CGSize(width: 60.0, height: 60.0), renderingScale: UIScreen.main.scale, - loop: true + loop: false ) ), environment: {}, containerSize: availableSize ) let animationFrame = CGRect(origin: CGPoint(x: originX - 11.0, y: 15.0), size: animationSize) - if let view = self.animation.view { + if let view = self.animation.view as? LottieComponent.View { if view.superview == nil { - self.addSubview(view) + view.externalShouldPlay = false + self.containerView.addSubview(view) } view.frame = animationFrame + + if component.isPlaying && !self.isPlaying { + self.isPlaying = true + Queue.mainQueue().justDispatch { + let completionBlock = { [weak self] in + guard let self else { + return + } + self.isPlaying = false + Queue.mainQueue().after(0.1) { + self.component?.playbackCompleted() + } + } + + view.playOnce(force: true, completion: { [weak view] in + view?.playOnce(force: true, completion: { + completionBlock() + }) + }) + } + } } let titleSize = self.titleLabel.update( @@ -308,7 +389,7 @@ private final class GuideItemComponent: Component { let titleFrame = CGRect(origin: CGPoint(x: originX + 60.0, y: 25.0), size: titleSize) if let view = self.titleLabel.view { if view.superview == nil { - self.addSubview(view) + self.containerView.addSubview(view) } view.frame = titleFrame } @@ -322,11 +403,18 @@ private final class GuideItemComponent: Component { let textFrame = CGRect(origin: CGPoint(x: originX + 60.0, y: titleFrame.maxY + 2.0), size: textSize) if let view = self.descriptionLabel.view { if view.superview == nil { - self.addSubview(view) + self.containerView.addSubview(view) } view.frame = textFrame } + self.selectionView.frame = CGRect(origin: .zero, size: size).insetBy(dx: 12.0, dy: 8.0) + transition.setAlpha(view: self.selectionView, alpha: component.isPlaying ? 1.0 : 0.0) + + self.containerView.bounds = CGRect(origin: .zero, size: size) + self.containerView.center = CGPoint(x: size.width / 2.0, y: size.height / 2.0) + transition.setScale(view: self.containerView, scale: component.isPlaying ? 1.1 : 1.0) + return size } } diff --git a/submodules/TelegramUI/Components/TokenListTextField/Sources/EditableTokenListNode.swift b/submodules/TelegramUI/Components/TokenListTextField/Sources/EditableTokenListNode.swift index 99190021a3..dbfda1f372 100644 --- a/submodules/TelegramUI/Components/TokenListTextField/Sources/EditableTokenListNode.swift +++ b/submodules/TelegramUI/Components/TokenListTextField/Sources/EditableTokenListNode.swift @@ -11,6 +11,7 @@ struct EditableTokenListToken { enum Subject { case peer(EnginePeer) case category(UIImage?) + case emoji(String) } let id: AnyHashable @@ -86,6 +87,7 @@ private final class TokenNode: ASDisplayNode { let token: EditableTokenListToken let avatarNode: AvatarNode let categoryAvatarNode: ASImageNode + let emojiTextNode: ImmediateTextNode let removeIconNode: ASImageNode let titleNode: ASTextNode let backgroundNode: ASImageNode @@ -119,6 +121,7 @@ private final class TokenNode: ASDisplayNode { self.categoryAvatarNode = ASImageNode() self.categoryAvatarNode.displaysAsynchronously = false self.categoryAvatarNode.displayWithoutProcessing = true + self.emojiTextNode = ImmediateTextNode() self.removeIconNode = ASImageNode() self.removeIconNode.alpha = 0.0 @@ -132,6 +135,8 @@ private final class TokenNode: ASDisplayNode { cornerRadius = 24.0 case .category: cornerRadius = 14.0 + case .emoji: + cornerRadius = 24.0 } self.backgroundNode = ASImageNode() @@ -160,6 +165,9 @@ private final class TokenNode: ASDisplayNode { case let .category(image): self.addSubnode(self.categoryAvatarNode) self.categoryAvatarNode.image = image + case let .emoji(emoji): + self.addSubnode(self.emojiTextNode) + self.emojiTextNode.attributedText = NSAttributedString(string: emoji, font: Font.regular(17.0), textColor: .white) } self.updateIsSelected(isSelected, animated: false) @@ -167,7 +175,12 @@ private final class TokenNode: ASDisplayNode { override func calculateSizeThatFits(_ constrainedSize: CGSize) -> CGSize { let titleSize = self.titleNode.measure(CGSize(width: constrainedSize.width - 8.0, height: constrainedSize.height)) - return CGSize(width: 22.0 + titleSize.width + 16.0, height: 28.0) + var width = 22.0 + titleSize.width + 16.0 + if self.emojiTextNode.supernode != nil { + let _ = self.emojiTextNode.updateLayout(constrainedSize) + width += 3.0 + } + return CGSize(width: width, height: 28.0) } override func layout() { @@ -181,7 +194,13 @@ private final class TokenNode: ASDisplayNode { self.categoryAvatarNode.frame = self.avatarNode.frame self.removeIconNode.frame = self.avatarNode.frame - self.titleNode.frame = CGRect(origin: CGPoint(x: 29.0, y: floor((self.bounds.size.height - titleSize.height) / 2.0)), size: titleSize) + var textLeftOffset: CGFloat = 29.0 + if let emojiTextSize = self.emojiTextNode.cachedLayout?.size { + self.emojiTextNode.frame = CGRect(origin: CGPoint(x: 7.0, y: 4.0), size: emojiTextSize) + textLeftOffset += 3.0 + } + + self.titleNode.frame = CGRect(origin: CGPoint(x: textLeftOffset, y: floor((self.bounds.size.height - titleSize.height) / 2.0)), size: titleSize) } func updateIsSelected(_ isSelected: Bool, animated: Bool) { @@ -192,6 +211,7 @@ private final class TokenNode: ASDisplayNode { self.avatarNode.alpha = isSelected ? 0.0 : 1.0 self.categoryAvatarNode.alpha = isSelected ? 0.0 : 1.0 + self.emojiTextNode.alpha = isSelected ? 0.0 : 1.0 self.removeIconNode.alpha = isSelected ? 1.0 : 0.0 if animated { @@ -205,6 +225,9 @@ private final class TokenNode: ASDisplayNode { self.categoryAvatarNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2) self.categoryAvatarNode.layer.animateScale(from: 1.0, to: 0.01, duration: 0.2) + self.emojiTextNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2) + self.emojiTextNode.layer.animateScale(from: 1.0, to: 0.01, duration: 0.2) + self.removeIconNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) self.removeIconNode.layer.animateScale(from: 0.01, to: 1.0, duration: 0.2) } else { @@ -217,6 +240,9 @@ private final class TokenNode: ASDisplayNode { self.categoryAvatarNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) self.categoryAvatarNode.layer.animateScale(from: 0.01, to: 1.0, duration: 0.2) + self.emojiTextNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) + self.emojiTextNode.layer.animateScale(from: 0.01, to: 1.0, duration: 0.2) + self.removeIconNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2) self.removeIconNode.layer.animateScale(from: 1.0, to: 0.01, duration: 0.2) } diff --git a/submodules/TelegramUI/Components/TokenListTextField/Sources/TokenListTextField.swift b/submodules/TelegramUI/Components/TokenListTextField/Sources/TokenListTextField.swift index a906a60105..a5c3358430 100644 --- a/submodules/TelegramUI/Components/TokenListTextField/Sources/TokenListTextField.swift +++ b/submodules/TelegramUI/Components/TokenListTextField/Sources/TokenListTextField.swift @@ -21,6 +21,7 @@ public final class TokenListTextField: Component { public enum Content: Equatable { case peer(EnginePeer) case category(UIImage?) + case emoji(String) public static func ==(lhs: Content, rhs: Content) -> Bool { switch lhs { @@ -36,6 +37,12 @@ public final class TokenListTextField: Component { } else { return false } + case let .emoji(lhsEmoji): + if case let .emoji(rhsEmoji) = rhs, lhsEmoji == rhsEmoji { + return true + } else { + return false + } } } } @@ -217,6 +224,8 @@ public final class TokenListTextField: Component { mappedSubject = .peer(peer) case let .category(image): mappedSubject = .category(image) + case let .emoji(emoji): + mappedSubject = .emoji(emoji) } return EditableTokenListToken( diff --git a/submodules/TelegramUI/Images.xcassets/Premium/Icons/Coffee.imageset/Coffee.png b/submodules/TelegramUI/Images.xcassets/Premium/Icons/Coffee.imageset/Coffee.png new file mode 100644 index 0000000000..60c199314a Binary files /dev/null and b/submodules/TelegramUI/Images.xcassets/Premium/Icons/Coffee.imageset/Coffee.png differ diff --git a/submodules/TelegramUI/Images.xcassets/Premium/Icons/Coffee.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Premium/Icons/Coffee.imageset/Contents.json new file mode 100644 index 0000000000..bf38b88a52 --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Premium/Icons/Coffee.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Coffee.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/submodules/TelegramUI/Images.xcassets/Premium/Icons/Duck.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Premium/Icons/Duck.imageset/Contents.json new file mode 100644 index 0000000000..a7c2a54e8d --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Premium/Icons/Duck.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Duck.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/submodules/TelegramUI/Images.xcassets/Premium/Icons/Duck.imageset/Duck.png b/submodules/TelegramUI/Images.xcassets/Premium/Icons/Duck.imageset/Duck.png new file mode 100644 index 0000000000..ac1cc232e1 Binary files /dev/null and b/submodules/TelegramUI/Images.xcassets/Premium/Icons/Duck.imageset/Duck.png differ diff --git a/submodules/TelegramUI/Images.xcassets/Premium/Icons/Steam.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Premium/Icons/Steam.imageset/Contents.json new file mode 100644 index 0000000000..3313751950 --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Premium/Icons/Steam.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Steam.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/submodules/TelegramUI/Images.xcassets/Premium/Icons/Steam.imageset/Steam.png b/submodules/TelegramUI/Images.xcassets/Premium/Icons/Steam.imageset/Steam.png new file mode 100644 index 0000000000..eaf5a8361a Binary files /dev/null and b/submodules/TelegramUI/Images.xcassets/Premium/Icons/Steam.imageset/Steam.png differ diff --git a/submodules/TelegramUI/Sources/ChatController.swift b/submodules/TelegramUI/Sources/ChatController.swift index 2835094022..299841c627 100644 --- a/submodules/TelegramUI/Sources/ChatController.swift +++ b/submodules/TelegramUI/Sources/ChatController.swift @@ -738,18 +738,19 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G strongSelf.bankCardDisposable = disposable } - var cancelImpl: (() -> Void)? - let presentationData = strongSelf.context.sharedContext.currentPresentationData.with { $0 } +// var cancelImpl: (() -> Void)? +// let presentationData = strongSelf.context.sharedContext.currentPresentationData.with { $0 } let progressSignal = Signal { subscriber in - let controller = OverlayStatusController(theme: presentationData.theme, type: .loading(cancelled: { - cancelImpl?() - })) - strongSelf.present(controller, in: .window(.root), with: ViewControllerPresentationArguments(presentationAnimation: .modalSheet)) - return ActionDisposable { [weak controller] in - Queue.mainQueue().async() { - controller?.dismiss() - } - } +// let controller = OverlayStatusController(theme: presentationData.theme, type: .loading(cancelled: { +// cancelImpl?() +// })) +// strongSelf.present(controller, in: .window(.root), with: ViewControllerPresentationArguments(presentationAnimation: .modalSheet)) +// return ActionDisposable { [weak controller] in +// Queue.mainQueue().async() { +// controller?.dismiss() +// } +// } + return EmptyDisposable } |> runOn(Queue.mainQueue()) |> delay(0.15, queue: Queue.mainQueue()) @@ -761,14 +762,13 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G progressDisposable.dispose() } } - cancelImpl = { - disposable.set(nil) - } +// cancelImpl = { +// disposable.set(nil) +// } disposable.set((signal |> deliverOnMainQueue).startStrict(next: { [weak self] info in if let strongSelf = self, let info = info { - let date = stringForDate(timestamp: giveaway.untilDate, strings: strongSelf.presentationData.strings) - let startDate = stringForDate(timestamp: message.timestamp, strings: strongSelf.presentationData.strings) + let untilDate = stringForDate(timestamp: giveaway.untilDate, strings: strongSelf.presentationData.strings) let title: String let text: String @@ -781,9 +781,10 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G })] switch info { - case let .ongoing(status): - title = "About This Giveaway" + case let .ongoing(start, status): + let startDate = stringForDate(timestamp: start, strings: strongSelf.presentationData.strings) + title = "About This Giveaway" let intro: String if case .almostOver = status { @@ -793,33 +794,17 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } let ending: String - if case .almostOver = status { - if giveaway.flags.contains(.onlyNewSubscribers) { - if giveaway.channelPeerIds.count > 1 { - ending = "On **\(date)**, Telegram automatically selected **\(giveaway.quantity)** random users that joined **\(peerName)** and other listed channels after **\(startDate)**." - } else { - ending = "On **\(date)**, Telegram automatically selected **\(giveaway.quantity)** random users that joined **\(peerName)** after **\(startDate)**." - } + if giveaway.flags.contains(.onlyNewSubscribers) { + if giveaway.channelPeerIds.count > 1 { + ending = "On **\(untilDate)**, Telegram will automatically select **\(giveaway.quantity)** random users that joined **\(peerName)** and **\(giveaway.channelPeerIds.count - 1)** other listed channels after **\(startDate)**." } else { - if giveaway.channelPeerIds.count > 1 { - ending = "On **\(date)**, Telegram automatically selected **\(giveaway.quantity)** random subscribers of **\(peerName)** and other listed channels." - } else { - ending = "On **\(date)**, Telegram automatically selected **\(giveaway.quantity)** random subscribers of **\(peerName)**." - } + ending = "On **\(untilDate)**, Telegram will automatically select **\(giveaway.quantity)** random users that joined **\(peerName)** after **\(startDate)**." } } else { - if giveaway.flags.contains(.onlyNewSubscribers) { - if giveaway.channelPeerIds.count > 1 { - ending = "On **\(date)**, Telegram will automatically select **\(giveaway.quantity)** random users that joined **\(peerName)** and **\(giveaway.channelPeerIds.count - 1)** other listed channels after **\(startDate)**." - } else { - ending = "On **\(date)**, Telegram will automatically select **\(giveaway.quantity)** random users that joined **\(peerName)** after **\(startDate)**." - } + if giveaway.channelPeerIds.count > 1 { + ending = "On **\(untilDate)**, Telegram will automatically select **\(giveaway.quantity)** random subscribers of **\(peerName)** and **\(giveaway.channelPeerIds.count - 1)** other listed channels." } else { - if giveaway.channelPeerIds.count > 1 { - ending = "On **\(date)**, Telegram will automatically select **\(giveaway.quantity)** random subscribers of **\(peerName)** and **\(giveaway.channelPeerIds.count - 1)** other listed channels." - } else { - ending = "On **\(date)**, Telegram will automatically select **\(giveaway.quantity)** random subscribers of **\(peerName)**." - } + ending = "On **\(untilDate)**, Telegram will automatically select **\(giveaway.quantity)** random subscribers of **\(peerName)**." } } @@ -827,9 +812,9 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G switch status { case .notQualified: if giveaway.channelPeerIds.count > 1 { - participation = "To take part in this giveaway please join the channel **\(peerName)** (**\(giveaway.channelPeerIds.count - 1)** other listed channels) before **\(date)**." + participation = "To take part in this giveaway please join the channel **\(peerName)** (**\(giveaway.channelPeerIds.count - 1)** other listed channels) before **\(untilDate)**." } else { - participation = "To take part in this giveaway please join the channel **\(peerName)** before **\(date)**." + participation = "To take part in this giveaway please join the channel **\(peerName)** before **\(untilDate)**." } case let .notAllowed(reason): switch reason { @@ -839,6 +824,9 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G case let .channelAdmin(adminId): let _ = adminId participation = "You are not eligible to participate in this giveaway, because you are an admin of participating channel (**\(peerName)**)." + case let .disallowedCountry(countryCode): + let _ = countryCode + participation = "You are not eligible to participate in this giveaway, because your country is not included in the terms of the giveaway." } case .participating: if giveaway.channelPeerIds.count > 1 { @@ -855,8 +843,9 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } text = "\(intro)\n\n\(ending)\(participation)" - case let .finished(status, finishDate, _, activatedCount): - let date = stringForDate(timestamp: finishDate, strings: strongSelf.presentationData.strings) + case let .finished(status, start, finish, _, activatedCount): + let startDate = stringForDate(timestamp: start, strings: strongSelf.presentationData.strings) + let finishDate = stringForDate(timestamp: finish, strings: strongSelf.presentationData.strings) title = "Giveaway Ended" let intro = "The giveaway was sponsored by the admins of **\(peerName)**, who acquired **\(giveaway.quantity) Telegram Premium** subscriptions for **\(giveaway.months)** months for its followers." @@ -864,15 +853,15 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G var ending: String if giveaway.flags.contains(.onlyNewSubscribers) { if giveaway.channelPeerIds.count > 1 { - ending = "On **\(date)**, Telegram automatically selected **\(giveaway.quantity)** random users that joined **\(peerName)** and other listed channels after **\(startDate)**." + ending = "On **\(finishDate)**, Telegram automatically selected **\(giveaway.quantity)** random users that joined **\(peerName)** and other listed channels after **\(startDate)**." } else { - ending = "On **\(date)**, Telegram automatically selected **\(giveaway.quantity)** random users that joined **\(peerName)** after **\(startDate)**." + ending = "On **\(finishDate)**, Telegram automatically selected **\(giveaway.quantity)** random users that joined **\(peerName)** after **\(startDate)**." } } else { if giveaway.channelPeerIds.count > 1 { - ending = "On **\(date)**, Telegram automatically selected **\(giveaway.quantity)** random subscribers of **\(peerName)** and other listed channels." + ending = "On **\(finishDate)**, Telegram automatically selected **\(giveaway.quantity)** random subscribers of **\(peerName)** and other listed channels." } else { - ending = "On **\(date)**, Telegram automatically selected **\(giveaway.quantity)** random subscribers of **\(peerName)**." + ending = "On **\(finishDate)**, Telegram automatically selected **\(giveaway.quantity)** random subscribers of **\(peerName)**." } } diff --git a/submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift b/submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift index 2ffbf39606..f31be0cb27 100644 --- a/submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift +++ b/submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift @@ -142,16 +142,19 @@ private func canEditMessage(accountPeerId: PeerId, limitsConfiguration: EngineCo } else if let _ = media as? TelegramMediaPoll { hasUneditableAttributes = true break - } else if let _ = media as? TelegramMediaDice { + } else if let _ = media as? TelegramMediaDice { hasUneditableAttributes = true break - } else if let _ = media as? TelegramMediaGame { + } else if let _ = media as? TelegramMediaGame { hasUneditableAttributes = true break - } else if let _ = media as? TelegramMediaInvoice { + } else if let _ = media as? TelegramMediaInvoice { hasUneditableAttributes = true break - } else if let _ = media as? TelegramMediaStory { + } else if let _ = media as? TelegramMediaStory { + hasUneditableAttributes = true + break + } else if let _ = media as? TelegramMediaGiveaway { hasUneditableAttributes = true break } diff --git a/submodules/TelegramUI/Sources/ChatMessageActionItemNode.swift b/submodules/TelegramUI/Sources/ChatMessageActionItemNode.swift index ef98e15707..1eac4b7c6b 100644 --- a/submodules/TelegramUI/Sources/ChatMessageActionItemNode.swift +++ b/submodules/TelegramUI/Sources/ChatMessageActionItemNode.swift @@ -158,7 +158,12 @@ class ChatMessageActionBubbleContentNode: ChatMessageBubbleContentNode { let cachedMaskBackgroundImage = self.cachedMaskBackgroundImage return { item, layoutConstants, _, _, _, _ in - let contentProperties = ChatMessageBubbleContentProperties(hidesSimpleAuthorHeader: true, headerSpacing: 0.0, hidesBackground: .always, forceFullCorners: false, forceAlignment: .center) + var isGiveaway = false + if let _ = item.message.media.first(where: { $0 is TelegramMediaGiveaway }) { + isGiveaway = true + } + + let contentProperties = ChatMessageBubbleContentProperties(hidesSimpleAuthorHeader: true, headerSpacing: 0.0, hidesBackground: .always, forceFullCorners: false, forceAlignment: isGiveaway ? .none : .center, isDetached: isGiveaway) let backgroundImage = PresentationResourcesChat.chatActionPhotoBackgroundImage(item.presentationData.theme.theme, wallpaper: !item.presentationData.theme.wallpaper.isEmpty) @@ -230,6 +235,8 @@ class ChatMessageActionBubbleContentNode: ChatMessageBubbleContentNode { if let _ = image { backgroundSize.height += imageSize.height + 10 + } else if isGiveaway { + backgroundSize.height += 8.0 } return (backgroundSize.width, { boundingWidth in @@ -510,6 +517,10 @@ class ChatMessageActionBubbleContentNode: ChatMessageBubbleContentNode { } override func tapActionAtPoint(_ point: CGPoint, gesture: TapLongTapOrDoubleTapGesture, isEstimating: Bool) -> ChatMessageBubbleContentTapAction { + if let item = self.item, item.message.media.first(where: { $0 is TelegramMediaGiveaway }) != nil { + return .none + } + let textNodeFrame = self.labelNode.textNode.frame if let (index, attributes) = self.labelNode.textNode.attributesAtPoint(CGPoint(x: point.x - textNodeFrame.minX, y: point.y - textNodeFrame.minY - 10.0)), gesture == .tap { if let url = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.URL)] as? String { @@ -528,7 +539,7 @@ class ChatMessageActionBubbleContentNode: ChatMessageBubbleContentNode { return .hashtag(hashtag.peerName, hashtag.hashtag) } } - if let imageNode = imageNode, imageNode.frame.contains(point) { + if let imageNode = self.imageNode, imageNode.frame.contains(point) { return .openMessage } diff --git a/submodules/TelegramUI/Sources/ChatMessageAttachedContentNode.swift b/submodules/TelegramUI/Sources/ChatMessageAttachedContentNode.swift index 5e9dccc19d..549cc9680a 100644 --- a/submodules/TelegramUI/Sources/ChatMessageAttachedContentNode.swift +++ b/submodules/TelegramUI/Sources/ChatMessageAttachedContentNode.swift @@ -72,6 +72,7 @@ final class ChatMessageAttachedContentButtonNode: HighlightTrackingButtonNode { self.shimmerEffectNode = ShimmerEffectForegroundNode() self.shimmerEffectNode.cornerRadius = 5.0 + self.shimmerEffectNode.isHidden = true self.backgroundNode = ASImageNode() self.backgroundNode.isLayerBacked = true diff --git a/submodules/TelegramUI/Sources/ChatMessageBubbleItemNode.swift b/submodules/TelegramUI/Sources/ChatMessageBubbleItemNode.swift index 66d5d23b98..574dc8cf00 100644 --- a/submodules/TelegramUI/Sources/ChatMessageBubbleItemNode.swift +++ b/submodules/TelegramUI/Sources/ChatMessageBubbleItemNode.swift @@ -1235,7 +1235,7 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode var allowFullWidth = false let chatLocationPeerId: PeerId = item.chatLocation.peerId ?? item.content.firstMessage.id.peerId - + do { let peerId = chatLocationPeerId @@ -1790,7 +1790,7 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode } } } - + if initialDisplayHeader && displayAuthorInfo { if let peer = firstMessage.peers[firstMessage.id.peerId] as? TelegramChannel, case .broadcast = peer.info, item.content.firstMessage.adAttribute == nil { authorNameString = EnginePeer(peer).displayTitle(strings: item.presentationData.strings, displayOrder: item.presentationData.nameDisplayOrder) @@ -2399,7 +2399,12 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode print("contentNodeWidth \(contentNodeWidth) > \(maximumNodeWidth)") } #endif - maxContentWidth = max(maxContentWidth, contentNodeWidth) + + if contentNodeProperties.isDetached { + + } else { + maxContentWidth = max(maxContentWidth, contentNodeWidth) + } contentNodePropertiesAndFinalize.append((contentNodeProperties, contentPosition, contentNodeFinalize, contentGroupId, itemSelection)) } @@ -2414,6 +2419,7 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode var contentNodesHeight: CGFloat = 0.0 var totalContentNodesHeight: CGFloat = 0.0 var currentContainerGroupOverlap: CGFloat = 0.0 + var detachedContentNodesHeight: CGFloat = 0.0 var mosaicStatusOrigin: CGPoint? for i in 0 ..< contentNodePropertiesAndFinalize.count { @@ -2455,7 +2461,7 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode } totalContentNodesHeight += properties.headerSpacing } - + if currentContainerGroupId != contentGroupId { if let containerGroupId = currentContainerGroupId { var overlapOffset: CGFloat = 0.0 @@ -2475,11 +2481,16 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode currentItemSelection = itemSelection } + let contentNodeOriginY = contentNodesHeight - detachedContentNodesHeight let (size, apply) = finalize(maxContentWidth) - contentNodeFramesPropertiesAndApply.append((CGRect(origin: CGPoint(x: 0.0, y: contentNodesHeight), size: size), properties, contentGroupId == nil, apply)) + contentNodeFramesPropertiesAndApply.append((CGRect(origin: CGPoint(x: 0.0, y: contentNodeOriginY), size: size), properties, contentGroupId == nil, apply)) contentNodesHeight += size.height totalContentNodesHeight += size.height + + if properties.isDetached { + detachedContentNodesHeight += size.height + } } } @@ -2509,7 +2520,7 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode } reactionButtonsSizeAndApply = reactionButtonsFinalize(maxContentWidth) } - + let minimalContentSize: CGSize if hideBackground { minimalContentSize = CGSize(width: 1.0, height: 1.0) @@ -2517,24 +2528,23 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode minimalContentSize = layoutConstants.bubble.minimumSize } let calculatedBubbleHeight = headerSize.height + contentSize.height + layoutConstants.bubble.contentInsets.top + layoutConstants.bubble.contentInsets.bottom - let layoutBubbleSize = CGSize(width: max(contentSize.width, headerSize.width) + layoutConstants.bubble.contentInsets.left + layoutConstants.bubble.contentInsets.right, height: max(minimalContentSize.height, calculatedBubbleHeight)) - + let layoutBubbleSize = CGSize(width: max(contentSize.width, headerSize.width) + layoutConstants.bubble.contentInsets.left + layoutConstants.bubble.contentInsets.right, height: max(minimalContentSize.height, calculatedBubbleHeight - detachedContentNodesHeight)) var contentVerticalOffset: CGFloat = 0.0 if minimalContentSize.height > calculatedBubbleHeight + 2.0 { contentVerticalOffset = floorToScreenPixels((minimalContentSize.height - calculatedBubbleHeight) / 2.0) } + let availableWidth = params.width - params.leftInset - params.rightInset let backgroundFrame: CGRect let contentOrigin: CGPoint let contentUpperRightCorner: CGPoint switch alignment { case .none: - backgroundFrame = CGRect(origin: CGPoint(x: incoming ? (params.leftInset + layoutConstants.bubble.edgeInset + avatarInset) : (params.width - params.rightInset - layoutBubbleSize.width - layoutConstants.bubble.edgeInset - deliveryFailedInset), y: 0.0), size: layoutBubbleSize) + backgroundFrame = CGRect(origin: CGPoint(x: incoming ? (params.leftInset + layoutConstants.bubble.edgeInset + avatarInset) : (params.width - params.rightInset - layoutBubbleSize.width - layoutConstants.bubble.edgeInset - deliveryFailedInset), y: detachedContentNodesHeight), size: layoutBubbleSize) contentOrigin = CGPoint(x: backgroundFrame.origin.x + (incoming ? layoutConstants.bubble.contentInsets.left : layoutConstants.bubble.contentInsets.right), y: backgroundFrame.origin.y + layoutConstants.bubble.contentInsets.top + headerSize.height + contentVerticalOffset) contentUpperRightCorner = CGPoint(x: backgroundFrame.maxX - (incoming ? layoutConstants.bubble.contentInsets.right : layoutConstants.bubble.contentInsets.left), y: backgroundFrame.origin.y + layoutConstants.bubble.contentInsets.top + headerSize.height) case .center: - let availableWidth = params.width - params.leftInset - params.rightInset - backgroundFrame = CGRect(origin: CGPoint(x: params.leftInset + floor((availableWidth - layoutBubbleSize.width) / 2.0), y: 0.0), size: layoutBubbleSize) + backgroundFrame = CGRect(origin: CGPoint(x: params.leftInset + floor((availableWidth - layoutBubbleSize.width) / 2.0), y: detachedContentNodesHeight), size: layoutBubbleSize) let contentOriginX: CGFloat if !hideBackground { contentOriginX = (incoming ? layoutConstants.bubble.contentInsets.left : layoutConstants.bubble.contentInsets.right) @@ -2547,7 +2557,7 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode let bubbleContentWidth = maxContentWidth - layoutConstants.bubble.edgeInset * 2.0 - (layoutConstants.bubble.contentInsets.right + layoutConstants.bubble.contentInsets.left) - var layoutSize = CGSize(width: params.width, height: layoutBubbleSize.height) + var layoutSize = CGSize(width: params.width, height: layoutBubbleSize.height + detachedContentNodesHeight) if let reactionButtonsSizeAndApply = reactionButtonsSizeAndApply { layoutSize.height += 4.0 + reactionButtonsSizeAndApply.0.height } @@ -3203,6 +3213,7 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode } containerSupernode.addSubnode(contentNode) + contentNode.itemNode = strongSelf contentNode.bubbleBackgroundNode = strongSelf.backgroundNode contentNode.bubbleBackdropNode = strongSelf.backgroundWallpaperNode contentNode.updateIsTextSelectionActive = { [weak contextSourceNode] value in @@ -3239,7 +3250,7 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode var shouldClipOnTransitions = true var contentNodeIndex = 0 - for (relativeFrame, _, useContentOrigin, apply) in contentNodeFramesPropertiesAndApply { + for (relativeFrame, properties, useContentOrigin, apply) in contentNodeFramesPropertiesAndApply { apply(animation, synchronousLoads, applyInfo) if contentNodeIndex >= strongSelf.contentNodes.count { @@ -3252,7 +3263,14 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode shouldClipOnTransitions = false } - let contentNodeFrame = relativeFrame.offsetBy(dx: contentOrigin.x, dy: useContentOrigin ? contentOrigin.y : 0.0) + var effectiveContentOriginX = contentOrigin.x + var effectiveContentOriginY = useContentOrigin ? contentOrigin.y : 0.0 + if properties.isDetached { + effectiveContentOriginX = floorToScreenPixels((layout.size.width - relativeFrame.width) / 2.0) + effectiveContentOriginY = 0.0 + } + + let contentNodeFrame = relativeFrame.offsetBy(dx: effectiveContentOriginX, dy: effectiveContentOriginY) let previousContentNodeFrame = contentNode.frame if case let .System(duration, _) = animation { diff --git a/submodules/TelegramUI/Sources/ChatMessageGiftItemNode.swift b/submodules/TelegramUI/Sources/ChatMessageGiftItemNode.swift index c7362ea1dc..bd7f643d36 100644 --- a/submodules/TelegramUI/Sources/ChatMessageGiftItemNode.swift +++ b/submodules/TelegramUI/Sources/ChatMessageGiftItemNode.swift @@ -39,6 +39,7 @@ class ChatMessageGiftBubbleContentNode: ChatMessageBubbleContentNode { private let placeholderNode: StickerShimmerEffectNode private let animationNode: AnimatedStickerNode + private let shimmerEffectNode: ShimmerEffectForegroundNode private let buttonNode: HighlightTrackingButtonNode private let buttonStarsNode: PremiumStarsNode private let buttonTitleNode: TextNode @@ -93,6 +94,9 @@ class ChatMessageGiftBubbleContentNode: ChatMessageBubbleContentNode { self.buttonNode = HighlightTrackingButtonNode() self.buttonNode.clipsToBounds = true self.buttonNode.cornerRadius = 17.0 + + self.shimmerEffectNode = ShimmerEffectForegroundNode() + self.shimmerEffectNode.cornerRadius = 17.0 self.placeholderNode = StickerShimmerEffectNode() self.placeholderNode.isUserInteractionEnabled = false @@ -116,6 +120,7 @@ class ChatMessageGiftBubbleContentNode: ChatMessageBubbleContentNode { self.addSubnode(self.animationNode) self.addSubnode(self.buttonNode) + self.buttonNode.addSubnode(self.shimmerEffectNode) self.buttonNode.addSubnode(self.buttonStarsNode) self.addSubnode(self.buttonTitleNode) @@ -151,6 +156,26 @@ class ChatMessageGiftBubbleContentNode: ChatMessageBubbleContentNode { return } let _ = item.controllerInteraction.openMessage(item.message, .default) + self.startShimmering() + Queue.mainQueue().after(0.75) { + self.stopShimmering() + } + } + + func startShimmering() { + self.shimmerEffectNode.isHidden = false + self.shimmerEffectNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) + + let backgroundFrame = self.buttonNode.frame + self.shimmerEffectNode.frame = CGRect(origin: .zero, size: backgroundFrame.size) + self.shimmerEffectNode.updateAbsoluteRect(CGRect(origin: .zero, size: backgroundFrame.size), within: backgroundFrame.size) + self.shimmerEffectNode.update(backgroundColor: .clear, foregroundColor: UIColor.white.withAlphaComponent(0.2), horizontal: true, effectSize: nil, globalTimeOffset: false, duration: nil) + } + + func stopShimmering() { + self.shimmerEffectNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, completion: { [weak self] _ in + self?.shimmerEffectNode.isHidden = true + }) } private func removePlaceholder(animated: Bool) { @@ -500,7 +525,9 @@ class ChatMessageGiftBubbleContentNode: ChatMessageBubbleContentNode { } } - if let backgroundNode = self.backgroundNode, backgroundNode.frame.contains(point) { + if self.buttonNode.frame.contains(point) { + return .ignore + } else if let backgroundNode = self.backgroundNode, backgroundNode.frame.contains(point) { return .openMessage } else if self.mediaBackgroundNode.frame.contains(point) { return .openMessage @@ -546,6 +573,12 @@ class ChatMessageGiftBubbleContentNode: ChatMessageBubbleContentNode { if !item.controllerInteraction.seenOneTimeAnimatedMedia.contains(item.message.id) { item.controllerInteraction.seenOneTimeAnimatedMedia.insert(item.message.id) self.animationNode.playOnce() + + Queue.mainQueue().after(0.05) { + if let itemNode = self.itemNode, let supernode = itemNode.supernode { + supernode.addSubnode(itemNode) + } + } } if !alreadySeen && self.animationNode.isPlaying { diff --git a/submodules/TelegramUI/Sources/ChatMessageGiveawayBubbleContentNode.swift b/submodules/TelegramUI/Sources/ChatMessageGiveawayBubbleContentNode.swift index 9fa3364cab..6bfa330263 100644 --- a/submodules/TelegramUI/Sources/ChatMessageGiveawayBubbleContentNode.swift +++ b/submodules/TelegramUI/Sources/ChatMessageGiveawayBubbleContentNode.swift @@ -18,6 +18,7 @@ import AvatarNode import ChatMessageDateAndStatusNode import ChatMessageBubbleContentNode import ChatMessageItemCommon +import UndoUI private let titleFont = Font.medium(15.0) private let textFont = Font.regular(13.0) @@ -35,6 +36,8 @@ class ChatMessageGiveawayBubbleContentNode: ChatMessageBubbleContentNode { private let participantsTitleNode: TextNode private let participantsTextNode: TextNode + private let countriesTextNode: TextNode + private let dateTitleNode: TextNode private let dateTextNode: TextNode @@ -81,6 +84,8 @@ class ChatMessageGiveawayBubbleContentNode: ChatMessageBubbleContentNode { self.participantsTitleNode = TextNode() self.participantsTextNode = TextNode() + self.countriesTextNode = TextNode() + self.dateTitleNode = TextNode() self.dateTextNode = TextNode() @@ -98,6 +103,7 @@ class ChatMessageGiveawayBubbleContentNode: ChatMessageBubbleContentNode { self.addSubnode(self.prizeTextNode) self.addSubnode(self.participantsTitleNode) self.addSubnode(self.participantsTextNode) + self.addSubnode(self.countriesTextNode) self.addSubnode(self.dateTitleNode) self.addSubnode(self.dateTextNode) self.addSubnode(self.buttonNode) @@ -137,8 +143,18 @@ class ChatMessageGiveawayBubbleContentNode: ChatMessageBubbleContentNode { override func didLoad() { super.didLoad() -// let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(self.contactTap(_:))) -// self.view.addGestureRecognizer(tapRecognizer) + let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(self.bubbleTap(_:))) + self.view.addGestureRecognizer(tapRecognizer) + } + + @objc private func bubbleTap(_ gestureRecognizer: UITapGestureRecognizer) { + guard let item = self.item else { + return + } + + let presentationData = item.context.sharedContext.currentPresentationData.with { $0 } + let controller = UndoOverlayController(presentationData: presentationData, content: .info(title: nil, text: "You can't participate in this giveaway.", timeout: nil), elevatedLayout: false, position: .bottom, animateInAsReplacement: false, action: { _ in return false }) + item.controllerInteraction.presentController(controller, nil) } private func removePlaceholder(animated: Bool) { @@ -160,6 +176,8 @@ class ChatMessageGiveawayBubbleContentNode: ChatMessageBubbleContentNode { let makeParticipantsTitleLayout = TextNode.asyncLayout(self.participantsTitleNode) let makeParticipantsTextLayout = TextNode.asyncLayout(self.participantsTextNode) + let makeCountriesTextLayout = TextNode.asyncLayout(self.countriesTextNode) + let makeDateTitleLayout = TextNode.asyncLayout(self.dateTitleNode) let makeDateTextLayout = TextNode.asyncLayout(self.dateTextNode) @@ -215,12 +233,14 @@ class ChatMessageGiveawayBubbleContentNode: ChatMessageBubbleContentNode { let participantsTitleString = NSAttributedString(string: "Participants", font: titleFont, textColor: textColor) let participantsText: String + let countriesText: String + if let giveaway { if giveaway.flags.contains(.onlyNewSubscribers) { if giveaway.channelPeerIds.count > 1 { participantsText = "All users who join the channels below after this date:" } else { - participantsText = "All users who join this channel below after this date:" + participantsText = "All users who join this channel after this date:" } } else { if giveaway.channelPeerIds.count > 1 { @@ -229,12 +249,41 @@ class ChatMessageGiveawayBubbleContentNode: ChatMessageBubbleContentNode { participantsText = "All subscribers of this channel:" } } + if !giveaway.countries.isEmpty { + let locale = localeWithStrings(item.presentationData.strings) + let countryNames = giveaway.countries.map { id in + if let countryName = locale.localizedString(forRegionCode: id) { + return "\(flagEmoji(countryCode: id))\(countryName)" + } else { + return id + } + } + var countries: String = "" + if countryNames.count == 1, let country = countryNames.first { + countries = country + } else { + for i in 0 ..< countryNames.count { + countries.append(countryNames[i]) + if i == countryNames.count - 2 { + countries.append(" and ") + } else if i < countryNames.count - 2 { + countries.append(", ") + } + } + } + countriesText = "from \(countries)" + } else { + countriesText = "" + } } else { participantsText = "" + countriesText = "" } let participantsTextString = NSAttributedString(string: participantsText, font: textFont, textColor: textColor) + let countriesTextString = NSAttributedString(string: countriesText, font: textFont, textColor: textColor) + let dateTitleString = NSAttributedString(string: "Winners Selection Date", font: titleFont, textColor: textColor) var dateTextString: NSAttributedString? if let giveaway { @@ -255,6 +304,8 @@ class ChatMessageGiveawayBubbleContentNode: ChatMessageBubbleContentNode { let (participantsTitleLayout, participantsTitleApply) = makeParticipantsTitleLayout(TextNodeLayoutArguments(attributedString: participantsTitleString, backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: maxTextWidth, height: CGFloat.greatestFiniteMagnitude), alignment: .center, cutout: nil, insets: UIEdgeInsets())) let (participantsTextLayout, participantsTextApply) = makeParticipantsTextLayout(TextNodeLayoutArguments(attributedString: participantsTextString, backgroundColor: nil, maximumNumberOfLines: 5, truncationType: .end, constrainedSize: CGSize(width: maxTextWidth, height: CGFloat.greatestFiniteMagnitude), alignment: .center, cutout: nil, insets: UIEdgeInsets())) + let (countriesTextLayout, countriesTextApply) = makeCountriesTextLayout(TextNodeLayoutArguments(attributedString: countriesTextString, backgroundColor: nil, maximumNumberOfLines: 5, truncationType: .end, constrainedSize: CGSize(width: maxTextWidth, height: CGFloat.greatestFiniteMagnitude), alignment: .center, cutout: nil, insets: UIEdgeInsets())) + let (dateTitleLayout, dateTitleApply) = makeDateTitleLayout(TextNodeLayoutArguments(attributedString: dateTitleString, backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: maxTextWidth, height: CGFloat.greatestFiniteMagnitude), alignment: .center, cutout: nil, insets: UIEdgeInsets())) let (dateTextLayout, dateTextApply) = makeDateTextLayout(TextNodeLayoutArguments(attributedString: dateTextString, backgroundColor: nil, maximumNumberOfLines: 5, truncationType: .end, constrainedSize: CGSize(width: maxTextWidth, height: CGFloat.greatestFiniteMagnitude), alignment: .center, cutout: nil, insets: UIEdgeInsets())) @@ -395,6 +446,9 @@ class ChatMessageGiveawayBubbleContentNode: ChatMessageBubbleContentNode { var layoutSize = CGSize(width: contentWidth, height: 49.0 + prizeTitleLayout.size.height + prizeTextLayout.size.height + participantsTitleLayout.size.height + participantsTextLayout.size.height + dateTitleLayout.size.height + dateTextLayout.size.height + buttonSize.height + buttonSpacing + 120.0) + if countriesTextLayout.size.height > 0.0 { + layoutSize.height += countriesTextLayout.size.height + 7.0 + } layoutSize.height += channelButtonSize.height if let statusSizeAndApply = statusSizeAndApply { @@ -414,16 +468,13 @@ class ChatMessageGiveawayBubbleContentNode: ChatMessageBubbleContentNode { strongSelf.updateVisibility() let _ = badgeTextApply() - let _ = prizeTitleApply() let _ = prizeTextApply() - let _ = participantsTitleApply() let _ = participantsTextApply() - + let _ = countriesTextApply() let _ = dateTitleApply() let _ = dateTextApply() - let _ = channelButtonApply() let _ = buttonApply() @@ -456,7 +507,14 @@ class ChatMessageGiveawayBubbleContentNode: ChatMessageBubbleContentNode { originY += participantsTextLayout.size.height + smallSpacing * 2.0 + 3.0 strongSelf.channelButton.frame = CGRect(origin: CGPoint(x: floorToScreenPixels((layoutSize.width - channelButtonSize.width) / 2.0), y: originY), size: channelButtonSize) - originY += channelButtonSize.height + largeSpacing + originY += channelButtonSize.height + + if countriesTextLayout.size.height > 0.0 { + originY += smallSpacing * 2.0 + 3.0 + strongSelf.countriesTextNode.frame = CGRect(origin: CGPoint(x: floorToScreenPixels((layoutSize.width - countriesTextLayout.size.width) / 2.0), y: originY), size: countriesTextLayout.size) + originY += countriesTextLayout.size.height + } + originY += largeSpacing strongSelf.dateTitleNode.frame = CGRect(origin: CGPoint(x: floorToScreenPixels((layoutSize.width - dateTitleLayout.size.width) / 2.0), y: originY), size: dateTitleLayout.size) originY += dateTitleLayout.size.height + smallSpacing @@ -522,25 +580,21 @@ class ChatMessageGiveawayBubbleContentNode: ChatMessageBubbleContentNode { override func tapActionAtPoint(_ point: CGPoint, gesture: TapLongTapOrDoubleTapGesture, isEstimating: Bool) -> ChatMessageBubbleContentTapAction { if self.buttonNode.frame.contains(point) { - return .openMessage + return .ignore } if self.dateAndStatusNode.supernode != nil, let _ = self.dateAndStatusNode.hitTest(self.view.convert(point, to: self.dateAndStatusNode.view), with: nil) { return .ignore } return .none } - - @objc func contactTap(_ recognizer: UITapGestureRecognizer) { - if case .ended = recognizer.state { - if let item = self.item { - let _ = item.controllerInteraction.openMessage(item.message, .default) - } - } - } - + @objc private func buttonPressed() { if let item = self.item { let _ = item.controllerInteraction.openMessage(item.message, .default) + self.buttonNode.startShimmering() + Queue.mainQueue().after(0.75) { + self.buttonNode.stopShimmering() + } } }