diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index 9e085df4b94..fc112237f35 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -2981,8 +2981,8 @@ "general": { "bzlTransitiveDigest": "YjE3dFjYQ4sj5gn2Iz1cWVK14/ZJ5cmnAUUGjDbACAA=", "recordedFileInputs": { - "@@//Package.resolved": "d58e5546b1f3bee716eaf1913ebad2e4acb5a7cdc2c0fd35f56f69721a023b57", - "@@//Package.swift": "b55c7ef028c6f3d7460b682fbf5d3c7be38b92396f24d18cbe8467244e35b673" + "@@//Package.resolved": "30140fa448ac62bfa0781affd60171e7fbacc1f2d1e0f02a336c356162c102c7", + "@@//Package.swift": "fb3cb1d48066e64f8bf17fe1a49f689b7a6bf4bfc07aa90b9b80a02188501951" }, "recordedDirentsInputs": {}, "envVariables": {}, @@ -3082,7 +3082,7 @@ "ruleClassName": "swift_package", "attributes": { "bazel_package_name": "swiftpkg_swiftui_flow", - "commit": "3086a602b98155eec28b4be79210d6cb1a43e339", + "commit": "9d122ace53e143dc3e1bf61c01a024535b0c7ab7", "remote": "https://github.com/denis15yo/SwiftUI-Flow.git", "init_submodules": false, "recursive_init_submodules": true, @@ -3208,7 +3208,7 @@ "ruleClassName": "swift_package", "attributes": { "bazel_package_name": "swiftpkg_core_swift", - "commit": "a37b52af6bc6ca9d7e4d2fe0942f91ed16c7c6ac", + "commit": "78f8920a260775686dd0e04f5045677447bb7a6c", "remote": "https://github.com/denis15yo/core-swift.git", "init_submodules": false, "recursive_init_submodules": true, @@ -3226,7 +3226,7 @@ "ruleClassName": "swift_package", "attributes": { "bazel_package_name": "swiftpkg_walletconnectswiftv2", - "commit": "3327c0a8c014b155534b91520554d812e2960077", + "commit": "b6e9e37ab5981444f3898653c34fd39284534aad", "remote": "https://github.com/WalletConnect/WalletConnectSwiftV2.git", "init_submodules": false, "recursive_init_submodules": true, @@ -3460,7 +3460,7 @@ "ruleClassName": "swift_package", "attributes": { "bazel_package_name": "swiftpkg_nicegram_assistant_ios", - "commit": "074eb3968c63b72ddf36e77b729245fed7c03f52", + "commit": "fda1e89c94fc546d2762d0b2d754e9654965774f", "remote": "git@bitbucket.org:mobyrix/nicegram-assistant-ios.git", "init_submodules": false, "recursive_init_submodules": true, @@ -3532,7 +3532,7 @@ "ruleClassName": "swift_package", "attributes": { "bazel_package_name": "swiftpkg_grdb.swift", - "commit": "156d630c7a4175ddf4d529244f4672428cc6e2fc", + "commit": "afc958017ee4feefd3c61c8e2cddf81d079d2e39", "remote": "https://github.com/denis15yo/GRDB.swift.git", "init_submodules": false, "recursive_init_submodules": true, @@ -3568,7 +3568,7 @@ "ruleClassName": "swift_package", "attributes": { "bazel_package_name": "swiftpkg_floatingpanel", - "commit": "b6e8928b1a3ad909e6db6a0278d286c33cfd0dc3", + "commit": "71f419a3cd212afc7615e2179c2fec1df1aa74da", "remote": "https://github.com/scenee/FloatingPanel", "init_submodules": false, "recursive_init_submodules": true, @@ -3658,7 +3658,7 @@ "ruleClassName": "swift_package", "attributes": { "bazel_package_name": "swiftpkg_wallet_core", - "commit": "db50956fe49d7feb5aca3a4406f49b722e5cfab5", + "commit": "160a6e6275d25c0edb91823ae62c605d3d66c013", "remote": "https://github.com/trustwallet/wallet-core.git", "init_submodules": false, "recursive_init_submodules": true, @@ -3694,7 +3694,7 @@ "ruleClassName": "swift_package", "attributes": { "bazel_package_name": "swiftpkg_xcodeedit", - "commit": "49734e514af8e917f5aa2112ce59a0eea542580c", + "commit": "b6b67389a0f1a6fdd9c6457a8ab5b02eaab13c5c", "remote": "https://github.com/tomlokhorst/XcodeEdit", "init_submodules": false, "recursive_init_submodules": true, @@ -3712,7 +3712,7 @@ "ruleClassName": "swift_package", "attributes": { "bazel_package_name": "swiftpkg_nicegram_wallet_ios", - "commit": "0717b9623950e20f7834547792e30e28fd921b77", + "commit": "458be079925da10ff15a2b41961ae3bfb4468835", "remote": "git@bitbucket.org:mobyrix/nicegram-wallet-ios.git", "init_submodules": false, "recursive_init_submodules": true, diff --git a/Nicegram/NGEnv/Sources/NGEnv.swift b/Nicegram/NGEnv/Sources/NGEnv.swift index b8b5b5232f0..e4f4a3d26e9 100644 --- a/Nicegram/NGEnv/Sources/NGEnv.swift +++ b/Nicegram/NGEnv/Sources/NGEnv.swift @@ -25,8 +25,6 @@ public struct NGEnvObj: Decodable { public let web3AuthBackupQuestion: String public let web3AuthClientId: String public let web3AuthVerifier: String - public let stonfiApiUrl: String - public let stonfiNicegramApiUrl: String } } diff --git a/Package.resolved b/Package.resolved index 883dbe4e403..47b52bf89c9 100644 --- a/Package.resolved +++ b/Package.resolved @@ -33,7 +33,7 @@ "location" : "https://github.com/denis15yo/core-swift.git", "state" : { "branch" : "release/1.0.0", - "revision" : "a37b52af6bc6ca9d7e4d2fe0942f91ed16c7c6ac" + "revision" : "78f8920a260775686dd0e04f5045677447bb7a6c" } }, { @@ -122,8 +122,8 @@ "kind" : "remoteSourceControl", "location" : "git@bitbucket.org:mobyrix/nicegram-assistant-ios.git", "state" : { - "branch" : "feat/stonfi-swap", - "revision" : "074eb3968c63b72ddf36e77b729245fed7c03f52" + "branch" : "develop", + "revision" : "44a21c1abe091fd57733c626b79cf095ddedc465" } }, { @@ -131,8 +131,8 @@ "kind" : "remoteSourceControl", "location" : "git@bitbucket.org:mobyrix/nicegram-wallet-ios.git", "state" : { - "branch" : "feat/stonfi-swap", - "revision" : "0717b9623950e20f7834547792e30e28fd921b77" + "branch" : "develop", + "revision" : "b11641a03c3b2d90e0a8c7ce3d93fe2b8505acb4" } }, { @@ -374,8 +374,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/tomlokhorst/XcodeEdit", "state" : { - "revision" : "49734e514af8e917f5aa2112ce59a0eea542580c", - "version" : "2.10.1" + "revision" : "b6b67389a0f1a6fdd9c6457a8ab5b02eaab13c5c", + "version" : "2.9.2" } } ], diff --git a/Package.swift b/Package.swift index fc571616479..fbff9b5bdb3 100644 --- a/Package.swift +++ b/Package.swift @@ -5,7 +5,7 @@ import PackageDescription let package = Package( name: "nicegram-package", dependencies: [ - .package(url: "git@bitbucket.org:mobyrix/nicegram-assistant-ios.git", branch: "feat/stonfi-swap"), - .package(url: "git@bitbucket.org:mobyrix/nicegram-wallet-ios.git", branch: "feat/stonfi-swap") + .package(url: "git@bitbucket.org:mobyrix/nicegram-assistant-ios.git", branch: "develop"), + .package(url: "git@bitbucket.org:mobyrix/nicegram-wallet-ios.git", branch: "develop") ] ) diff --git a/Telegram/Telegram-iOS/en.lproj/Localizable.strings b/Telegram/Telegram-iOS/en.lproj/Localizable.strings index 706a1a80619..d58fadc4681 100644 --- a/Telegram/Telegram-iOS/en.lproj/Localizable.strings +++ b/Telegram/Telegram-iOS/en.lproj/Localizable.strings @@ -5633,6 +5633,8 @@ Sorry for the inconvenience."; "Settings.RemoveConfirmation" = "Remove"; +"Conversation.ContextMenuOpenInfo" = "Open Info"; + "Conversation.ContextMenuOpenProfile" = "Open Profile"; "Conversation.ContextMenuSendMessage" = "Send Message"; "Conversation.ContextMenuMention" = "Mention"; @@ -10290,6 +10292,9 @@ Sorry for the inconvenience."; "BoostGift.GiveawayCreated.Text" = "Check your channel's [Statistics]() to see how this giveaway boosted your channel."; "BoostGift.PremiumGifted.Title" = "Premium Subscriptions Gifted"; "BoostGift.PremiumGifted.Text" = "Check your channel's [Statistics]() to see how gifts boosted your channel."; +"BoostGift.StarsGiveawayCreated.Title" = "Stars Giveaway Created"; +"BoostGift.StarsGiveawayCreated.Text" = "Check your channel's [Statistics]() to see how this giveaway boosted your channel."; +"BoostGift.Group.StarsGiveawayCreated.Text" = "Check your group's [Boosts]() to see how this giveaway boosted your group."; "BoostGift.Subscribers.Title" = "Gift Premium"; "BoostGift.Subscribers.Subtitle" = "select up to %@ subscribers"; @@ -10505,11 +10510,15 @@ Sorry for the inconvenience."; "Notification.GiveawayResultsNoWinners_1" = "Due to the giveaway terms, no winners could be selected by Telegram, a gift link was forwarded to channel administrators."; "Notification.GiveawayResultsNoWinners_any" = "Due to the giveaway terms, no winners could be selected by Telegram, all **%@** gift links were forwarded to channel administrators."; +"Notification.GiveawayResultsNoWinners.Group_1" = "Due to the giveaway terms, no winners could be selected by Telegram, a gift link was forwarded to group administrators."; +"Notification.GiveawayResultsNoWinners.Group_any" = "Due to the giveaway terms, no winners could be selected by Telegram, all **%@** gift links were forwarded to group administrators."; "Notification.GiveawayResultsMixedWinners_1" = "**%@** winner of the giveaway was randomly selected by Telegram and received their gift link in a private message."; "Notification.GiveawayResultsMixedWinners_any" = "**%@** winners of the giveaway were randomly selected by Telegram and received their gift links in private messages."; "Notification.GiveawayResultsMixedUnclaimed_1" = "**%@** undistributed gift link was forwarded to channel administrators"; "Notification.GiveawayResultsMixedUnclaimed_any" = "**%@** undistributed gift links were forwarded to channel administrators"; +"Notification.GiveawayResultsMixedUnclaimed.Group_1" = "**%@** undistributed gift link was forwarded to group administrators"; +"Notification.GiveawayResultsMixedUnclaimed.Group_any" = "**%@** undistributed gift links were forwarded to group administrators"; "Chat.Giveaway.DeleteConfirmation.Title" = "Do you want to delete the Giveaway Announcement?"; "Chat.Giveaway.DeleteConfirmation.Text" = "Deleting this message won't cancel the giveaway - the winners will still be selected on **%@**.\n\nOnce deleted, the Giveaway Announcement cannot be recovered."; @@ -12264,6 +12273,7 @@ Sorry for the inconvenience."; "Stars.Intro.Incoming" = "Incoming"; "Stars.Intro.Outgoing" = "Outgoing"; +"Stars.Intro.Transaction.GiveawayPrize" = "Giveaway Prize"; "Stars.Intro.Transaction.MediaPurchase" = "Media Purchase"; "Stars.Intro.Transaction.AppleTopUp.Title" = "Stars Top-Up"; "Stars.Intro.Transaction.AppleTopUp.Subtitle" = "via App Store"; @@ -12811,6 +12821,121 @@ Sorry for the inconvenience."; "Chat.ToastStarsSent.Title_1" = "Star sent!"; "Chat.ToastStarsSent.Title_any" = "Stars sent!"; + +"Chat.ToastStarsSent.AnonymousTitle_1" = "Star sent anonymously!"; +"Chat.ToastStarsSent.AnonymousTitle_any" = "Stars sent anonymously!"; + "Chat.ToastStarsSent.Text" = "You have reacted with %1$@ %2$@."; "Chat.ToastStarsSent.TextStarAmount_1" = "star"; "Chat.ToastStarsSent.TextStarAmount_any" = "stars"; + +"Stars.Purchase.StarsReactionsNeededInfo" = "Buy Stars to send paid reactions **%@** and other channels."; +"Chat.ToastStarsReactionsDisabled" = "Star Reactions were disabled in %@"; + +"ChatContextMenu.SingleReactionEmojiSet" = "This reaction is from #[%@]() emoji pack."; + +"Channel.AdminLog.MessageParticipantSubscriptionExtended" = "%@ extended their subscription"; + +"Notification.StarsPrize" = "You received a gift"; + +"Notification.GiveawayStartedStars" = "%1$@ just started a giveaway of %2$@ Telegram Stars for its followers."; +"Notification.GiveawayStartedStarsGroup" = "%1$@ just started a giveaway of %2$@ Telegram Stars for its members."; + +"Notification.StarsGiveawayStarted" = "%1$@ just started a giveaway of %2$@ for its followers."; +"Notification.StarsGiveawayStartedGroup" = "%1$@ just started a giveaway of %2$@ for its members."; +"Notification.StarsGiveawayStarted.Stars_1" = "%@ Telegram Star"; +"Notification.StarsGiveawayStarted.Stars_any" = "%@ Telegram Stars"; + +"Chat.Giveaway.Message.Stars.PrizeText" = "%1$@ will be distributed %2$@."; +"Chat.Giveaway.Message.Stars.Stars_1" = "**%@** Stars"; +"Chat.Giveaway.Message.Stars.Stars_any" = "**%@** Stars"; +"Chat.Giveaway.Message.Stars.Winners_1" = "to **%@** winner"; +"Chat.Giveaway.Message.Stars.Winners_any" = "among **%@** winners"; + +"Chat.Giveaway.Info.Stars.Stars_1" = "**%@ Star**"; +"Chat.Giveaway.Info.Stars.Stars_any" = "**%@ Stars**"; +"Chat.Giveaway.Info.Stars.OngoingIntro" = "The giveaway is sponsored by the admins of **%1$@**, who acquired %2$@ for its followers."; +"Chat.Giveaway.Info.Stars.EndedIntro" = "The giveaway was sponsored by the admins of **%1$@**, who acquired %2$@ for its followers."; +"Chat.Giveaway.Info.Stars.Group.OngoingIntro" = "The giveaway is sponsored by the admins of **%1$@**, who acquired %2$@ for its members."; +"Chat.Giveaway.Info.Stars.Group.EndedIntro" = "The giveaway was sponsored by the admins of **%1$@**, who acquired %2$@ for its members."; + +"Stars.Transaction.Giveaway.Title" = "Received Prize"; +"Stars.Transaction.Giveaway.Reason" = "Reason"; +"Stars.Transaction.Giveaway.Giveaway" = "Giveaway"; +"Stars.Transaction.Giveaway.Prize" = "Prize"; +"Stars.Transaction.Giveaway.Stars_1" = "%@ Star"; +"Stars.Transaction.Giveaway.Stars_any" = "%@ Stars"; + +"Stats.RevenueInTon" = "Revenue in TON"; +"Stats.RevenueInStars" = "Revenue in Stars"; +"Stats.RevenueInUsd" = "Revenue in USD"; + +"BoostGift.NewDescriptionGroup" = "Get more boosts and members for\nyour group by giving away prizes."; +"BoostGift.NewDescription" = "Get more boosts and subscribers for\nyour channel by giving away prizes."; +"BoostGift.Prize" = "PRIZE"; +"BoostGift.Prize.Premium" = "Telegram Premium"; +"BoostGift.Prize.Stars" = "Telegram Stars"; +"BoostGift.Stars.Title" = "STARS TO DISTRIBUTE"; +"BoostGift.Stars.Boosts_1" = "%@ BOOST"; +"BoostGift.Stars.Boosts_any" = "%@ BOOSTS"; +"BoostGift.Stars.Stars_1" = "%@ Star"; +"BoostGift.Stars.Stars_any" = "%@ Stars"; +"BoostGift.Stars.PerUser" = "%@ per user"; +"BoostGift.Stars.ShowMoreOptions" = "Show More Options"; +"BoostGift.Stars.Info" = "Choose how many stars to give away and how many boosts to receive for 1 year."; +"BoostGift.AdditionalPrizesInfoStarsOff" = "Turn this on if you want to give the winners your own prizes in addition to Stars."; + +"BoostGift.AdditionalPrizesInfoStars_1" = "**%@** Star"; +"BoostGift.AdditionalPrizesInfoStars_any" = "**%@** Stars"; +"BoostGift.AdditionalPrizesInfoStarsOn" = "All prizes: %1$@%2$@."; +"BoostGift.AdditionalPrizesInfoStarsAndOther" = " and **%1$@** %2$@"; + +"BoostGift.Stars.Winners" = "NUMBER OF WINNERS"; +"BoostGift.Stars.WinnersInfo" = "Choose how many winners you want to distribute stars among."; + +"BoostGift.Group.StarsDateInfo" = "Choose when %@ of your group will be randomly selected to receive Stars."; +"BoostGift.StarsDateInfo" = "Choose when %@ of your channel will be randomly selected to receive Stars."; + +"BoostGift.PrepaidGiveawayStarsCount_1" = "%@ Telegram Premium"; +"BoostGift.PrepaidGiveawayStarsCount_any" = "%@ Telegram Premium"; +"BoostGift.PrepaidGiveawayStarsMonths" = "%@-month subscriptions"; + +"WebBrowser.ShowInstantView" = "Show Instant View"; +"WebBrowser.HideInstantView" = "Hide Instant View"; + +"Stats.Boosts.Stars_1" = "%@ Star"; +"Stats.Boosts.Stars_any" = "%@ Stars"; +"Stats.Boosts.StarsWinners_1" = "for %@ winner"; +"Stats.Boosts.StarsWinners_any" = "among %@ winners"; + +"BoostGift.PrepaidGiveaway.StarsCount_1" = "%@ Star"; +"BoostGift.PrepaidGiveaway.StarsCount_any" = "%@ Stars"; +"BoostGift.PrepaidGiveaway.StarsWinners_1" = "for %@ winner"; +"BoostGift.PrepaidGiveaway.StarsWinners_any" = "among %@ winners"; + +"Stars.Transaction.Giveaway.Boost.Stars_1" = "%@ Star"; +"Stars.Transaction.Giveaway.Boost.Stars_any" = "%@ Stars"; +"Stars.Transaction.Giveaway.Boost.Boosts_1" = "%@ Boost"; +"Stars.Transaction.Giveaway.Boost.Boosts_any" = "%@ Boosts"; +"Stars.Transaction.Giveaway.Boost.Members_1" = "%@ Member"; +"Stars.Transaction.Giveaway.Boost.Members_any" = "%@ Members"; +"Stars.Transaction.Giveaway.Boost.Subscriber_1" = "%@ Subscriber"; +"Stars.Transaction.Giveaway.Boost.Subscriber_any" = "%@ Subscribers"; + +"Conversation.ContextMenuCopyEmail" = "Copy Email"; + +"Chat.Giveaway.Message.WinnersInfo.Stars_1" = "**%@ Star**"; +"Chat.Giveaway.Message.WinnersInfo.Stars_any" = "**%@ Stars**"; +"Chat.Giveaway.Message.WinnersInfo.Stars.One" = "The winner received %@."; +"Chat.Giveaway.Message.WinnersInfo.Stars.Many" = "All winners received a total of %@."; + +"Premium.BoostByGiveawayDescription" = "Get more boosts and subscribers for your channel by giving away prizes. [Get boosts >]()"; +"Premium.Group.BoostByGiveawayDescription" = "Get more boosts and members for your group by giving away prizes. [Get boosts >]()"; + +"Notification.StarsGiveawayResultsNoWinners" = "Due to the giveaway terms, no winners could be selected by Telegram, all stars were credited to channel administrators."; +"Notification.StarsGiveawayResultsNoWinners.Group" = "Due to the giveaway terms, no winners could be selected by Telegram, all stars were credited to group administrators."; + +"Notification.StarsGiveaway.Title" = "Congratulations!"; +"Notification.StarsGiveaway.Subtitle" = "You won a prize in a giveaway organized by **%1$@**.\n\nYour prize is **%2$@**."; +"Notification.StarsGiveaway.Subtitle.Stars_1" = "%@ Star"; +"Notification.StarsGiveaway.Subtitle.Stars_any" = "%@ Stars"; diff --git a/build-system/Make/BuildConfiguration.py b/build-system/Make/BuildConfiguration.py index 061837bb068..9fa7dd4ee19 100644 --- a/build-system/Make/BuildConfiguration.py +++ b/build-system/Make/BuildConfiguration.py @@ -144,13 +144,14 @@ def load_codesigning_data_from_git(working_dir, repo_url, temp_key_path, branch, encrypted_working_dir = working_dir + '/encrypted' if os.path.exists(encrypted_working_dir): + original_working_dir = os.getcwd() + os.chdir(encrypted_working_dir) if always_fetch: - original_working_dir = os.getcwd() - os.chdir(encrypted_working_dir) check_run_system('GIT_SSH_COMMAND="{ssh_command}" git fetch'.format(ssh_command=ssh_command)) - check_run_system('git checkout "{branch}"'.format(branch=branch)) + check_run_system('git checkout "{branch}"'.format(branch=branch)) + if always_fetch: check_run_system('GIT_SSH_COMMAND="{ssh_command}" git pull'.format(ssh_command=ssh_command)) - os.chdir(original_working_dir) + os.chdir(original_working_dir) else: os.makedirs(encrypted_working_dir, exist_ok=True) original_working_dir = os.getcwd() diff --git a/submodules/AccountContext/Sources/AccountContext.swift b/submodules/AccountContext/Sources/AccountContext.swift index 3c61855e8e1..31f77b3dec6 100644 --- a/submodules/AccountContext/Sources/AccountContext.swift +++ b/submodules/AccountContext/Sources/AccountContext.swift @@ -1022,6 +1022,7 @@ public protocol SharedAccountContext: AnyObject { func makeStarsAmountScreen(context: AccountContext, initialValue: Int64?, completion: @escaping (Int64) -> Void) -> ViewController func makeStarsWithdrawalScreen(context: AccountContext, stats: StarsRevenueStats, completion: @escaping (Int64) -> Void) -> ViewController func makeStarsGiftScreen(context: AccountContext, message: EngineMessage) -> ViewController + func makeStarsGiveawayBoostScreen(context: AccountContext, peerId: EnginePeer.Id, boost: ChannelBoostersContext.State.Boost) -> ViewController func makeMiniAppListScreenInitialData(context: AccountContext) -> Signal func makeMiniAppListScreen(context: AccountContext, initialData: MiniAppListScreenInitialData) -> ViewController diff --git a/submodules/AccountContext/Sources/AttachmentMainButtonState.swift b/submodules/AccountContext/Sources/AttachmentMainButtonState.swift index 3bcceef06d2..026706daf66 100644 --- a/submodules/AccountContext/Sources/AttachmentMainButtonState.swift +++ b/submodules/AccountContext/Sources/AttachmentMainButtonState.swift @@ -5,6 +5,13 @@ public struct AttachmentMainButtonState { public enum Background { case color(UIColor) case premium + + public var colorValue: UIColor? { + if case let .color(color) = self { + return color + } + return nil + } } public enum Progress: Equatable { @@ -18,6 +25,13 @@ public struct AttachmentMainButtonState { case bold } + public enum Position: String, Equatable { + case top + case bottom + case left + case right + } + public let text: String? public let font: Font public let background: Background @@ -25,6 +39,8 @@ public struct AttachmentMainButtonState { public let isVisible: Bool public let progress: Progress public let isEnabled: Bool + public let hasShimmer: Bool + public let position: Position? public init( text: String?, @@ -33,7 +49,9 @@ public struct AttachmentMainButtonState { textColor: UIColor, isVisible: Bool, progress: Progress, - isEnabled: Bool + isEnabled: Bool, + hasShimmer: Bool, + position: Position? = nil ) { self.text = text self.font = font @@ -42,9 +60,11 @@ public struct AttachmentMainButtonState { self.isVisible = isVisible self.progress = progress self.isEnabled = isEnabled + self.hasShimmer = hasShimmer + self.position = position } public static var initial: AttachmentMainButtonState { - return AttachmentMainButtonState(text: nil, font: .bold, background: .color(.clear), textColor: .clear, isVisible: false, progress: .none, isEnabled: false) + return AttachmentMainButtonState(text: nil, font: .bold, background: .color(.clear), textColor: .clear, isVisible: false, progress: .none, isEnabled: false, hasShimmer: false) } } diff --git a/submodules/AccountContext/Sources/ChatController.swift b/submodules/AccountContext/Sources/ChatController.swift index 315d1ec2541..bc00816ad63 100644 --- a/submodules/AccountContext/Sources/ChatController.swift +++ b/submodules/AccountContext/Sources/ChatController.swift @@ -1021,7 +1021,8 @@ public protocol ChatController: ViewController { var canReadHistory: ValuePromise { get } var parentController: ViewController? { get set } var customNavigationController: NavigationController? { get set } - + + var dismissPreviewing: (() -> Void)? { get set } var purposefulAction: (() -> Void)? { get set } var stateUpdated: ((ContainedViewLayoutTransition) -> Void)? { get set } diff --git a/submodules/AccountContext/Sources/Premium.swift b/submodules/AccountContext/Sources/Premium.swift index b3950170573..5cd4fd732e3 100644 --- a/submodules/AccountContext/Sources/Premium.swift +++ b/submodules/AccountContext/Sources/Premium.swift @@ -126,6 +126,7 @@ public enum StarsPurchasePurpose: Equatable { case generic case topUp(requiredStars: Int64, purpose: String?) case transfer(peerId: EnginePeer.Id, requiredStars: Int64) + case reactions(peerId: EnginePeer.Id, requiredStars: Int64) case subscription(peerId: EnginePeer.Id, requiredStars: Int64, renew: Bool) case gift(peerId: EnginePeer.Id) case unlockMedia(requiredStars: Int64) diff --git a/submodules/AccountContext/Sources/PresentationCallManager.swift b/submodules/AccountContext/Sources/PresentationCallManager.swift index c232006fb21..d3e44953ea1 100644 --- a/submodules/AccountContext/Sources/PresentationCallManager.swift +++ b/submodules/AccountContext/Sources/PresentationCallManager.swift @@ -335,14 +335,14 @@ public enum PresentationGroupCallTone { case recordingStarted } -public struct PresentationGroupCallRequestedVideo { +public struct PresentationGroupCallRequestedVideo: Equatable { public enum Quality { case thumbnail case medium case full } - public struct SsrcGroup { + public struct SsrcGroup: Equatable { public var semantics: String public var ssrcs: [UInt32] } @@ -441,6 +441,7 @@ public protocol PresentationGroupCall: AnyObject { func updateDefaultParticipantsAreMuted(isMuted: Bool) func setVolume(peerId: EnginePeer.Id, volume: Int32, sync: Bool) func setRequestedVideoList(items: [PresentationGroupCallRequestedVideo]) + func setSuspendVideoChannelRequests(_ value: Bool) func setCurrentAudioOutput(_ output: AudioSessionOutput) func playTone(_ tone: PresentationGroupCallTone) diff --git a/submodules/AttachmentUI/Sources/AttachmentController.swift b/submodules/AttachmentUI/Sources/AttachmentController.swift index 60646efeb7a..4653f67ba95 100644 --- a/submodules/AttachmentUI/Sources/AttachmentController.swift +++ b/submodules/AttachmentUI/Sources/AttachmentController.swift @@ -213,10 +213,15 @@ public protocol AttachmentMediaPickerContext { var price: Int64? { get } func setPrice(_ price: Int64) -> Void + var hasTimers: Bool { get } + var loadingProgress: Signal { get } var mainButtonState: Signal { get } + var secondaryButtonState: Signal { get } + var bottomPanelBackgroundColor: Signal { get } func mainButtonAction() + func secondaryButtonAction() func setCaption(_ caption: NSAttributedString) func send(mode: AttachmentMediaPickerSendMode, attachmentMode: AttachmentMediaPickerAttachmentMode, parameters: ChatSendMessageActionSheetController.SendParameters?) @@ -254,6 +259,10 @@ public extension AttachmentMediaPickerContext { func setPrice(_ price: Int64) -> Void { } + var hasTimers: Bool { + return false + } + var loadingProgress: Signal { return .single(nil) } @@ -261,6 +270,14 @@ public extension AttachmentMediaPickerContext { var mainButtonState: Signal { return .single(nil) } + + var secondaryButtonState: Signal { + return .single(nil) + } + + var bottomPanelBackgroundColor: Signal { + return .single(nil) + } func setCaption(_ caption: NSAttributedString) { } @@ -273,6 +290,9 @@ public extension AttachmentMediaPickerContext { func mainButtonAction() { } + + func secondaryButtonAction() { + } } private func generateShadowImage() -> UIImage? { @@ -364,6 +384,8 @@ public class AttachmentController: ViewController, MinimizableController { private let loadingProgressDisposable = MetaDisposable() private let mainButtonStateDisposable = MetaDisposable() + private let secondaryButtonStateDisposable = MetaDisposable() + private let bottomPanelBackgroundColorDisposable = MetaDisposable() private var selectionCount: Int = 0 @@ -408,11 +430,44 @@ public class AttachmentController: ViewController, MinimizableController { }) } })) + self.secondaryButtonStateDisposable.set((mediaPickerContext.secondaryButtonState + |> deliverOnMainQueue).startStrict(next: { [weak self] mainButtonState in + if let strongSelf = self { + let _ = (strongSelf.panel.animatingTransitionPromise.get() + |> filter { value in + return !value + } + |> take(1)).startStandalone(next: { [weak self] _ in + if let strongSelf = self { + strongSelf.panel.updateSecondaryButtonState(mainButtonState) + if let layout = strongSelf.validLayout { + strongSelf.containerLayoutUpdated(layout, transition: .animated(duration: 0.4, curve: .spring)) + } + } + }) + } + })) + self.bottomPanelBackgroundColorDisposable.set((mediaPickerContext.bottomPanelBackgroundColor + |> deliverOnMainQueue).startStrict(next: { [weak self] color in + if let strongSelf = self { + let _ = (strongSelf.panel.animatingTransitionPromise.get() + |> filter { value in + return !value + } + |> take(1)).startStandalone(next: { [weak self] _ in + if let strongSelf = self { + strongSelf.panel.updateCustomBottomPanelBackgroundColor(color) + } + }) + } + })) } else { self.updateSelectionCount(0) self.mediaSelectionCountDisposable.set(nil) self.loadingProgressDisposable.set(nil) self.mainButtonStateDisposable.set(nil) + self.secondaryButtonStateDisposable.set(nil) + self.bottomPanelBackgroundColorDisposable.set(nil) } } } @@ -590,12 +645,18 @@ public class AttachmentController: ViewController, MinimizableController { } } - self.panel.mainButtonPressed = { [weak self] in + self.panel.onMainButtonPressed = { [weak self] in if let strongSelf = self { strongSelf.mediaPickerContext?.mainButtonAction() } } + self.panel.onSecondaryButtonPressed = { [weak self] in + if let strongSelf = self { + strongSelf.mediaPickerContext?.secondaryButtonAction() + } + } + self.panel.requestLayout = { [weak self] in if let strongSelf = self, let layout = strongSelf.validLayout { strongSelf.containerLayoutUpdated(layout, transition: .animated(duration: 0.2, curve: .easeInOut)) @@ -628,6 +689,8 @@ public class AttachmentController: ViewController, MinimizableController { self.mediaSelectionCountDisposable.dispose() self.loadingProgressDisposable.dispose() self.mainButtonStateDisposable.dispose() + self.secondaryButtonStateDisposable.dispose() + self.bottomPanelBackgroundColorDisposable.dispose() } private var inputContainerHeight: CGFloat? @@ -985,10 +1048,7 @@ public class AttachmentController: ViewController, MinimizableController { var containerLayout = layout let containerRect: CGRect - var isCompact = true if case .regular = layout.metrics.widthClass { - isCompact = false - let availableHeight = layout.size.height - (layout.inputHeight ?? 0.0) - 60.0 let size = CGSize(width: 390.0, height: min(620.0, availableHeight)) @@ -1055,7 +1115,7 @@ public class AttachmentController: ViewController, MinimizableController { var containerInsets = containerLayout.intrinsicInsets var hasPanel = false - let previousHasButton = self.hasButton +// let previousHasButton = self.hasButton let hasButton = self.panel.isButtonVisible && !self.isDismissing self.hasButton = hasButton if let controller = self.controller, controller.buttons.count > 1 || controller.hasTextInput { @@ -1066,53 +1126,21 @@ public class AttachmentController: ViewController, MinimizableController { } let isEffecitvelyCollapsedUpdated = (self.selectionCount > 0) != (self.panel.isSelecting) - var panelHeight = self.panel.update(layout: containerLayout, buttons: self.controller?.buttons ?? [], isSelecting: self.selectionCount > 0, elevateProgress: !hasPanel && !hasButton, transition: transition) - if fromMenu && !hasButton, let inputContainerHeight = self.inputContainerHeight { - panelHeight = inputContainerHeight - } + let panelHeight = self.panel.update(layout: containerLayout, buttons: self.controller?.buttons ?? [], isSelecting: self.selectionCount > 0, elevateProgress: !hasPanel && !hasButton, transition: transition) if hasPanel || hasButton { containerInsets.bottom = panelHeight } - - var transitioning = false - if fromMenu && previousHasButton != hasButton, let (_, _, getTransition) = controller.getInputContainerNode(), let inputTransition = getTransition() { - if hasButton { - self.panel.animateTransitionIn(inputTransition: inputTransition, transition: transition) - } else { - self.panel.animateTransitionOut(inputTransition: inputTransition, dismissed: false, transition: transition) - } - transitioning = true - } var panelTransition = transition if isEffecitvelyCollapsedUpdated { panelTransition = .animated(duration: 0.25, curve: .easeInOut) } var panelY = containerRect.height - panelHeight - if fromMenu && isCompact { - panelY = layout.size.height - panelHeight - } else if !hasPanel && !hasButton { + if !hasPanel && !hasButton { panelY = containerRect.height } - - if fromMenu && isCompact { - if hasButton { - self.panel.isHidden = false - self.inputContainerNode?.isHidden = true - } else if !transitioning { - if !self.panel.animatingTransition { - self.panel.isHidden = true - self.inputContainerNode?.isHidden = false - } - } - } - - panelTransition.updateFrame(node: self.panel, frame: CGRect(origin: CGPoint(x: 0.0, y: panelY), size: CGSize(width: containerRect.width, height: panelHeight)), completion: { [weak self] finished in - if transitioning && finished, isCompact { - self?.panel.isHidden = !hasButton - self?.inputContainerNode?.isHidden = hasButton - } - }) + + panelTransition.updateFrame(node: self.panel, frame: CGRect(origin: CGPoint(x: 0.0, y: panelY), size: CGSize(width: containerRect.width, height: panelHeight))) var shadowFrame = containerRect.insetBy(dx: -60.0, dy: -60.0) shadowFrame.size.height -= 12.0 diff --git a/submodules/AttachmentUI/Sources/AttachmentPanel.swift b/submodules/AttachmentUI/Sources/AttachmentPanel.swift index 261b0b7c5dd..23036b7ebbd 100644 --- a/submodules/AttachmentUI/Sources/AttachmentPanel.swift +++ b/submodules/AttachmentUI/Sources/AttachmentPanel.swift @@ -463,7 +463,7 @@ private final class MainButtonNode: HighlightTrackingButtonNode { let diameter: CGFloat = size.height - 22.0 let progressFrame = CGRect(origin: CGPoint(x: floorToScreenPixels(buttonOffset + (buttonWidth - diameter) / 2.0), y: floorToScreenPixels((size.height - diameter) / 2.0)), size: CGSize(width: diameter, height: diameter)) progressNode.frame = progressFrame - progressNode.image = generateIndefiniteActivityIndicatorImage(color: .white, diameter: diameter, lineWidth: 3.0) + progressNode.image = generateIndefiniteActivityIndicatorImage(color: self.state.textColor, diameter: diameter, lineWidth: 3.0) self.addSubnode(progressNode) @@ -499,7 +499,7 @@ private final class MainButtonNode: HighlightTrackingButtonNode { } private func setupShimmering() { - if case .premium = self.state.background { + if self.state.hasShimmer { if self.shimmerView == nil { let shimmerView = ShimmerEffectForegroundView() shimmerView.isUserInteractionEnabled = false @@ -601,7 +601,7 @@ private final class MainButtonNode: HighlightTrackingButtonNode { } } - func updateLayout(size: CGSize, state: AttachmentMainButtonState, transition: ContainedViewLayoutTransition) { + func updateLayout(size: CGSize, state: AttachmentMainButtonState, animateBackground: Bool = false, transition: ContainedViewLayoutTransition) { let previousState = self.state self.state = state self.size = size @@ -610,6 +610,12 @@ private final class MainButtonNode: HighlightTrackingButtonNode { self.setupShimmering() + let colorUpdated = previousState.textColor != state.textColor + if let progressNode = self.progressNode, colorUpdated { + let diameter: CGFloat = size.height - 22.0 + progressNode.image = generateIndefiniteActivityIndicatorImage(color: state.textColor, diameter: diameter, lineWidth: 3.0) + } + if let text = state.text { let font: UIFont switch state.font { @@ -621,13 +627,23 @@ private final class MainButtonNode: HighlightTrackingButtonNode { self.textNode.attributedText = NSAttributedString(string: text, font: font, textColor: state.textColor) let textSize = self.textNode.updateLayout(size) - self.textNode.frame = CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - textSize.width) / 2.0), y: floorToScreenPixels((size.height - textSize.height) / 2.0)), size: textSize) + let textFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - textSize.width) / 2.0), y: floorToScreenPixels((size.height - textSize.height) / 2.0)), size: textSize) + if self.textNode.frame.width.isZero { + self.textNode.frame = textFrame + } else { + self.textNode.bounds = CGRect(origin: .zero, size: textSize) + transition.updatePosition(node: self.textNode, position: textFrame.center) + } switch state.background { case let .color(backgroundColor): self.backgroundAnimationNode.image = nil self.backgroundAnimationNode.layer.removeAllAnimations() - self.backgroundColor = backgroundColor + if animateBackground { + ContainedViewLayoutTransition.animated(duration: 0.2, curve: .linear).updateBackgroundColor(node: self, color: backgroundColor) + } else { + self.backgroundColor = backgroundColor + } case .premium: if self.backgroundAnimationNode.image == nil { let backgroundColors = [ @@ -706,9 +722,12 @@ final class AttachmentPanel: ASDisplayNode, ASScrollViewDelegate { private var textInputPanelNode: AttachmentTextInputPanelNode? private var progressNode: LoadingProgressNode? private var mainButtonNode: MainButtonNode + private var secondaryButtonNode: MainButtonNode private var loadingProgress: CGFloat? private var mainButtonState: AttachmentMainButtonState = .initial + private var secondaryButtonState: AttachmentMainButtonState = .initial + private var customBottomPanelBackgroundColor: UIColor? private var elevateProgress: Bool = false private var buttons: [AttachmentButtonType] = [] @@ -716,7 +735,7 @@ final class AttachmentPanel: ASDisplayNode, ASScrollViewDelegate { private(set) var isSelecting: Bool = false private var _isButtonVisible: Bool = false var isButtonVisible: Bool { - return self.mainButtonState.isVisible + return self.mainButtonState.isVisible || self.secondaryButtonState.isVisible } private var validLayout: ContainerViewLayout? @@ -737,7 +756,8 @@ final class AttachmentPanel: ASDisplayNode, ASScrollViewDelegate { var getCurrentSendMessageContextMediaPreview: (() -> ChatSendMessageContextScreenMediaPreview?)? - var mainButtonPressed: () -> Void = { } + var onMainButtonPressed: () -> Void = { } + var onSecondaryButtonPressed: () -> Void = { } init(controller: AttachmentController, context: AccountContext, chatLocation: ChatLocation?, isScheduledMessages: Bool, updatedPresentationData: (initial: PresentationData, signal: Signal)?, makeEntityInputView: @escaping () -> AttachmentTextInputPanelInputView?) { self.controller = controller @@ -760,6 +780,7 @@ final class AttachmentPanel: ASDisplayNode, ASScrollViewDelegate { self.separatorNode.backgroundColor = self.presentationData.theme.rootController.tabBar.separatorColor self.mainButtonNode = MainButtonNode() + self.secondaryButtonNode = MainButtonNode() super.init() @@ -768,9 +789,11 @@ final class AttachmentPanel: ASDisplayNode, ASScrollViewDelegate { self.containerNode.addSubnode(self.separatorNode) self.containerNode.addSubnode(self.scrollNode) + self.addSubnode(self.secondaryButtonNode) self.addSubnode(self.mainButtonNode) - self.mainButtonNode.addTarget(self, action: #selector(self.buttonPressed), forControlEvents: .touchUpInside) + self.mainButtonNode.addTarget(self, action: #selector(self.mainButtonPressed), forControlEvents: .touchUpInside) + self.secondaryButtonNode.addTarget(self, action: #selector(self.secondaryButtonPressed), forControlEvents: .touchUpInside) // MARK: Nicegram (cloudMessages + copyForwardMessages + copySelectedMessages) self.interfaceInteraction = ChatPanelInterfaceInteraction(cloudMessages: { _ in}, copyForwardMessages: { _ in @@ -990,10 +1013,12 @@ final class AttachmentPanel: ASDisplayNode, ASScrollViewDelegate { var captionIsAboveMedia: Signal = .single(false) var canMakePaidContent = false var currentPrice: Int64? + var hasTimers = false if let controller = strongSelf.controller, let mediaPickerContext = controller.mediaPickerContext { captionIsAboveMedia = mediaPickerContext.captionIsAboveMedia canMakePaidContent = mediaPickerContext.canMakePaidContent currentPrice = mediaPickerContext.price + hasTimers = mediaPickerContext.hasTimers } let _ = (combineLatest( @@ -1025,7 +1050,8 @@ final class AttachmentPanel: ASDisplayNode, ASScrollViewDelegate { canSendWhenOnline: sendWhenOnlineAvailable, forwardMessageIds: strongSelf.presentationInterfaceState.interfaceState.forwardMessageIds ?? [], canMakePaidContent: canMakePaidContent, - currentPrice: currentPrice + currentPrice: currentPrice, + hasTimers: hasTimers )), hasEntityKeyboard: hasEntityKeyboard, gesture: gesture, @@ -1108,7 +1134,7 @@ final class AttachmentPanel: ASDisplayNode, ASScrollViewDelegate { if let strongSelf = self { strongSelf.presentationData = presentationData - strongSelf.backgroundNode.updateColor(color: presentationData.theme.rootController.tabBar.backgroundColor, transition: .immediate) + strongSelf.backgroundNode.updateColor(color: strongSelf.customBottomPanelBackgroundColor ?? presentationData.theme.rootController.tabBar.backgroundColor, transition: .immediate) strongSelf.separatorNode.backgroundColor = presentationData.theme.rootController.tabBar.separatorColor strongSelf.updateChatPresentationInterfaceState({ $0.updatedTheme(presentationData.theme) }) @@ -1140,8 +1166,12 @@ final class AttachmentPanel: ASDisplayNode, ASScrollViewDelegate { self.view.accessibilityTraits = .tabBar } - @objc private func buttonPressed() { - self.mainButtonPressed() + @objc private func mainButtonPressed() { + self.onMainButtonPressed() + } + + @objc private func secondaryButtonPressed() { + self.onSecondaryButtonPressed() } func updateBackgroundAlpha(_ alpha: CGFloat, transition: ContainedViewLayoutTransition) { @@ -1375,11 +1405,24 @@ final class AttachmentPanel: ASDisplayNode, ASScrollViewDelegate { func updateMainButtonState(_ mainButtonState: AttachmentMainButtonState?) { var currentButtonState = self.mainButtonState if mainButtonState == nil { - currentButtonState = AttachmentMainButtonState(text: currentButtonState.text, font: currentButtonState.font, background: currentButtonState.background, textColor: currentButtonState.textColor, isVisible: false, progress: .none, isEnabled: currentButtonState.isEnabled) + currentButtonState = AttachmentMainButtonState(text: currentButtonState.text, font: currentButtonState.font, background: currentButtonState.background, textColor: currentButtonState.textColor, isVisible: false, progress: .none, isEnabled: currentButtonState.isEnabled, hasShimmer: currentButtonState.hasShimmer) } self.mainButtonState = mainButtonState ?? currentButtonState } + func updateSecondaryButtonState(_ secondaryButtonState: AttachmentMainButtonState?) { + var currentButtonState = self.secondaryButtonState + if secondaryButtonState == nil { + currentButtonState = AttachmentMainButtonState(text: currentButtonState.text, font: currentButtonState.font, background: currentButtonState.background, textColor: currentButtonState.textColor, isVisible: false, progress: .none, isEnabled: currentButtonState.isEnabled, hasShimmer: currentButtonState.hasShimmer) + } + self.secondaryButtonState = secondaryButtonState ?? currentButtonState + } + + func updateCustomBottomPanelBackgroundColor(_ color: UIColor?) { + self.customBottomPanelBackgroundColor = color + self.backgroundNode.updateColor(color: self.customBottomPanelBackgroundColor ?? presentationData.theme.rootController.tabBar.backgroundColor, transition: .animated(duration: 0.2, curve: .linear)) + } + let animatingTransitionPromise = ValuePromise(false) private(set) var animatingTransition = false { didSet { @@ -1524,11 +1567,14 @@ final class AttachmentPanel: ASDisplayNode, ASScrollViewDelegate { self.scrollNode.isUserInteractionEnabled = !isSelecting - let isButtonVisible = self.mainButtonState.isVisible - let isNarrowButton = isButtonVisible && self.mainButtonState.font == .regular + let isAnyButtonVisible = self.mainButtonState.isVisible || self.secondaryButtonState.isVisible + let isNarrowButton = isAnyButtonVisible && self.mainButtonState.font == .regular + + let isTwoVerticalButtons = self.mainButtonState.isVisible && self.secondaryButtonState.isVisible && [.top, .bottom].contains(self.secondaryButtonState.position) + let isTwoHorizontalButtons = self.mainButtonState.isVisible && self.secondaryButtonState.isVisible && [.left, .right].contains(self.secondaryButtonState.position) var insets = layout.insets(options: []) - if let inputHeight = layout.inputHeight, inputHeight > 0.0 && (isSelecting || isButtonVisible) { + if let inputHeight = layout.inputHeight, inputHeight > 0.0 && (isSelecting || isAnyButtonVisible) { insets.bottom = inputHeight } else if layout.intrinsicInsets.bottom > 0.0 { insets.bottom = layout.intrinsicInsets.bottom @@ -1563,7 +1609,11 @@ final class AttachmentPanel: ASDisplayNode, ASScrollViewDelegate { let bounds = CGRect(origin: CGPoint(), size: CGSize(width: layout.size.width, height: buttonSize.height + insets.bottom)) var containerTransition: ContainedViewLayoutTransition let containerFrame: CGRect - if isButtonVisible { + + let sideInset: CGFloat = 16.0 + let buttonHeight: CGFloat = 50.0 + + if isAnyButtonVisible { var height: CGFloat if layout.intrinsicInsets.bottom > 0.0 && (layout.inputHeight ?? 0.0).isZero { height = bounds.height @@ -1580,6 +1630,9 @@ final class AttachmentPanel: ASDisplayNode, ASScrollViewDelegate { if !isNarrowButton { height += 9.0 } + if isTwoVerticalButtons { + height += buttonHeight + sideInset + } containerFrame = CGRect(origin: CGPoint(), size: CGSize(width: bounds.width, height: height)) } else if isSelecting { containerFrame = CGRect(origin: CGPoint(), size: CGSize(width: bounds.width, height: textPanelHeight + insets.bottom)) @@ -1592,8 +1645,8 @@ final class AttachmentPanel: ASDisplayNode, ASScrollViewDelegate { } else { containerTransition = transition } - containerTransition.updateAlpha(node: self.scrollNode, alpha: isSelecting || isButtonVisible ? 0.0 : 1.0) - containerTransition.updateTransformScale(node: self.scrollNode, scale: isSelecting || isButtonVisible ? 0.85 : 1.0) + containerTransition.updateAlpha(node: self.scrollNode, alpha: isSelecting || isAnyButtonVisible ? 0.0 : 1.0) + containerTransition.updateTransformScale(node: self.scrollNode, scale: isSelecting || isAnyButtonVisible ? 0.85 : 1.0) if isSelectingUpdated { if isSelecting { @@ -1645,16 +1698,68 @@ final class AttachmentPanel: ASDisplayNode, ASScrollViewDelegate { progressNode?.removeFromSupernode() }) } - - let sideInset: CGFloat = 16.0 - let buttonSize = CGSize(width: layout.size.width - (sideInset + layout.safeInsets.left) * 2.0, height: 50.0) - let buttonTopInset: CGFloat = isNarrowButton ? 2.0 : 8.0 - if !self.dismissed { - self.mainButtonNode.updateLayout(size: buttonSize, state: self.mainButtonState, transition: transition) + var buttonSize = CGSize(width: layout.size.width - (sideInset + layout.safeInsets.left) * 2.0, height: buttonHeight) + if isTwoHorizontalButtons { + buttonSize = CGSize(width: (buttonSize.width - sideInset) / 2.0, height: buttonSize.height) } + let buttonTopInset: CGFloat = isNarrowButton ? 2.0 : 8.0 + if !self.animatingTransition { - transition.updateFrame(node: self.mainButtonNode, frame: CGRect(origin: CGPoint(x: layout.safeInsets.left + sideInset, y: isButtonVisible || self.fromMenu ? buttonTopInset : containerFrame.height), size: buttonSize)) + let buttonOriginX = layout.safeInsets.left + sideInset + let buttonOriginY = isAnyButtonVisible || self.fromMenu ? buttonTopInset : containerFrame.height + var mainButtonFrame: CGRect? + var secondaryButtonFrame: CGRect? + if self.secondaryButtonState.isVisible && self.mainButtonState.isVisible, let position = self.secondaryButtonState.position { + switch position { + case .top: + secondaryButtonFrame = CGRect(origin: CGPoint(x: buttonOriginX, y: buttonOriginY), size: buttonSize) + mainButtonFrame = CGRect(origin: CGPoint(x: buttonOriginX, y: buttonOriginY + sideInset + buttonSize.height), size: buttonSize) + case .bottom: + mainButtonFrame = CGRect(origin: CGPoint(x: buttonOriginX, y: buttonOriginY), size: buttonSize) + secondaryButtonFrame = CGRect(origin: CGPoint(x: buttonOriginX, y: buttonOriginY + sideInset + buttonSize.height), size: buttonSize) + case .left: + secondaryButtonFrame = CGRect(origin: CGPoint(x: buttonOriginX, y: buttonOriginY), size: buttonSize) + mainButtonFrame = CGRect(origin: CGPoint(x: buttonOriginX + buttonSize.width + sideInset, y: buttonOriginY), size: buttonSize) + case .right: + mainButtonFrame = CGRect(origin: CGPoint(x: buttonOriginX, y: buttonOriginY), size: buttonSize) + secondaryButtonFrame = CGRect(origin: CGPoint(x: buttonOriginX + buttonSize.width + sideInset, y: buttonOriginY), size: buttonSize) + } + } else { + if self.mainButtonState.isVisible { + mainButtonFrame = CGRect(origin: CGPoint(x: buttonOriginX, y: buttonOriginY), size: buttonSize) + } + if self.secondaryButtonState.isVisible { + secondaryButtonFrame = CGRect(origin: CGPoint(x: buttonOriginX, y: buttonOriginY), size: buttonSize) + } + } + + if let mainButtonFrame { + if !self.dismissed { + self.mainButtonNode.updateLayout(size: buttonSize, state: self.mainButtonState, animateBackground: self.mainButtonState.background.colorValue == self.backgroundNode.color && transition.isAnimated, transition: transition) + } + if self.mainButtonNode.frame.width.isZero { + self.mainButtonNode.frame = mainButtonFrame + } else { + transition.updateFrame(node: self.mainButtonNode, frame: mainButtonFrame) + } + transition.updateAlpha(node: self.mainButtonNode, alpha: 1.0) + } else { + transition.updateAlpha(node: self.mainButtonNode, alpha: 0.0) + } + if let secondaryButtonFrame { + if !self.dismissed { + self.secondaryButtonNode.updateLayout(size: buttonSize, state: self.secondaryButtonState, animateBackground: self.secondaryButtonState.background.colorValue == self.backgroundNode.color && transition.isAnimated, transition: transition) + } + if self.secondaryButtonNode.frame.width.isZero { + self.secondaryButtonNode.frame = secondaryButtonFrame + } else { + transition.updateFrame(node: self.secondaryButtonNode, frame: secondaryButtonFrame) + } + transition.updateAlpha(node: self.secondaryButtonNode, alpha: 1.0) + } else { + transition.updateAlpha(node: self.secondaryButtonNode, alpha: 0.0) + } } return containerFrame.height diff --git a/submodules/AvatarNode/Sources/AvatarNode.swift b/submodules/AvatarNode/Sources/AvatarNode.swift index ecf20247bef..7ece12618a9 100644 --- a/submodules/AvatarNode/Sources/AvatarNode.swift +++ b/submodules/AvatarNode/Sources/AvatarNode.swift @@ -25,6 +25,7 @@ public let repostStoryIcon = generateTintedImage(image: UIImage(bundleImageName: private let archivedChatsIcon = UIImage(bundleImageName: "Avatar/ArchiveAvatarIcon")?.precomposed() private let repliesIcon = generateTintedImage(image: UIImage(bundleImageName: "Avatar/RepliesMessagesIcon"), color: .white) private let anonymousSavedMessagesIcon = generateTintedImage(image: UIImage(bundleImageName: "Avatar/AnonymousSenderIcon"), color: .white) +private let anonymousSavedMessagesDarkIcon = generateTintedImage(image: UIImage(bundleImageName: "Avatar/AnonymousSenderIcon"), color: UIColor(white: 1.0, alpha: 0.4)) private let myNotesIcon = generateTintedImage(image: UIImage(bundleImageName: "Avatar/MyNotesIcon"), color: .white) public func avatarPlaceholderFont(size: CGFloat) -> UIFont { @@ -101,7 +102,11 @@ private func calculateColors(context: AccountContext?, explicitColorIndex: Int?, if isColored { colors = AvatarNode.savedMessagesColors } else { - colors = AvatarNode.grayscaleColors + if let theme, theme.overallDarkAppearance { + colors = AvatarNode.grayscaleDarkColors + } else { + colors = AvatarNode.grayscaleColors + } } } else if case .myNotesIcon = icon { colors = AvatarNode.savedMessagesColors @@ -272,6 +277,10 @@ public final class AvatarNode: ASDisplayNode { UIColor(rgb: 0xb1b1b1), UIColor(rgb: 0xcdcdcd) ] + static let grayscaleDarkColors: [UIColor] = [ + UIColor(white: 1.0, alpha: 0.22), UIColor(white: 1.0, alpha: 0.18) + ] + static let savedMessagesColors: [UIColor] = [ UIColor(rgb: 0x2a9ef1), UIColor(rgb: 0x72d5fd) ] @@ -950,8 +959,14 @@ public final class AvatarNode: ASDisplayNode { context.scaleBy(x: factor, y: -factor) context.translateBy(x: -bounds.size.width / 2.0, y: -bounds.size.height / 2.0) - if let anonymousSavedMessagesIcon = anonymousSavedMessagesIcon { - context.draw(anonymousSavedMessagesIcon.cgImage!, in: CGRect(origin: CGPoint(x: floor((bounds.size.width - anonymousSavedMessagesIcon.size.width) / 2.0), y: floor((bounds.size.height - anonymousSavedMessagesIcon.size.height) / 2.0)), size: anonymousSavedMessagesIcon.size)) + if let theme = parameters.theme, theme.overallDarkAppearance { + if let anonymousSavedMessagesDarkIcon = anonymousSavedMessagesDarkIcon { + context.draw(anonymousSavedMessagesDarkIcon.cgImage!, in: CGRect(origin: CGPoint(x: floor((bounds.size.width - anonymousSavedMessagesDarkIcon.size.width) / 2.0), y: floor((bounds.size.height - anonymousSavedMessagesDarkIcon.size.height) / 2.0)), size: anonymousSavedMessagesDarkIcon.size)) + } + } else { + if let anonymousSavedMessagesIcon = anonymousSavedMessagesIcon { + context.draw(anonymousSavedMessagesIcon.cgImage!, in: CGRect(origin: CGPoint(x: floor((bounds.size.width - anonymousSavedMessagesIcon.size.width) / 2.0), y: floor((bounds.size.height - anonymousSavedMessagesIcon.size.height) / 2.0)), size: anonymousSavedMessagesIcon.size)) + } } } else if case .myNotesIcon = parameters.icon { let factor = bounds.size.width / 60.0 diff --git a/submodules/AvatarNode/Sources/PeerAvatar.swift b/submodules/AvatarNode/Sources/PeerAvatar.swift index b73efb4d1fb..e65786a3e22 100644 --- a/submodules/AvatarNode/Sources/PeerAvatar.swift +++ b/submodules/AvatarNode/Sources/PeerAvatar.swift @@ -421,7 +421,7 @@ public enum AvatarBackgroundColor { case violet } -public func generateAvatarImage(size: CGSize, icon: UIImage?, iconScale: CGFloat = 1.0, cornerRadius: CGFloat? = nil, circleCorners: Bool = false, color: AvatarBackgroundColor, customColors: [UIColor]? = nil) -> UIImage? { +public func generateAvatarImage(size: CGSize, icon: UIImage?, iconScale: CGFloat = 1.0, cornerRadius: CGFloat? = nil, circleCorners: Bool = false, color: AvatarBackgroundColor, customColors: [UIColor]? = nil, diagonal: Bool = false) -> UIImage? { return generateImage(size, rotatedContext: { size, context in context.clear(CGRect(origin: CGPoint(), size: size)) context.beginPath() @@ -464,14 +464,21 @@ public func generateAvatarImage(size: CGSize, icon: UIImage?, iconScale: CGFloat colorsArray = AvatarNode.gradientColors[colorIndex % AvatarNode.gradientColors.count].map(\.cgColor) as NSArray } } - - var locations: [CGFloat] = [1.0, 0.0] + + var locations: [CGFloat] = [] + let delta = 1.0 / CGFloat(colorsArray.count - 1) + for i in 0 ..< colorsArray.count { + locations.append(1.0 - delta * CGFloat(i)) + } let colorSpace = CGColorSpaceCreateDeviceRGB() let gradient = CGGradient(colorsSpace: colorSpace, colors: colorsArray, locations: &locations)! - context.drawLinearGradient(gradient, start: CGPoint(), end: CGPoint(x: 0.0, y: size.height), options: CGGradientDrawingOptions()) - + if diagonal { + context.drawLinearGradient(gradient, start: CGPoint(x: size.width, y: 0.0), end: CGPoint(x: 0.0, y: size.height), options: CGGradientDrawingOptions()) + } else { + context.drawLinearGradient(gradient, start: CGPoint(), end: CGPoint(x: 0.0, y: size.height), options: CGGradientDrawingOptions()) + } context.resetClip() context.setBlendMode(.normal) diff --git a/submodules/BrowserUI/Sources/BrowserContent.swift b/submodules/BrowserUI/Sources/BrowserContent.swift index 7422bed87eb..2aef1273e7d 100644 --- a/submodules/BrowserUI/Sources/BrowserContent.swift +++ b/submodules/BrowserUI/Sources/BrowserContent.swift @@ -41,6 +41,8 @@ final class BrowserContentState: Equatable { let contentType: ContentType let favicon: UIImage? let isSecure: Bool + let hasInstantView: Bool + let isInnerInstantViewEnabled: Bool let canGoBack: Bool let canGoForward: Bool @@ -56,6 +58,8 @@ final class BrowserContentState: Equatable { contentType: ContentType, favicon: UIImage? = nil, isSecure: Bool = false, + hasInstantView: Bool = false, + isInnerInstantViewEnabled: Bool = false, canGoBack: Bool = false, canGoForward: Bool = false, backList: [HistoryItem] = [], @@ -68,6 +72,8 @@ final class BrowserContentState: Equatable { self.contentType = contentType self.favicon = favicon self.isSecure = isSecure + self.hasInstantView = hasInstantView + self.isInnerInstantViewEnabled = isInnerInstantViewEnabled self.canGoBack = canGoBack self.canGoForward = canGoForward self.backList = backList @@ -96,6 +102,9 @@ final class BrowserContentState: Equatable { if lhs.isSecure != rhs.isSecure { return false } + if lhs.hasInstantView != rhs.hasInstantView { + return false + } if lhs.canGoBack != rhs.canGoBack { return false } @@ -112,43 +121,51 @@ final class BrowserContentState: Equatable { } func withUpdatedTitle(_ title: String) -> BrowserContentState { - return BrowserContentState(title: title, url: self.url, estimatedProgress: self.estimatedProgress, readingProgress: self.readingProgress, contentType: self.contentType, favicon: self.favicon, isSecure: self.isSecure, canGoBack: self.canGoBack, canGoForward: self.canGoForward, backList: self.backList, forwardList: self.forwardList) + return BrowserContentState(title: title, url: self.url, estimatedProgress: self.estimatedProgress, readingProgress: self.readingProgress, contentType: self.contentType, favicon: self.favicon, isSecure: self.isSecure, hasInstantView: self.hasInstantView, isInnerInstantViewEnabled: self.isInnerInstantViewEnabled, canGoBack: self.canGoBack, canGoForward: self.canGoForward, backList: self.backList, forwardList: self.forwardList) } func withUpdatedUrl(_ url: String) -> BrowserContentState { - return BrowserContentState(title: self.title, url: url, estimatedProgress: self.estimatedProgress, readingProgress: self.readingProgress, contentType: self.contentType, favicon: self.favicon, isSecure: self.isSecure, canGoBack: self.canGoBack, canGoForward: self.canGoForward, backList: self.backList, forwardList: self.forwardList) + return BrowserContentState(title: self.title, url: url, estimatedProgress: self.estimatedProgress, readingProgress: self.readingProgress, contentType: self.contentType, favicon: self.favicon, isSecure: self.isSecure, hasInstantView: self.hasInstantView, isInnerInstantViewEnabled: self.isInnerInstantViewEnabled, canGoBack: self.canGoBack, canGoForward: self.canGoForward, backList: self.backList, forwardList: self.forwardList) } func withUpdatedIsSecure(_ isSecure: Bool) -> BrowserContentState { - return BrowserContentState(title: self.title, url: self.url, estimatedProgress: self.estimatedProgress, readingProgress: self.readingProgress, contentType: self.contentType, favicon: self.favicon, isSecure: isSecure, canGoBack: self.canGoBack, canGoForward: self.canGoForward, backList: self.backList, forwardList: self.forwardList) + return BrowserContentState(title: self.title, url: self.url, estimatedProgress: self.estimatedProgress, readingProgress: self.readingProgress, contentType: self.contentType, favicon: self.favicon, isSecure: isSecure, hasInstantView: self.hasInstantView, isInnerInstantViewEnabled: self.isInnerInstantViewEnabled, canGoBack: self.canGoBack, canGoForward: self.canGoForward, backList: self.backList, forwardList: self.forwardList) + } + + func withUpdatedHasInstantView(_ hasInstantView: Bool) -> BrowserContentState { + return BrowserContentState(title: self.title, url: self.url, estimatedProgress: self.estimatedProgress, readingProgress: self.readingProgress, contentType: self.contentType, favicon: self.favicon, isSecure: self.isSecure, hasInstantView: hasInstantView, isInnerInstantViewEnabled: self.isInnerInstantViewEnabled, canGoBack: self.canGoBack, canGoForward: self.canGoForward, backList: self.backList, forwardList: self.forwardList) + } + + func withUpdatedIsInnerInstantViewEnabled(_ isInnerInstantViewEnabled: Bool) -> BrowserContentState { + return BrowserContentState(title: self.title, url: self.url, estimatedProgress: self.estimatedProgress, readingProgress: self.readingProgress, contentType: self.contentType, favicon: self.favicon, isSecure: self.isSecure, hasInstantView: self.hasInstantView, isInnerInstantViewEnabled: isInnerInstantViewEnabled, canGoBack: self.canGoBack, canGoForward: self.canGoForward, backList: self.backList, forwardList: self.forwardList) } func withUpdatedEstimatedProgress(_ estimatedProgress: Double) -> BrowserContentState { - return BrowserContentState(title: self.title, url: self.url, estimatedProgress: estimatedProgress, readingProgress: self.readingProgress, contentType: self.contentType, favicon: self.favicon, isSecure: self.isSecure, canGoBack: self.canGoBack, canGoForward: self.canGoForward, backList: self.backList, forwardList: self.forwardList) + return BrowserContentState(title: self.title, url: self.url, estimatedProgress: estimatedProgress, readingProgress: self.readingProgress, contentType: self.contentType, favicon: self.favicon, isSecure: self.isSecure, hasInstantView: self.hasInstantView, isInnerInstantViewEnabled: self.isInnerInstantViewEnabled, canGoBack: self.canGoBack, canGoForward: self.canGoForward, backList: self.backList, forwardList: self.forwardList) } func withUpdatedReadingProgress(_ readingProgress: Double) -> BrowserContentState { - return BrowserContentState(title: self.title, url: self.url, estimatedProgress: self.estimatedProgress, readingProgress: readingProgress, contentType: self.contentType, favicon: self.favicon, isSecure: self.isSecure, canGoBack: self.canGoBack, canGoForward: self.canGoForward, backList: self.backList, forwardList: self.forwardList) + return BrowserContentState(title: self.title, url: self.url, estimatedProgress: self.estimatedProgress, readingProgress: readingProgress, contentType: self.contentType, favicon: self.favicon, isSecure: self.isSecure, hasInstantView: self.hasInstantView, isInnerInstantViewEnabled: self.isInnerInstantViewEnabled, canGoBack: self.canGoBack, canGoForward: self.canGoForward, backList: self.backList, forwardList: self.forwardList) } func withUpdatedFavicon(_ favicon: UIImage?) -> BrowserContentState { - return BrowserContentState(title: self.title, url: self.url, estimatedProgress: self.estimatedProgress, readingProgress: self.readingProgress, contentType: self.contentType, favicon: favicon, isSecure: self.isSecure, canGoBack: self.canGoBack, canGoForward: self.canGoForward, backList: self.backList, forwardList: self.forwardList) + return BrowserContentState(title: self.title, url: self.url, estimatedProgress: self.estimatedProgress, readingProgress: self.readingProgress, contentType: self.contentType, favicon: favicon, isSecure: self.isSecure, hasInstantView: self.hasInstantView, isInnerInstantViewEnabled: self.isInnerInstantViewEnabled, canGoBack: self.canGoBack, canGoForward: self.canGoForward, backList: self.backList, forwardList: self.forwardList) } func withUpdatedCanGoBack(_ canGoBack: Bool) -> BrowserContentState { - return BrowserContentState(title: self.title, url: self.url, estimatedProgress: self.estimatedProgress, readingProgress: self.readingProgress, contentType: self.contentType, favicon: self.favicon, isSecure: self.isSecure, canGoBack: canGoBack, canGoForward: self.canGoForward, backList: self.backList, forwardList: self.forwardList) + return BrowserContentState(title: self.title, url: self.url, estimatedProgress: self.estimatedProgress, readingProgress: self.readingProgress, contentType: self.contentType, favicon: self.favicon, isSecure: self.isSecure, hasInstantView: self.hasInstantView, isInnerInstantViewEnabled: self.isInnerInstantViewEnabled, canGoBack: canGoBack, canGoForward: self.canGoForward, backList: self.backList, forwardList: self.forwardList) } func withUpdatedCanGoForward(_ canGoForward: Bool) -> BrowserContentState { - return BrowserContentState(title: self.title, url: self.url, estimatedProgress: self.estimatedProgress, readingProgress: self.readingProgress, contentType: self.contentType, favicon: self.favicon, isSecure: self.isSecure, canGoBack: self.canGoBack, canGoForward: canGoForward, backList: self.backList, forwardList: self.forwardList) + return BrowserContentState(title: self.title, url: self.url, estimatedProgress: self.estimatedProgress, readingProgress: self.readingProgress, contentType: self.contentType, favicon: self.favicon, isSecure: self.isSecure, hasInstantView: self.hasInstantView, isInnerInstantViewEnabled: self.isInnerInstantViewEnabled, canGoBack: self.canGoBack, canGoForward: canGoForward, backList: self.backList, forwardList: self.forwardList) } func withUpdatedBackList(_ backList: [HistoryItem]) -> BrowserContentState { - return BrowserContentState(title: self.title, url: self.url, estimatedProgress: self.estimatedProgress, readingProgress: self.readingProgress, contentType: self.contentType, favicon: self.favicon, isSecure: self.isSecure, canGoBack: self.canGoBack, canGoForward: self.canGoForward, backList: backList, forwardList: self.forwardList) + return BrowserContentState(title: self.title, url: self.url, estimatedProgress: self.estimatedProgress, readingProgress: self.readingProgress, contentType: self.contentType, favicon: self.favicon, isSecure: self.isSecure, hasInstantView: self.hasInstantView, isInnerInstantViewEnabled: self.isInnerInstantViewEnabled, canGoBack: self.canGoBack, canGoForward: self.canGoForward, backList: backList, forwardList: self.forwardList) } func withUpdatedForwardList(_ forwardList: [HistoryItem]) -> BrowserContentState { - return BrowserContentState(title: self.title, url: self.url, estimatedProgress: self.estimatedProgress, readingProgress: self.readingProgress, contentType: self.contentType, favicon: self.favicon, isSecure: self.isSecure, canGoBack: self.canGoBack, canGoForward: self.canGoForward, backList: self.backList, forwardList: forwardList) + return BrowserContentState(title: self.title, url: self.url, estimatedProgress: self.estimatedProgress, readingProgress: self.readingProgress, contentType: self.contentType, favicon: self.favicon, isSecure: self.isSecure, hasInstantView: self.hasInstantView, isInnerInstantViewEnabled: self.isInnerInstantViewEnabled, canGoBack: self.canGoBack, canGoForward: self.canGoForward, backList: self.backList, forwardList: forwardList) } } @@ -158,7 +175,7 @@ protocol BrowserContent: UIView { var currentState: BrowserContentState { get } var state: Signal { get } - var pushContent: (BrowserScreen.Subject) -> Void { get set } + var pushContent: (BrowserScreen.Subject, BrowserContent?) -> Void { get set } var present: (ViewController, Any?) -> Void { get set } var presentInGlobalOverlay: (ViewController) -> Void { get set } var getNavigationController: () -> NavigationController? { get set } @@ -177,6 +194,8 @@ protocol BrowserContent: UIView { func navigateForward() func navigateTo(historyItem: BrowserContentState.HistoryItem) + func toggleInstantView(_ enabled: Bool) + func updatePresentationData(_ presentationData: PresentationData) func updateFontState(_ state: BrowserPresentationState.FontState) diff --git a/submodules/BrowserUI/Sources/BrowserDocumentContent.swift b/submodules/BrowserUI/Sources/BrowserDocumentContent.swift index c17a2911ed8..1f72255144a 100644 --- a/submodules/BrowserUI/Sources/BrowserDocumentContent.swift +++ b/submodules/BrowserUI/Sources/BrowserDocumentContent.swift @@ -36,7 +36,7 @@ final class BrowserDocumentContent: UIView, BrowserContent, WKNavigationDelegate return self.statePromise.get() } - var pushContent: (BrowserScreen.Subject) -> Void = { _ in } + var pushContent: (BrowserScreen.Subject, BrowserContent?) -> Void = { _, _ in } var openAppUrl: (String) -> Void = { _ in } var onScrollingUpdate: (ContentScrollingUpdate) -> Void = { _ in } var minimize: () -> Void = { } @@ -92,6 +92,17 @@ final class BrowserDocumentContent: UIView, BrowserContent, WKNavigationDelegate self.webView.underPageBackgroundColor = presentationData.theme.list.plainBackgroundColor } self.addSubview(self.webView) + + self.webView.interactiveTransitionGestureRecognizerTest = { [weak self] point in + if let self { + if let result = self.webView.hitTest(point, with: nil), let scrollView = findScrollView(view: result), scrollView.isDescendant(of: self.webView) { + if scrollView.contentSize.width > scrollView.frame.width, scrollView.contentOffset.x > -scrollView.contentInset.left { + return true + } + } + } + return false + } } required init?(coder: NSCoder) { @@ -122,6 +133,9 @@ final class BrowserDocumentContent: UIView, BrowserContent, WKNavigationDelegate self.webView.evaluateJavaScript(js) { _, _ in } } + func toggleInstantView(_ enabled: Bool) { + } + private var didSetupSearch = false private func setupSearch(completion: @escaping () -> Void) { guard !self.didSetupSearch else { @@ -385,7 +399,7 @@ final class BrowserDocumentContent: UIView, BrowserContent, WKNavigationDelegate navigationController._keepModalDismissProgress = true navigationController.pushViewController(controller) } else { - self.pushContent(subject) + self.pushContent(subject, nil) } } @@ -413,3 +427,14 @@ final class BrowserDocumentContent: UIView, BrowserContent, WKNavigationDelegate return imageView } } + +private func findScrollView(view: UIView?) -> UIScrollView? { + if let view = view { + if let view = view as? UIScrollView { + return view + } + return findScrollView(view: view.superview) + } else { + return nil + } +} diff --git a/submodules/BrowserUI/Sources/BrowserInstantPageContent.swift b/submodules/BrowserUI/Sources/BrowserInstantPageContent.swift index f334322e608..977dcceafe1 100644 --- a/submodules/BrowserUI/Sources/BrowserInstantPageContent.swift +++ b/submodules/BrowserUI/Sources/BrowserInstantPageContent.swift @@ -28,6 +28,9 @@ final class BrowserInstantPageContent: UIView, BrowserContent, UIScrollViewDeleg private var theme: InstantPageTheme private var settings: InstantPagePresentationSettings = .defaultSettings private let sourceLocation: InstantPageSourceLocation + private let preloadedResouces: [Any]? + private var originalContent: BrowserContent? + private let url: String private var webPage: TelegramMediaWebpage? @@ -66,7 +69,8 @@ final class BrowserInstantPageContent: UIView, BrowserContent, UIScrollViewDeleg var currentAccessibilityAreas: [AccessibilityAreaNode] = [] - var pushContent: (BrowserScreen.Subject) -> Void = { _ in } + var pushContent: (BrowserScreen.Subject, BrowserContent?) -> Void = { _, _ in } + var restoreContent: (BrowserContent) -> Void = { _ in } var openAppUrl: (String) -> Void = { _ in } var onScrollingUpdate: (ContentScrollingUpdate) -> Void = { _ in } var minimize: () -> Void = { } @@ -84,19 +88,22 @@ final class BrowserInstantPageContent: UIView, BrowserContent, UIScrollViewDeleg private let loadWebpageDisposable = MetaDisposable() private let resolveUrlDisposable = MetaDisposable() private let updateLayoutDisposable = MetaDisposable() - + private let loadProgress = ValuePromise(1.0, ignoreRepeated: true) private let readingProgress = ValuePromise(1.0, ignoreRepeated: true) private var containerLayout: (size: CGSize, insets: UIEdgeInsets, fullInsets: UIEdgeInsets)? private var setupScrollOffsetOnLayout = false - init(context: AccountContext, presentationData: PresentationData, webPage: TelegramMediaWebpage, anchor: String?, url: String, sourceLocation: InstantPageSourceLocation) { + init(context: AccountContext, presentationData: PresentationData, webPage: TelegramMediaWebpage, anchor: String?, url: String, sourceLocation: InstantPageSourceLocation, preloadedResouces: [Any]?, originalContent: BrowserContent? = nil) { self.context = context self.webPage = webPage self.presentationData = presentationData self.theme = instantPageThemeForType(presentationData.theme.overallDarkAppearance ? .dark : .light, settings: .defaultSettings) self.sourceLocation = sourceLocation + self.preloadedResouces = preloadedResouces + self.originalContent = originalContent + self.url = url self.uuid = UUID() @@ -107,7 +114,8 @@ final class BrowserInstantPageContent: UIView, BrowserContent, UIScrollViewDeleg title = "" } - self._state = BrowserContentState(title: title, url: url, estimatedProgress: 0.0, readingProgress: 0.0, contentType: .instantPage) + let isInnerInstantViewEnabled = originalContent != nil + self._state = BrowserContentState(title: title, url: url, estimatedProgress: 0.0, readingProgress: 0.0, contentType: .instantPage, isInnerInstantViewEnabled: isInnerInstantViewEnabled) self.statePromise = Promise(self._state) self.wrapperNode = ASDisplayNode() @@ -126,7 +134,7 @@ final class BrowserInstantPageContent: UIView, BrowserContent, UIScrollViewDeleg self.readingProgress.get() ) |> map { estimatedProgress, readingProgress in - return BrowserContentState(title: title, url: url, estimatedProgress: estimatedProgress, readingProgress: readingProgress, contentType: .instantPage) + return BrowserContentState(title: title, url: url, estimatedProgress: estimatedProgress, readingProgress: readingProgress, contentType: .instantPage, isInnerInstantViewEnabled: isInnerInstantViewEnabled) } )) @@ -160,7 +168,6 @@ final class BrowserInstantPageContent: UIView, BrowserContent, UIScrollViewDeleg guard let self else { return } - self.webPage = result self.updateWebPage(result, anchor: self.initialAnchor) }) } @@ -360,6 +367,12 @@ final class BrowserInstantPageContent: UIView, BrowserContent, UIScrollViewDeleg self.updatePageLayout() self.updateVisibleItems(visibleBounds: self.scrollNode.view.bounds) } + + func toggleInstantView(_ enabled: Bool) { + if !enabled, let originalContent = self.originalContent { + self.restoreContent(originalContent) + } + } func setSearch(_ query: String?, completion: ((Int) -> Void)?) { @@ -604,7 +617,9 @@ final class BrowserInstantPageContent: UIView, BrowserContent, UIScrollViewDeleg self?.updateWebEmbedHeight(embedIndex, height) }, updateDetailsExpanded: { [weak self] expanded in self?.updateDetailsExpanded(detailsIndex, expanded) - }, currentExpandedDetails: self.currentExpandedDetails) { + }, currentExpandedDetails: self.currentExpandedDetails, getPreloadedResource: { [weak self] url in + return self?.getPreloadedResource(url) + }) { newNode.frame = itemFrame newNode.updateLayout(size: itemFrame.size, transition: transition) if let topNode = topNode { @@ -716,6 +731,24 @@ final class BrowserInstantPageContent: UIView, BrowserContent, UIScrollViewDeleg } } + private func getPreloadedResource(_ url: String) -> Data? { + guard let preloadedResouces = self.preloadedResouces else { + return nil + } + var cleanUrl = url + var components = URLComponents(string: url) + components?.queryItems = nil + cleanUrl = components?.url?.absoluteString ?? cleanUrl + for resource in preloadedResouces { + if let resource = resource as? [String: Any], let resourceUrl = resource["WebResourceURL"] as? String { + if resourceUrl == url || resourceUrl.hasPrefix(cleanUrl) { + return resource["WebResourceData"] as? Data + } + } + } + return nil + } + private struct ScrollingOffsetState: Equatable { var value: CGFloat var isDraggingOrDecelerating: Bool @@ -878,8 +911,14 @@ final class BrowserInstantPageContent: UIView, BrowserContent, UIScrollViewDeleg anchor = String(baseUrl[anchorRange.upperBound...]).removingPercentEncoding baseUrl = String(baseUrl[.. deliverOnMainQueue).start(next: { [weak self] result in if let strongSelf = self { strongSelf.loadProgress.set(0.07) @@ -906,7 +945,7 @@ final class BrowserInstantPageContent: UIView, BrowserContent, UIScrollViewDeleg case let .result(webpageResult): if let webpageResult = webpageResult, case .Loaded = webpageResult.webpage.content { strongSelf.loadProgress.set(1.0) - strongSelf.pushContent(.instantPage(webPage: webpageResult.webpage, anchor: anchor, sourceLocation: strongSelf.sourceLocation)) + strongSelf.pushContent(.instantPage(webPage: webpageResult.webpage, anchor: anchor, sourceLocation: strongSelf.sourceLocation, preloadedResources: nil), nil) } break case let .progress(progress): @@ -916,11 +955,11 @@ final class BrowserInstantPageContent: UIView, BrowserContent, UIScrollViewDeleg })) } else { strongSelf.loadProgress.set(1.0) - strongSelf.pushContent(.webPage(url: externalUrl)) + strongSelf.pushContent(.webPage(url: externalUrl), nil) } case let .instantView(webpage, anchor): strongSelf.loadProgress.set(1.0) - strongSelf.pushContent(.instantPage(webPage: webpage, anchor: anchor, sourceLocation: strongSelf.sourceLocation)) + strongSelf.pushContent(.instantPage(webPage: webpage, anchor: anchor, sourceLocation: strongSelf.sourceLocation, preloadedResources: nil), nil) default: strongSelf.loadProgress.set(1.0) strongSelf.minimize() @@ -971,8 +1010,15 @@ final class BrowserInstantPageContent: UIView, BrowserContent, UIScrollViewDeleg } private func openUrlIn(_ url: InstantPageUrlItem) { + var baseUrl = url.url + if !baseUrl.hasPrefix("http://") && !baseUrl.hasPrefix("https://") { + if let updatedUrl = URL(string: baseUrl, relativeTo: URL(string: "/", relativeTo: URL(string: self.url))) { + baseUrl = updatedUrl.absoluteString + } + } + let presentationData = self.context.sharedContext.currentPresentationData.with { $0 } - let actionSheet = OpenInActionSheetController(context: self.context, item: .url(url: url.url), openUrl: { [weak self] url in + let actionSheet = OpenInActionSheetController(context: self.context, item: .url(url: baseUrl), openUrl: { [weak self] url in if let self { self.context.sharedContext.openExternalUrl(context: self.context, urlContext: .generic, url: url, forceExternal: true, presentationData: presentationData, navigationController: nil, dismissInput: {}) } @@ -1067,7 +1113,9 @@ final class BrowserInstantPageContent: UIView, BrowserContent, UIScrollViewDeleg if let centralIndex = centralIndex { let controller = InstantPageGalleryController(context: self.context, userLocation: self.sourceLocation.userLocation, webPage: webPage, entries: entries, centralIndex: centralIndex, fromPlayingVideo: fromPlayingVideo, replaceRootController: { _, _ in - }, baseNavigationController: self.getNavigationController()) + }, baseNavigationController: self.getNavigationController(), getPreloadedResource: { [weak self] url in + return self?.getPreloadedResource(url) + }) self.hiddenMediaDisposable.set((controller.hiddenMedia |> deliverOnMainQueue).start(next: { [weak self] entry in if let strongSelf = self { for (_, itemNode) in strongSelf.visibleItemsWithNodes { @@ -1156,11 +1204,18 @@ final class BrowserInstantPageContent: UIView, BrowserContent, UIScrollViewDeleg } case .longTap: if let url = self.urlForTapLocation(location) { - let canOpenIn = availableOpenInOptions(context: self.context, item: .url(url: url.url)).count > 1 + var baseUrl = url.url + if !baseUrl.hasPrefix("http://") && !baseUrl.hasPrefix("https://") { + if let updatedUrl = URL(string: baseUrl, relativeTo: URL(string: "/", relativeTo: URL(string: self.url))) { + baseUrl = updatedUrl.absoluteString + } + } + + let canOpenIn = availableOpenInOptions(context: self.context, item: .url(url: baseUrl)).count > 1 let openText = canOpenIn ? self.presentationData.strings.Conversation_FileOpenIn : self.presentationData.strings.Conversation_LinkDialogOpen let actionSheet = ActionSheetController(instantPageTheme: self.theme) actionSheet.setItemGroups([ActionSheetItemGroup(items: [ - ActionSheetTextItem(title: url.url), + ActionSheetTextItem(title: baseUrl), ActionSheetButtonItem(title: openText, color: .accent, action: { [weak self, weak actionSheet] in actionSheet?.dismissAnimated() if let strongSelf = self { @@ -1173,11 +1228,11 @@ final class BrowserInstantPageContent: UIView, BrowserContent, UIScrollViewDeleg }), ActionSheetButtonItem(title: self.presentationData.strings.ShareMenu_CopyShareLink, color: .accent, action: { [weak actionSheet] in actionSheet?.dismissAnimated() - UIPasteboard.general.string = url.url + UIPasteboard.general.string = baseUrl }), ActionSheetButtonItem(title: self.presentationData.strings.Conversation_AddToReadingList, color: .accent, action: { [weak actionSheet] in actionSheet?.dismissAnimated() - if let link = URL(string: url.url) { + if let link = URL(string: baseUrl) { let _ = try? SSReadingList.default()?.addItem(with: link, title: nil, previewText: nil) } }) diff --git a/submodules/BrowserUI/Sources/BrowserPdfContent.swift b/submodules/BrowserUI/Sources/BrowserPdfContent.swift index 4b67b6849a3..2b1aec9f0fa 100644 --- a/submodules/BrowserUI/Sources/BrowserPdfContent.swift +++ b/submodules/BrowserUI/Sources/BrowserPdfContent.swift @@ -24,7 +24,12 @@ final class BrowserPdfContent: UIView, BrowserContent, UIScrollViewDelegate, PDF private let pdfView: PDFView private let scrollView: UIScrollView! - + + private let pageIndicatorBackgorund: UIVisualEffectView + private let pageIndicator = ComponentView() + private var pageNumber: (Int, Int)? + private var pageTimer: SwiftSignalKit.Timer? + let uuid: UUID private var _state: BrowserContentState @@ -37,7 +42,7 @@ final class BrowserPdfContent: UIView, BrowserContent, UIScrollViewDelegate, PDF return self.statePromise.get() } - var pushContent: (BrowserScreen.Subject) -> Void = { _ in } + var pushContent: (BrowserScreen.Subject, BrowserContent?) -> Void = { _, _ in } var openAppUrl: (String) -> Void = { _ in } var onScrollingUpdate: (ContentScrollingUpdate) -> Void = { _ in } var minimize: () -> Void = { } @@ -55,7 +60,12 @@ final class BrowserPdfContent: UIView, BrowserContent, UIScrollViewDelegate, PDF self.file = file self.pdfView = PDFView() - + self.pdfView.clipsToBounds = false + + self.pageIndicatorBackgorund = UIVisualEffectView(effect: UIBlurEffect(style: .light)) + self.pageIndicatorBackgorund.clipsToBounds = true + self.pageIndicatorBackgorund.layer.cornerRadius = 10.0 + var scrollView: UIScrollView? for view in self.pdfView.subviews { if let view = view as? UIScrollView { @@ -69,6 +79,7 @@ final class BrowserPdfContent: UIView, BrowserContent, UIScrollViewDelegate, PDF } } self.scrollView = scrollView + scrollView?.clipsToBounds = false self.pdfView.displayDirection = .vertical self.pdfView.autoScales = true @@ -102,12 +113,45 @@ final class BrowserPdfContent: UIView, BrowserContent, UIScrollViewDelegate, PDF scrollView.delegate = self } } + + self.pageNumber = (1, self.pdfView.document?.pageCount ?? 1) + + self.startPageIndicatorTimer() + + self.pdfView.interactiveTransitionGestureRecognizerTest = { [weak self] point in + if let self { + if let result = self.pdfView.hitTest(point, with: nil), let scrollView = findScrollView(view: result), scrollView.isDescendant(of: self.pdfView) { + if scrollView.contentSize.width > scrollView.frame.width, scrollView.contentOffset.x > -scrollView.contentInset.left { + return true + } + } + } + return false + } + + NotificationCenter.default.addObserver(self, selector: #selector(self.pageChangeHandler(_:)), name: .PDFViewPageChanged, object: nil) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + deinit { + NotificationCenter.default.removeObserver(self, name: .PDFViewPageChanged, object: nil) + } + + @objc func pageChangeHandler(_ notification: Notification) { + if let document = self.pdfView.document, let page = self.pdfView.currentPage { + let number = document.index(for: page) + 1 + if number != self.pageNumber?.0 { + self.pageNumber = (number, document.pageCount) + if let (size, insets, fullInsets) = self.validLayout { + self.updateLayout(size: size, insets: insets, fullInsets: fullInsets, safeInsets: .zero, transition: .immediate) + } + } + } + } + func updatePresentationData(_ presentationData: PresentationData) { self.presentationData = presentationData if #available(iOS 15.0, *) { @@ -118,12 +162,27 @@ final class BrowserPdfContent: UIView, BrowserContent, UIScrollViewDelegate, PDF } } + func startPageIndicatorTimer() { + self.pageTimer?.invalidate() + + self.pageTimer = SwiftSignalKit.Timer(timeout: 2.0, repeat: false, completion: { [weak self] in + guard let self else { + return + } + let transition = ComponentTransition.easeInOut(duration: 0.25) + transition.setAlpha(view: self.pageIndicatorBackgorund, alpha: 0.0) + }, queue: Queue.mainQueue()) + self.pageTimer?.start() + } func updateFontState(_ state: BrowserPresentationState.FontState) { } func updateFontState(_ state: BrowserPresentationState.FontState, force: Bool) { } + + func toggleInstantView(_ enabled: Bool) { + } private var findSession: Any? private var previousQuery: String? @@ -296,9 +355,39 @@ final class BrowserPdfContent: UIView, BrowserContent, UIScrollViewDelegate, PDF self.previousScrollingOffset = ScrollingOffsetState(value: self.scrollView.contentOffset.y, isDraggingOrDecelerating: self.scrollView.isDragging || self.scrollView.isDecelerating) - let pdfViewFrame = CGRect(origin: CGPoint(x: insets.left, y: insets.top), size: CGSize(width: size.width - insets.left - insets.right, height: size.height - insets.top - insets.bottom)) + let currentBounds = self.scrollView.bounds + let offsetToBottomEdge = max(0.0, self.scrollView.contentSize.height - currentBounds.maxY) + var bottomInset = insets.bottom + if offsetToBottomEdge < 128.0 { + bottomInset = fullInsets.bottom + } + + let pdfViewFrame = CGRect(origin: CGPoint(x: insets.left, y: insets.top), size: CGSize(width: size.width - insets.left - insets.right, height: size.height - insets.top - bottomInset)) transition.setFrame(view: self.pdfView, frame: pdfViewFrame) + let pageIndicatorSize = self.pageIndicator.update( + transition: .immediate, + component: AnyComponent( + Text(text: "\(self.pageNumber?.0 ?? 1) of \(self.pageNumber?.1 ?? 1)", font: Font.with(size: 15.0, weight: .semibold, traits: .monospacedNumbers), color: self.presentationData.theme.list.itemSecondaryTextColor) + ), + environment: {}, + containerSize: size + ) + if let view = self.pageIndicator.view { + if view.superview == nil { + self.addSubview(self.pageIndicatorBackgorund) + self.pageIndicatorBackgorund.contentView.addSubview(view) + } + + let horizontalPadding: CGFloat = 10.0 + let verticalPadding: CGFloat = 8.0 + let pageBackgroundFrame = CGRect(origin: CGPoint(x: insets.left + 20.0, y: insets.top + 16.0), size: CGSize(width: horizontalPadding * 2.0 + pageIndicatorSize.width, height: verticalPadding * 2.0 + pageIndicatorSize.height)) + + self.pageIndicatorBackgorund.bounds = CGRect(origin: .zero, size: pageBackgroundFrame.size) + transition.setPosition(view: self.pageIndicatorBackgorund, position: pageBackgroundFrame.center) + view.frame = CGRect(origin: CGPoint(x: horizontalPadding, y: verticalPadding), size: pageIndicatorSize) + } + if isFirstTime { self.pdfView.setNeedsLayout() self.pdfView.layoutIfNeeded() @@ -364,12 +453,30 @@ final class BrowserPdfContent: UIView, BrowserContent, UIScrollViewDelegate, PDF } } + func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + if let scrollViewDelegate = scrollView as? UIScrollViewDelegate { + scrollViewDelegate.scrollViewWillBeginDragging?(scrollView) + } + + let transition = ComponentTransition.easeInOut(duration: 0.1) + transition.setAlpha(view: self.pageIndicatorBackgorund, alpha: 1.0) + + self.pageTimer?.invalidate() + self.pageTimer = nil + } + public func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { if let scrollViewDelegate = scrollView as? UIScrollViewDelegate { scrollViewDelegate.scrollViewDidEndDragging?(scrollView, willDecelerate: decelerate) } if !decelerate { self.snapScrollingOffsetToInsets() + + if self.ignoreUpdatesUntilScrollingStopped { + self.ignoreUpdatesUntilScrollingStopped = false + } + + self.startPageIndicatorTimer() } } @@ -378,9 +485,18 @@ final class BrowserPdfContent: UIView, BrowserContent, UIScrollViewDelegate, PDF scrollViewDelegate.scrollViewDidEndDecelerating?(scrollView) } self.snapScrollingOffsetToInsets() + + if self.ignoreUpdatesUntilScrollingStopped { + self.ignoreUpdatesUntilScrollingStopped = false + } + + self.startPageIndicatorTimer() } private func updateScrollingOffset(isReset: Bool, transition: ComponentTransition) { + guard !self.ignoreUpdatesUntilScrollingStopped else { + return + } guard let scrollView = self.scrollView else { return } @@ -412,8 +528,12 @@ final class BrowserPdfContent: UIView, BrowserContent, UIScrollViewDelegate, PDF } } + private var ignoreUpdatesUntilScrollingStopped = false func resetScrolling() { self.updateScrollingOffset(isReset: true, transition: .spring(duration: 0.4)) + if self.scrollView.isDecelerating { + self.ignoreUpdatesUntilScrollingStopped = true + } } private func open(url: String, new: Bool) { @@ -425,7 +545,7 @@ final class BrowserPdfContent: UIView, BrowserContent, UIScrollViewDelegate, PDF navigationController._keepModalDismissProgress = true navigationController.pushViewController(controller) } else { - self.pushContent(subject) + self.pushContent(subject, nil) } } @@ -445,3 +565,14 @@ final class BrowserPdfContent: UIView, BrowserContent, UIScrollViewDelegate, PDF return nil } } + +private func findScrollView(view: UIView?) -> UIScrollView? { + if let view = view { + if let view = view as? UIScrollView { + return view + } + return findScrollView(view: view.superview) + } else { + return nil + } +} diff --git a/submodules/BrowserUI/Sources/BrowserReadability.swift b/submodules/BrowserUI/Sources/BrowserReadability.swift new file mode 100644 index 00000000000..25b4dda6aef --- /dev/null +++ b/submodules/BrowserUI/Sources/BrowserReadability.swift @@ -0,0 +1,787 @@ +import Foundation +import WebKit +import AppBundle +import Postbox +import TelegramCore +import InstantPageUI + +public class Readability: NSObject, WKNavigationDelegate { + private let url: URL + let webView: WKWebView + private let completionHandler: ((_ webPage: (TelegramMediaWebpage, [Any]?)?, _ error: Error?) -> Void) + private var hasRenderedReadabilityHTML = false + + private var subresources: [Any]? + + init(url: URL, archiveData: Data, completionHandler: @escaping (_ webPage: (TelegramMediaWebpage, [Any]?)?, _ error: Error?) -> Void) { + self.url = url + self.completionHandler = completionHandler + + let preferences = WKPreferences() + + let configuration = WKWebViewConfiguration() + configuration.preferences = preferences + configuration.userContentController.addUserScript(ReadabilityUserScript()) + + self.webView = WKWebView(frame: CGRect.zero, configuration: configuration) + + super.init() + + self.webView.configuration.suppressesIncrementalRendering = true + self.webView.navigationDelegate = self + if #available(iOS 16.4, *) { + self.webView.isInspectable = true + } + + if let (html, subresources) = extractHtmlString(from: archiveData) { + self.subresources = subresources + self.webView.loadHTMLString(html, baseURL: url.baseURL) + } + } + + private func initializeReadability(completion: @escaping (_ result: TelegramMediaWebpage?, _ error: Error?) -> Void) { + guard let readabilityInitializationJS = loadFile(name: "ReaderMode", type: "js") else { + return + } + + self.webView.evaluateJavaScript(readabilityInitializationJS) { (result, error) in + guard let result = result as? [String: Any] else { + completion(nil, error) + return + } + guard let page = parseJson(result, url: self.url.absoluteString) else { + return + } + completion(page, nil) + } + } + + public func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + if !self.hasRenderedReadabilityHTML { + self.initializeReadability() { [weak self] (webPage: TelegramMediaWebpage?, error: Error?) in + guard let self else { + return + } + self.hasRenderedReadabilityHTML = true + guard let webPage else { + self.completionHandler(nil, error) + return + } + self.completionHandler((webPage, self.subresources), error) + } + } + } +} + +class ReadabilityUserScript: WKUserScript { + convenience override init() { + guard let js = loadFile(name: "Readability", type: "js") else { + fatalError() + } + self.init(source: js, injectionTime: .atDocumentEnd, forMainFrameOnly: true) + } +} + +func loadFile(name: String, type: String) -> String? { + let bundle = getAppBundle() + guard let userScriptPath = bundle.path(forResource: name, ofType: type) else { + return nil + } + guard let userScriptData = try? Data(contentsOf: URL(fileURLWithPath: userScriptPath)) else { + return nil + } + guard let userScript = String(data: userScriptData, encoding: .utf8) else { + return nil + } + return userScript +} + +private func extractHtmlString(from webArchiveData: Data) -> (String, [Any]?)? { + if let webArchiveDict = try? PropertyListSerialization.propertyList(from: webArchiveData, format: nil) as? [String: Any], + let mainResource = webArchiveDict["WebMainResource"] as? [String: Any], + let htmlData = mainResource["WebResourceData"] as? Data { + + guard let htmlString = String(data: htmlData, encoding: .utf8) else { + return nil + } + return (htmlString, webArchiveDict["WebSubresources"] as? [Any]) + } + return nil +} + +private func parseJson(_ input: [String: Any], url: String) -> TelegramMediaWebpage? { + let siteName = input["siteName"] as? String + let title = input["title"] as? String + let byline = input["byline"] as? String + let excerpt = input["excerpt"] as? String + + var media: [MediaId: Media] = [:] + let blocks = parseContent(input, url, &media) + + guard !blocks.isEmpty else { + return nil + } + return TelegramMediaWebpage( + webpageId: MediaId(namespace: 0, id: 0), + content: .Loaded( + TelegramMediaWebpageLoadedContent( + url: url, + displayUrl: url, + hash: 0, + type: "article", + websiteName: siteName, + title: title, + text: excerpt, + embedUrl: nil, + embedType: nil, + embedSize: nil, + duration: nil, + author: byline, + isMediaLargeByDefault: nil, + image: nil, + file: nil, + story: nil, + attributes: [], + instantPage: InstantPage( + blocks: blocks, + media: media, + isComplete: true, + rtl: false, + url: url, + views: nil + ) + ) + ) + ) +} + +private func parseContent(_ input: [String: Any], _ url: String, _ media: inout [MediaId: Media]) -> [InstantPageBlock] { + let title = input["title"] as? String + let byline = input["byline"] as? String + let date = input["publishedTime"] as? String + + let _ = date + + guard let content = input["content"] as? [Any] else { + return [] + } + var blocks = parsePageBlocks(content, url, &media) + if case .header = blocks.first { + } else { + if var byline { + byline = byline.replacingOccurrences(of: "[\n\t]+", with: " ", options: .regularExpression, range: nil) + blocks.insert(.authorDate(author: trim(parseRichText(byline)), date: 0), at: 0) + } + if let title { + blocks.insert(.title(trim(parseRichText(title))), at: 0) + } + } + + return blocks +} + +private func parseRichText(_ input: String) -> RichText { + return .plain(input) +} + +private func parseRichText(_ input: [String: Any], _ media: inout [MediaId: Media]) -> RichText { + var text: RichText + if let string = input["content"] as? String { + text = parseRichText(string) + } else if let array = input["content"] as? [Any] { + text = parseRichText(array, &media) + } else { + text = .empty + } + text = applyAnchor(text, item: input) + if let _ = input["bold"] { + text = .bold(text) + } + if let _ = input["italic"] { + text = .italic(text) + } + return text +} + +private func parseRichText(_ input: [Any], _ media: inout [MediaId: Media]) -> RichText { + var result: [RichText] = [] + + for item in input { + if let string = item as? String { + result.append(parseRichText(string)) + } else if let item = item as? [String: Any], let tag = item["tag"] as? String { + var text: RichText? + var addLineBreak = false + switch tag { + case "b", "strong": + text = .bold(parseRichText(item, &media)) + case "i": + text = .italic(parseRichText(item, &media)) + case "s": + text = .strikethrough(parseRichText(item, &media)) + case "p": + text = parseRichText(item, &media) + case "a": + if let href = item["href"] as? String { + let telString = "tel:" + let mailtoString = "mailto:" + if href.hasPrefix("tel:") { + text = .phone(text: parseRichText(item, &media), phone: String(href[href.index(href.startIndex, offsetBy: telString.distance(from: telString.startIndex, to: telString.endIndex))...])) + } else if href.hasPrefix(mailtoString) { + text = .email(text: parseRichText(item, &media), email: String(href[href.index(href.startIndex, offsetBy: mailtoString.distance(from: mailtoString.startIndex, to: mailtoString.endIndex))...])) + } else { + text = .url(text: parseRichText(item, &media), url: href, webpageId: nil) + } + } else { + text = parseRichText(item, &media) + } + case "pre", "code": + text = .fixed(parseRichText(item, &media)) + case "mark": + text = .marked(parseRichText(item, &media)) + case "sub": + text = .subscript(parseRichText(item, &media)) + case "sup": + text = .superscript(parseRichText(item, &media)) + case "img": + if let src = item["src"] as? String, !src.isEmpty { + let width: Int32 + if let value = item["width"] as? String, let intValue = Int32(value) { + width = intValue + } else { + width = 0 + } + let height: Int32 + if let value = item["height"] as? String, let intValue = Int32(value) { + height = intValue + } else { + height = 0 + } + let id = MediaId(namespace: Namespaces.Media.CloudFile, id: Int64(media.count)) + media[id] = TelegramMediaImage( + imageId: id, + representations: [ + TelegramMediaImageRepresentation( + dimensions: PixelDimensions(width: width, height: height), + resource: InstantPageExternalMediaResource(url: src), + progressiveSizes: [], + immediateThumbnailData: nil + ) + ], + immediateThumbnailData: nil, + reference: nil, + partialReference: nil, + flags: [] + ) + text = .image(id: id, dimensions: PixelDimensions(width: width, height: height)) + if width > 100 { + addLineBreak = true + } + } + case "br": + if let last = result.last { + result[result.count - 1] = addNewLine(last) + } + default: + text = parseRichText(item, &media) + } + if var text { + text = applyAnchor(text, item: item) + result.append(text) + if addLineBreak { + result.append(.plain("\n")) + } + } + } + } + + if !result.isEmpty { + return .concat(result) + } else if result.count == 1, let text = result.first { + return text + } else { + return .empty + } +} + +private func trimStart(_ input: RichText) -> RichText { + var text = input + switch input { + case .empty: + text = .empty + case let .plain(string): + text = .plain(string.replacingOccurrences(of: "^[ \t\r\n]+", with: "", options: .regularExpression, range: nil)) + case let .bold(richText): + text = .bold(trimStart(richText)) + case let .italic(richText): + text = .italic(trimStart(richText)) + case let .underline(richText): + text = .underline(trimStart(richText)) + case let .strikethrough(richText): + text = .strikethrough(trimStart(richText)) + case let .fixed(richText): + text = .fixed(trimStart(richText)) + case let .url(richText, url, webpageId): + text = .url(text: trimStart(richText), url: url, webpageId: webpageId) + case let .email(richText, email): + text = .email(text: trimStart(richText), email: email) + case let .subscript(richText): + text = .subscript(trimStart(richText)) + case let .superscript(richText): + text = .superscript(trimStart(richText)) + case let .marked(richText): + text = .marked(trimStart(richText)) + case let .phone(richText, phone): + text = .phone(text: trimStart(richText), phone: phone) + case let .anchor(richText, name): + text = .anchor(text: trimStart(richText), name: name) + case var .concat(array): + if !array.isEmpty { + array[0] = trimStart(array[0]) + text = .concat(array) + } + case .image: + break + } + return text +} + +private func trimEnd(_ input: RichText) -> RichText { + var text = input + switch input { + case .empty: + text = .empty + case let .plain(string): + text = .plain(string.replacingOccurrences(of: "[ \t\r\n]+$", with: "", options: .regularExpression, range: nil)) + case let .bold(richText): + text = .bold(trimStart(richText)) + case let .italic(richText): + text = .italic(trimStart(richText)) + case let .underline(richText): + text = .underline(trimStart(richText)) + case let .strikethrough(richText): + text = .strikethrough(trimStart(richText)) + case let .fixed(richText): + text = .fixed(trimStart(richText)) + case let .url(richText, url, webpageId): + text = .url(text: trimStart(richText), url: url, webpageId: webpageId) + case let .email(richText, email): + text = .email(text: trimStart(richText), email: email) + case let .subscript(richText): + text = .subscript(trimStart(richText)) + case let .superscript(richText): + text = .superscript(trimStart(richText)) + case let .marked(richText): + text = .marked(trimStart(richText)) + case let .phone(richText, phone): + text = .phone(text: trimStart(richText), phone: phone) + case let .anchor(richText, name): + text = .anchor(text: trimStart(richText), name: name) + case var .concat(array): + if !array.isEmpty { + array[array.count - 1] = trimStart(array[array.count - 1]) + text = .concat(array) + } + case .image: + break + } + return text +} + +private func trim(_ input: RichText) -> RichText { + var text = input + switch input { + case .empty: + text = .empty + case let .plain(string): + text = .plain(string.trimmingCharacters(in: .whitespacesAndNewlines)) + case let .bold(richText): + text = .bold(trimStart(richText)) + case let .italic(richText): + text = .italic(trimStart(richText)) + case let .underline(richText): + text = .underline(trimStart(richText)) + case let .strikethrough(richText): + text = .strikethrough(trimStart(richText)) + case let .fixed(richText): + text = .fixed(trimStart(richText)) + case let .url(richText, url, webpageId): + text = .url(text: trimStart(richText), url: url, webpageId: webpageId) + case let .email(richText, email): + text = .email(text: trimStart(richText), email: email) + case let .subscript(richText): + text = .subscript(trimStart(richText)) + case let .superscript(richText): + text = .superscript(trimStart(richText)) + case let .marked(richText): + text = .marked(trimStart(richText)) + case let .phone(richText, phone): + text = .phone(text: trimStart(richText), phone: phone) + case let .anchor(richText, name): + text = .anchor(text: trimStart(richText), name: name) + case var .concat(array): + if !array.isEmpty { + array[0] = trimStart(array[0]) + array[array.count - 1] = trimEnd(array[array.count - 1]) + text = .concat(array) + } + case .image: + break + } + return text +} + +private func addNewLine(_ input: RichText) -> RichText { + var text = input + switch input { + case .empty: + text = .empty + case let .plain(string): + text = .plain(string + "\n") + case let .bold(richText): + text = .bold(addNewLine(richText)) + case let .italic(richText): + text = .italic(addNewLine(richText)) + case let .underline(richText): + text = .underline(addNewLine(richText)) + case let .strikethrough(richText): + text = .strikethrough(addNewLine(richText)) + case let .fixed(richText): + text = .fixed(addNewLine(richText)) + case let .url(richText, url, webpageId): + text = .url(text: addNewLine(richText), url: url, webpageId: webpageId) + case let .email(richText, email): + text = .email(text: addNewLine(richText), email: email) + case let .subscript(richText): + text = .subscript(addNewLine(richText)) + case let .superscript(richText): + text = .superscript(addNewLine(richText)) + case let .marked(richText): + text = .marked(addNewLine(richText)) + case let .phone(richText, phone): + text = .phone(text: addNewLine(richText), phone: phone) + case let .anchor(richText, name): + text = .anchor(text: addNewLine(richText), name: name) + case var .concat(array): + if !array.isEmpty { + array[array.count - 1] = addNewLine(array[array.count - 1]) + text = .concat(array) + } + case .image: + break + } + return text +} + +private func applyAnchor(_ input: RichText, item: [String: Any]) -> RichText { + guard let id = item["id"] as? String, !id.isEmpty else { + return input + } + return .anchor(text: input, name: id) +} + +private func parseTable(_ input: [String: Any], _ media: inout [MediaId: Media]) -> InstantPageBlock { + let title = (input["title"] as? String) ?? "" + return .table( + title: trim(applyAnchor(parseRichText(title), item: input)), + rows: parseTableRows((input["content"] as? [Any]) ?? [], &media), + bordered: true, + striped: true + ) +} + +private func parseTableRows(_ input: [Any], _ media: inout [MediaId: Media]) -> [InstantPageTableRow] { + var result: [InstantPageTableRow] = [] + for item in input { + if let item = item as? [String: Any] { + let tag = item["tag"] as? String + if tag == "tr" { + result.append(parseTableRow(item, &media)) + } else if let content = item["content"] as? [Any] { + result.append(contentsOf: parseTableRows(content, &media)) + } + } + } + return result +} + +private func parseTableRow(_ input: [String: Any], _ media: inout [MediaId: Media]) -> InstantPageTableRow { + var cells: [InstantPageTableCell] = [] + + if let content = input["content"] as? [Any] { + for item in content { + guard let item = item as? [String: Any] else { + continue + } + let tag = item["tag"] as? String + guard ["td", "th"].contains(tag) else { + continue + } + var text: RichText? + if let content = item["content"] as? [Any] { + text = trim(parseRichText(content, &media)) + if let currentText = text { + if let _ = item["bold"] { + text = .bold(currentText) + } + if let _ = item["italic"] { + text = .italic(currentText) + } + } + } + cells.append(InstantPageTableCell( + text: text, + header: tag == "th", + alignment: item["xcenter"] != nil ? .center : .left, + verticalAlignment: .middle, + colspan: ((item["colspan"] as? String).flatMap { Int32($0) }) ?? 0, + rowspan: ((item["rowspan"] as? String).flatMap { Int32($0) }) ?? 0 + )) + } + } + + return InstantPageTableRow(cells: cells) +} + +private func parseDetails(_ item: [String: Any], _ url: String, _ media: inout [MediaId: Media]) -> InstantPageBlock? { + guard var content = item["contant"] as? [Any] else { + return nil + } + var title: RichText = .empty + var titleIndex: Int? + for i in 0 ..< content.count { + if let subitem = content[i] as? [String: Any], let tag = subitem["tag"] as? String, tag == "summary" { + title = trim(parseRichText(subitem, &media)) + titleIndex = i + break + } + } + if let titleIndex { + content.remove(at: titleIndex) + } + return .details( + title: title, + blocks: parsePageBlocks(content, url, &media), + expanded: item["open"] != nil + ) +} + +private let nonListCharacters = CharacterSet(charactersIn: "0123456789").inverted +private func parseList(_ input: [String: Any], _ url: String, _ media: inout [MediaId: Media]) -> InstantPageBlock? { + guard let content = input["content"] as? [Any], let tag = input["tag"] as? String else { + return nil + } + var items: [InstantPageListItem] = [] + for item in content { + guard let item = item as? [String: Any], let tag = item["tag"] as? String, tag == "li" else { + continue + } + var parseAsBlocks = false + if let subcontent = item["content"] as? [Any] { + for item in subcontent { + if let item = item as? [String: Any], let tag = item["tag"] as? String, ["ul", "ol"].contains(tag) { + parseAsBlocks = true + } + } + if parseAsBlocks { + let blocks = parsePageBlocks(subcontent, url, &media) + if !blocks.isEmpty { + items.append(.blocks(blocks, nil)) + } + } else { + items.append(.text(trim(parseRichText(item, &media)), nil)) + } + } + } + let ordered = tag == "ol" + var allEmpty = true + for item in items { + if case let .text(text, _) = item { + if case .empty = text { + } else { + let plainText = text.plainText + if !plainText.isEmpty && plainText.rangeOfCharacter(from: nonListCharacters) != nil { + allEmpty = false + } + break + } + } else { + allEmpty = false + break + } + } + guard !allEmpty else { + return nil + } + return .list(items: items, ordered: ordered) +} + +private func parseImage(_ input: [String: Any], _ media: inout [MediaId: Media]) -> InstantPageBlock? { + guard let src = input["src"] as? String else { + return nil + } + + let caption: InstantPageCaption + if let alt = input["alt"] as? String { + caption = InstantPageCaption( + text: trim(parseRichText(alt)), + credit: .empty + ) + } else { + caption = InstantPageCaption(text: .empty, credit: .empty) + } + + let width: Int32 + if let value = input["width"] as? String, let intValue = Int32(value) { + width = intValue + } else { + width = 0 + } + + let height: Int32 + if let value = input["height"] as? String, let intValue = Int32(value) { + height = intValue + } else { + height = 0 + } + + let id = MediaId(namespace: Namespaces.Media.CloudImage, id: Int64(media.count)) + media[id] = TelegramMediaImage( + imageId: id, + representations: [ + TelegramMediaImageRepresentation( + dimensions: PixelDimensions(width: width, height: height), + resource: InstantPageExternalMediaResource(url: src), + progressiveSizes: [], + immediateThumbnailData: nil + ) + ], + immediateThumbnailData: nil, + reference: nil, + partialReference: nil, + flags: [] + ) + + return .image( + id: id, + caption: caption, + url: nil, + webpageId: nil + ) +} + +private func parseVideo(_ input: [String: Any], _ media: inout [MediaId: Media]) -> InstantPageBlock? { + guard let src = input["src"] as? String else { + return nil + } + + let width: Int32 + if let value = input["width"] as? String, let intValue = Int32(value) { + width = intValue + } else { + width = 0 + } + + let height: Int32 + if let value = input["height"] as? String, let intValue = Int32(value) { + height = intValue + } else { + height = 0 + } + + return .webEmbed( + url: src, + html: nil, + dimensions: PixelDimensions(width: width, height: height), + caption: InstantPageCaption(text: .empty, credit: .empty), + stretchToWidth: true, + allowScrolling: false, + coverId: nil + ) +} + +private func parseFigure(_ input: [String: Any], _ media: inout [MediaId: Media]) -> InstantPageBlock? { + guard let content = input["content"] as? [Any] else { + return nil + } + var block: InstantPageBlock? + var caption: RichText? + for item in content { + if let item = item as? [String: Any], let tag = item["tag"] as? String { + if tag == "p", let content = item["content"] as? [Any] { + for item in content { + if let item = item as? [String: Any], let tag = item["tag"] as? String { + if tag == "iframe" { + block = parseVideo(item, &media) + } + } + } + } else if tag == "iframe" { + block = parseVideo(item, &media) + } else if tag == "img" { + block = parseImage(item, &media) + } else if tag == "figcaption" { + caption = trim(parseRichText(item, &media)) + } + } + } + guard var block else { + return nil + } + if let caption, case let .image(id, _, url, webpageId) = block { + block = .image(id: id, caption: InstantPageCaption(text: caption, credit: .empty), url: url, webpageId: webpageId) + } + return block +} + +private func parsePageBlocks(_ input: [Any], _ url: String, _ media: inout [MediaId: Media]) -> [InstantPageBlock] { + var result: [InstantPageBlock] = [] + for item in input { + if let string = item as? String { + result.append(.paragraph(trim(parseRichText(string)))) + } else if let item = item as? [String: Any], let tag = item["tag"] as? String { + let content = item["content"] as? [Any] + switch tag { + case "p": + result.append(.paragraph(trim(parseRichText(item, &media)))) + case "h1", "h2": + result.append(.header(trim(parseRichText(item, &media)))) + case "h3", "h4", "h5", "h6": + result.append(.subheader(trim(parseRichText(item, &media)))) + case "pre": + result.append(.preformatted(.fixed(trim(parseRichText(item, &media))))) + case "blockquote": + result.append(.blockQuote(text: .italic(trim(parseRichText(item, &media))), caption: .empty)) + case "img": + if let image = parseImage(item, &media) { + result.append(image) + } + case "iframe": + if let video = parseVideo(item, &media) { + result.append(video) + } + case "figure": + if let figure = parseFigure(item, &media) { + result.append(figure) + } + case "table": + result.append(parseTable(item, &media)) + case "ul", "ol": + if let list = parseList(item, url, &media) { + result.append(list) + } + case "hr": + result.append(.divider) + case "details": + if let details = parseDetails(item, url, &media) { + result.append(details) + } + default: + if let content { + result.append(contentsOf: parsePageBlocks(content, url, &media)) + } + } + } + } + return result +} diff --git a/submodules/BrowserUI/Sources/BrowserScreen.swift b/submodules/BrowserUI/Sources/BrowserScreen.swift index 16bd42554cc..5ac73e97e44 100644 --- a/submodules/BrowserUI/Sources/BrowserScreen.swift +++ b/submodules/BrowserUI/Sources/BrowserScreen.swift @@ -2,6 +2,7 @@ import Foundation import UIKit import SwiftSignalKit import Display +import Postbox import TelegramCore import TelegramPresentationData import ComponentFlow @@ -492,6 +493,7 @@ public class BrowserScreen: ViewController, MinimizableController { case increaseFontSize case resetFontSize case updateFontIsSerif(Bool) + case toggleInstantView(Bool) case addBookmark case openBookmarks case openAddressBar @@ -519,7 +521,7 @@ public class BrowserScreen: ViewController, MinimizableController { private var presentationData: PresentationData private var presentationDataDisposable: Disposable? private var validLayout: (ContainerViewLayout, CGFloat)? - + init(controller: BrowserScreen) { self.context = controller.context self.controller = controller @@ -755,6 +757,8 @@ public class BrowserScreen: ViewController, MinimizableController { return updatedState }) content.updateFontState(self.presentationState.fontState) + case let .toggleInstantView(enabled): + content.toggleInstantView(enabled) case .addBookmark: if let content = self.content.last { self.addBookmark(content.currentState.url, showArrow: true) @@ -822,7 +826,7 @@ public class BrowserScreen: ViewController, MinimizableController { self.requestLayout(transition: transition) } - func pushContent(_ content: BrowserScreen.Subject, transition: ComponentTransition) { + func pushContent(_ content: BrowserScreen.Subject, additionalContent: BrowserContent? = nil, transition: ComponentTransition) { let browserContent: BrowserContent switch content { case let .webPage(url): @@ -834,25 +838,37 @@ public class BrowserScreen: ViewController, MinimizableController { } browserContent = webContent self.controller?.preferredConfiguration = nil - case let .instantPage(webPage, anchor, sourceLocation): - let instantPageContent = BrowserInstantPageContent(context: self.context, presentationData: self.presentationData, webPage: webPage, anchor: anchor, url: webPage.content.url ?? "", sourceLocation: sourceLocation) + case let .instantPage(webPage, anchor, sourceLocation, preloadedResouces): + let instantPageContent = BrowserInstantPageContent(context: self.context, presentationData: self.presentationData, webPage: webPage, anchor: anchor, url: webPage.content.url ?? "", sourceLocation: sourceLocation, preloadedResouces: preloadedResouces, originalContent: additionalContent) instantPageContent.openPeer = { [weak self] peer in guard let self else { return } self.openPeer(peer) } + instantPageContent.restoreContent = { [weak self, weak instantPageContent] content in + guard let self, let instantPageContent else { + return + } + self.pushBrowserContent(content, additionalContent: instantPageContent, transition: .easeInOut(duration: 0.3).withUserData(NavigationStackComponent.CurlTransition.hide)) + } browserContent = instantPageContent case let .document(file, _): browserContent = BrowserDocumentContent(context: self.context, presentationData: self.presentationData, file: file) case let .pdfDocument(file, _): browserContent = BrowserPdfContent(context: self.context, presentationData: self.presentationData, file: file) } - browserContent.pushContent = { [weak self] content in + browserContent.pushContent = { [weak self] content, additionalContent in guard let self else { return } - self.pushContent(content, transition: .spring(duration: 0.4)) + var transition: ComponentTransition + if let _ = additionalContent { + transition = .easeInOut(duration: 0.3).withUserData(NavigationStackComponent.CurlTransition.show) + } else { + transition = .spring(duration: 0.4) + } + self.pushContent(content, additionalContent: additionalContent, transition: transition) } browserContent.openAppUrl = { [weak self] url in guard let self else { @@ -896,7 +912,15 @@ public class BrowserScreen: ViewController, MinimizableController { } } - self.content.append(browserContent) + self.pushBrowserContent(browserContent, additionalContent: additionalContent, transition: transition) + } + + func pushBrowserContent(_ browserContent: BrowserContent, additionalContent: BrowserContent? = nil, transition: ComponentTransition) { + if let additionalContent, let index = self.content.firstIndex(where: { $0 === additionalContent }) { + self.content[index] = browserContent + } else { + self.content.append(browserContent) + } self.requestLayout(transition: transition) self.setupContentStateUpdates() @@ -1050,16 +1074,19 @@ public class BrowserScreen: ViewController, MinimizableController { return } + guard let controller = self.controller, let content = self.content.last else { + return + } + if let animationComponentView = referenceView.componentView.view as? LottieComponent.View { animationComponentView.playOnce() } - - self.view.endEditing(true) - let checkIcon: (PresentationTheme) -> UIImage? = { theme in return generateTintedImage(image: UIImage(bundleImageName: "Instant View/Settings/Check"), color: theme.contextMenu.primaryColor) } - let emptyIcon: (PresentationTheme) -> UIImage? = { _ in - return nil + if let webContent = content as? BrowserWebContent { + webContent.requestInstantView() } + + self.view.endEditing(true) let settings = context.sharedContext.accountManager.sharedData(keys: [ApplicationSpecificSharedDataKeys.webBrowserSettings]) |> take(1) @@ -1071,17 +1098,19 @@ public class BrowserScreen: ViewController, MinimizableController { } } - let _ = (settings - |> deliverOnMainQueue).start(next: { [weak self] settings in - guard let self, let controller = self.controller, let contentState = self.contentState, let layout = self.validLayout?.0 else { - return + let source: ContextContentSource = .reference(BrowserReferenceContentSource(controller: controller, sourceView: referenceView.referenceNode.view)) + + let items: Signal = combineLatest( + queue: Queue.mainQueue(), + settings, + content.state + ) + |> map { [weak self] settings, contentState -> ContextController.Items in + guard let self, let layout = self.validLayout?.0 else { + return ContextController.Items(content: .list([])) } - let source: ContextContentSource = .reference(BrowserReferenceContentSource(controller: controller, sourceView: referenceView.referenceNode.view)) - let performAction = self.performAction - - let forceIsSerif = self.presentationState.fontState.isSerif let fontItem = BrowserFontSizeContextMenuItem( value: self.presentationState.fontState.size, decrease: { [weak self] in @@ -1140,16 +1169,20 @@ public class BrowserScreen: ViewController, MinimizableController { } else { items.append(.custom(fontItem, false)) + //TODO:localize - items.append(.action(ContextMenuActionItem(text: self.presentationData.strings.InstantPage_FontSanFrancisco, icon: forceIsSerif ? emptyIcon : checkIcon, action: { (controller, action) in - performAction.invoke(.updateFontIsSerif(false)) - action(.default) - }))) - - items.append(.action(ContextMenuActionItem(text: self.presentationData.strings.InstantPage_FontNewYork, textFont: .custom(font: Font.with(size: 17.0, design: .serif, traits: []), height: nil, verticalOffset: nil), icon: forceIsSerif ? checkIcon : emptyIcon, action: { (controller, action) in - performAction.invoke(.updateFontIsSerif(true)) - action(.default) - }))) + if case .webPage = contentState.contentType { + let isAvailable = contentState.hasInstantView + items.append(.action(ContextMenuActionItem(text: self.presentationData.strings.WebBrowser_ShowInstantView, textColor: isAvailable ? .primary : .disabled, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Boost"), color: isAvailable ? theme.contextMenu.primaryColor : theme.contextMenu.primaryColor.withAlphaComponent(0.3)) }, action: isAvailable ? { (controller, action) in + performAction.invoke(.toggleInstantView(true)) + action(.default) + } : nil))) + } else if case .instantPage = contentState.contentType, contentState.isInnerInstantViewEnabled { + items.append(.action(ContextMenuActionItem(text: self.presentationData.strings.WebBrowser_HideInstantView, textColor: .primary, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Instant View/InstantViewOff"), color: theme.contextMenu.primaryColor) }, action: { (controller, action) in + performAction.invoke(.toggleInstantView(false)) + action(.default) + }))) + } } if !items.isEmpty { @@ -1190,10 +1223,16 @@ public class BrowserScreen: ViewController, MinimizableController { }))) } } - - let contextController = ContextController(presentationData: self.presentationData, source: source, items: .single(ContextController.Items(content: .list(items)))) - self.controller?.present(contextController, in: .window(.root)) - }) + return ContextController.Items(content: .list(items)) + } + + let contextController = ContextController(presentationData: self.presentationData, source: source, items: items) + contextController.dismissed = { [weak content] in + if let webContent = content as? BrowserWebContent { + webContent.releaseInstantView() + } + } + self.controller?.present(contextController, in: .window(.root)) } override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { @@ -1331,7 +1370,7 @@ public class BrowserScreen: ViewController, MinimizableController { inputHeight: layout.inputHeight ?? 0.0, metrics: layout.metrics, deviceMetrics: layout.deviceMetrics, - orientation: nil, + orientation: layout.metrics.orientation, isVisible: true, theme: self.presentationData.theme, strings: self.presentationData.strings, @@ -1434,13 +1473,22 @@ public class BrowserScreen: ViewController, MinimizableController { public enum Subject { case webPage(url: String) - case instantPage(webPage: TelegramMediaWebpage, anchor: String?, sourceLocation: InstantPageSourceLocation) + case instantPage(webPage: TelegramMediaWebpage, anchor: String?, sourceLocation: InstantPageSourceLocation, preloadedResources: [Any]?) case document(file: TelegramMediaFile, canShare: Bool) case pdfDocument(file: TelegramMediaFile, canShare: Bool) + + public var fileId: MediaId? { + switch self { + case let .document(file, _), let .pdfDocument(file, _): + return file.fileId + default: + return nil + } + } } private let context: AccountContext - fileprivate let subject: Subject + public let subject: Subject private var preferredConfiguration: WKWebViewConfiguration? private var openPreviousOnClose = false diff --git a/submodules/BrowserUI/Sources/BrowserToolbarComponent.swift b/submodules/BrowserUI/Sources/BrowserToolbarComponent.swift index a359f7c9e8f..10834cdfd0e 100644 --- a/submodules/BrowserUI/Sources/BrowserToolbarComponent.swift +++ b/submodules/BrowserUI/Sources/BrowserToolbarComponent.swift @@ -257,7 +257,7 @@ final class NavigationToolbarContentComponent: CombinedComponent { component: Button( content: AnyComponent( BundleIconComponent( - name: "Stories/EmbeddedViewIcon", + name: "Instant View/OpenDocument", tintColor: context.component.accentColor ) ), diff --git a/submodules/BrowserUI/Sources/BrowserWebContent.swift b/submodules/BrowserUI/Sources/BrowserWebContent.swift index 8b6e55048a2..68f212f78a3 100644 --- a/submodules/BrowserUI/Sources/BrowserWebContent.swift +++ b/submodules/BrowserUI/Sources/BrowserWebContent.swift @@ -124,7 +124,9 @@ private final class TonSchemeHandler: NSObject, WKURLSchemeHandler { final class WebView: WKWebView { var customBottomInset: CGFloat = 0.0 { didSet { - self.setNeedsLayout() + if self.customBottomInset != oldValue { + self.setNeedsLayout() + } } } @@ -176,6 +178,7 @@ final class BrowserWebContent: UIView, BrowserContent, WKNavigationDelegate, WKU private var presentationData: PresentationData let webView: WebView + var readability: Readability? private let errorView: ComponentHostView private var currentError: Error? @@ -194,7 +197,7 @@ final class BrowserWebContent: UIView, BrowserContent, WKNavigationDelegate, WKU private let faviconDisposable = MetaDisposable() - var pushContent: (BrowserScreen.Subject) -> Void = { _ in } + var pushContent: (BrowserScreen.Subject, BrowserContent?) -> Void = { _, _ in } var openAppUrl: (String) -> Void = { _ in } var onScrollingUpdate: (ContentScrollingUpdate) -> Void = { _ in } var minimize: () -> Void = { } @@ -245,7 +248,7 @@ final class BrowserWebContent: UIView, BrowserContent, WKNavigationDelegate, WKU contentController.add(WeakScriptMessageHandler { message in handleScriptMessageImpl?(message) }, name: "performAction") - + configuration.userContentController = contentController configuration.applicationNameForUserAgent = computedUserAgent() } @@ -349,6 +352,7 @@ final class BrowserWebContent: UIView, BrowserContent, WKNavigationDelegate, WKU self.webView.removeObserver(self, forKeyPath: #keyPath(WKWebView.hasOnlySecureContent)) self.faviconDisposable.dispose() + self.instantPageDisposable.dispose() } private func handleScriptMessage(_ message: WKScriptMessage) { @@ -392,13 +396,36 @@ final class BrowserWebContent: UIView, BrowserContent, WKNavigationDelegate, WKU self.webView.evaluateJavaScript(js) { _, _ in } } + func toggleInstantView(_ enabled: Bool) { + if enabled { + if let instantPage = self.instantPage { + self.pushContent(.instantPage(webPage: instantPage, anchor: nil, sourceLocation: InstantPageSourceLocation(userLocation: .other, peerType: .channel), preloadedResources: self.instantPageResources), self) + } else if let readability = self.readability { + readability.webView.frame = self.webView.frame + self.addSubview(readability.webView) + + var collapsedFrame = readability.webView.frame + collapsedFrame.size.height = 0.0 + readability.webView.clipsToBounds = true + readability.webView.layer.animateFrame(from: collapsedFrame, to: readability.webView.frame, duration: 0.3) + } + } else if let readability = self.readability { + var collapsedFrame = readability.webView.frame + collapsedFrame.size.height = 0.0 + readability.webView.layer.animateFrame(from: readability.webView.frame, to: collapsedFrame, duration: 0.3, removeOnCompletion: false, completion: { _ in + readability.webView.removeFromSuperview() + readability.webView.layer.removeAllAnimations() + }) + } + } + private var didSetupSearch = false private func setupSearch(completion: @escaping () -> Void) { guard !self.didSetupSearch else { completion() return } - + let bundle = getAppBundle() guard let scriptPath = bundle.path(forResource: "UIWebViewSearch", ofType: "js") else { return @@ -738,7 +765,7 @@ final class BrowserWebContent: UIView, BrowserContent, WKNavigationDelegate, WKU } // - if (navigationAction.targetFrame == nil || navigationAction.targetFrame?.isMainFrame == true) && (isTelegramMeLink(url) || isTelegraPhLink(url)) && !url.contains("/auth/push?") && !self._state.url.contains("/auth/push?") { + if (navigationAction.targetFrame == nil || navigationAction.targetFrame?.isMainFrame == true) && (isTelegramMeLink(url) || isTelegraPhLink(url) || url.hasPrefix("tg://")) && !url.contains("/auth/push?") && !self._state.url.contains("/auth/push?") { decisionHandler(.cancel, preferences) self.minimize() self.openAppUrl(url) @@ -774,7 +801,7 @@ final class BrowserWebContent: UIView, BrowserContent, WKNavigationDelegate, WKU func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { if let url = navigationAction.request.url?.absoluteString { - if (navigationAction.targetFrame == nil || navigationAction.targetFrame?.isMainFrame == true) && (isTelegramMeLink(url) || isTelegraPhLink(url)) { + if (navigationAction.targetFrame == nil || navigationAction.targetFrame?.isMainFrame == true) && (isTelegramMeLink(url) || isTelegraPhLink(url) || url.hasPrefix("tg://")) { decisionHandler(.cancel) self.minimize() self.openAppUrl(url) @@ -785,7 +812,12 @@ final class BrowserWebContent: UIView, BrowserContent, WKNavigationDelegate, WKU decisionHandler(.allow) } } - + + private let isLoaded = ValuePromise(false) + private var instantPageDisposable = MetaDisposable() + private var instantPage: TelegramMediaWebpage? + private var instantPageResources: [Any]? + func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) { if let _ = self.currentError { self.currentError = nil @@ -794,6 +826,11 @@ final class BrowserWebContent: UIView, BrowserContent, WKNavigationDelegate, WKU } } self.updateFontState(self.currentFontState, force: true) + + self.readability = nil + self.instantPage = nil + self.instantPageResources = nil + self.isLoaded.set(false) } func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { @@ -802,11 +839,74 @@ final class BrowserWebContent: UIView, BrowserContent, WKNavigationDelegate, WKU .withUpdatedForwardList(webView.backForwardList.forwardList.map { BrowserContentState.HistoryItem(webItem: $0) }) } self.parseFavicon() + + self.isLoaded.set(true) + } + + func releaseInstantView() { + self.instantPageDisposable.set(nil) + } + + func requestInstantView() { + guard self.readability == nil else { + return + } + + self.instantPageDisposable.set( + (self.isLoaded.get() + |> filter { $0 } + |> take(1) + |> deliverOnMainQueue).start(next: { [weak self] _ in + guard let self else { + return + } + guard let url = URL(string: self._state.url) else { + return + } + + if #available(iOS 14.5, *) { + self.webView.createWebArchiveData { [weak self] result in + guard let self, case let .success(data) = result else { + return + } + let readability = Readability(url: url, archiveData: data, completionHandler: { [weak self] result, error in + guard let self else { + return + } + if let (webPage, resources) = result { + self.updateState {$0 + .withUpdatedHasInstantView(true) + } + self.instantPage = webPage + self.instantPageResources = resources + let _ = (updatedRemoteWebpage(postbox: self.context.account.postbox, network: self.context.account.network, accountPeerId: self.context.account.peerId, webPage: WebpageReference(TelegramMediaWebpage(webpageId: MediaId(namespace: 0, id: 0), content: .Loaded(TelegramMediaWebpageLoadedContent(url: self._state.url, displayUrl: "", hash: 0, type: nil, websiteName: nil, title: nil, text: nil, embedUrl: nil, embedType: nil, embedSize: nil, duration: nil, author: nil, isMediaLargeByDefault: nil, image: nil, file: nil, story: nil, attributes: [], instantPage: nil))))) + |> deliverOnMainQueue).start(next: { [weak self] webPage in + guard let self, let webPage, case let .Loaded(result) = webPage.content, let _ = result.instantPage else { + return + } + self.instantPage = webPage + }) + } else { + self.instantPage = nil + self.instantPageResources = nil + self.updateState {$0 + .withUpdatedHasInstantView(false) + } + } + }) + self.readability = readability + } + } + }) + ) } func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { if [-1003, -1100, 102].contains((error as NSError).code) { - self.currentError = error + if let url = (error as NSError).userInfo["NSErrorFailingURLKey"] as? URL, url.absoluteString.hasPrefix("itms-appss:") { + } else { + self.currentError = error + } } else { self.currentError = nil } @@ -824,7 +924,7 @@ final class BrowserWebContent: UIView, BrowserContent, WKNavigationDelegate, WKU } // - if isTelegramMeLink(url) || isTelegraPhLink(url) { + if isTelegramMeLink(url) || isTelegraPhLink(url) || url.hasPrefix("tg://") { self.minimize() self.openAppUrl(url) } else { @@ -979,7 +1079,7 @@ final class BrowserWebContent: UIView, BrowserContent, WKNavigationDelegate, WKU navigationController.pushViewController(controller) return (controller.node.content.last as? BrowserWebContent)?.webView } else { - self.pushContent(subject) + self.pushContent(subject, nil) } return nil } @@ -1304,7 +1404,7 @@ let setupFontFunctions = """ """ private let videoSource = """ -function disableWebkitEnterFullscreen(videoElement) { +function tgBrowserDisableWebkitEnterFullscreen(videoElement) { if (videoElement && videoElement.webkitEnterFullscreen) { Object.defineProperty(videoElement, 'webkitEnterFullscreen', { value: undefined @@ -1312,11 +1412,11 @@ function disableWebkitEnterFullscreen(videoElement) { } } -function disableFullscreenOnExistingVideos() { - document.querySelectorAll('video').forEach(disableWebkitEnterFullscreen); +function tgBrowserDisableFullscreenOnExistingVideos() { + document.querySelectorAll('video').forEach(tgBrowserDisableWebkitEnterFullscreen); } -function handleMutations(mutations) { +function tgBrowserHandleMutations(mutations) { mutations.forEach((mutation) => { if (mutation.addedNodes && mutation.addedNodes.length > 0) { mutation.addedNodes.forEach((newNode) => { @@ -1331,17 +1431,17 @@ function handleMutations(mutations) { }); } -disableFullscreenOnExistingVideos(); +tgBrowserDisableFullscreenOnExistingVideos(); -const observer = new MutationObserver(handleMutations); +const _tgbrowser_observer = new MutationObserver(tgBrowserHandleMutations); -observer.observe(document.body, { +_tgbrowser_observer.observe(document.body, { childList: true, subtree: true }); -function disconnectObserver() { - observer.disconnect(); +function tgBrowserDisconnectObserver() { + _tgbrowser_observer.disconnect(); } """ diff --git a/submodules/Camera/Sources/CameraDevice.swift b/submodules/Camera/Sources/CameraDevice.swift index d8c75fbe142..6778c2dff52 100644 --- a/submodules/Camera/Sources/CameraDevice.swift +++ b/submodules/Camera/Sources/CameraDevice.swift @@ -271,7 +271,10 @@ final class CameraDevice { return } self.transaction(device) { device in - device.torchMode = active ? .on : .off + let torchMode: AVCaptureDevice.TorchMode = active ? .on : .off + if device.isTorchModeSupported(torchMode) { + device.torchMode = active ? .on : .off + } } } diff --git a/submodules/ChatListUI/Sources/ChatListController.swift b/submodules/ChatListUI/Sources/ChatListController.swift index e4cd0800d9d..485a4cd873b 100644 --- a/submodules/ChatListUI/Sources/ChatListController.swift +++ b/submodules/ChatListUI/Sources/ChatListController.swift @@ -1460,17 +1460,26 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController strongSelf.presentInGlobalOverlay(contextController) } } else { + var dismissPreviewingImpl: (() -> Void)? let source: ContextContentSource if let location = location { source = .location(ChatListContextLocationContentSource(controller: strongSelf, location: location)) } else { let chatController = strongSelf.context.sharedContext.makeChatController(context: strongSelf.context, chatLocation: .peer(id: peer.peerId), subject: nil, botStart: nil, mode: .standard(.previewing), params: nil) + chatController.customNavigationController = strongSelf.navigationController as? NavigationController chatController.canReadHistory.set(false) + chatController.dismissPreviewing = { + dismissPreviewingImpl?() + } source = .controller(ContextControllerContentSourceImpl(controller: chatController, sourceNode: node, navigationController: strongSelf.navigationController as? NavigationController)) } let contextController = ContextController(presentationData: strongSelf.presentationData, source: source, items: chatContextMenuItems(context: strongSelf.context, peerId: peer.peerId, promoInfo: promoInfo, source: .chatList(filter: strongSelf.chatListDisplayNode.mainContainerNode.currentItemNode.chatListFilter), chatListController: strongSelf, joined: joined) |> map { ContextController.Items(content: .list($0)) }, gesture: gesture) strongSelf.presentInGlobalOverlay(contextController) + + dismissPreviewingImpl = { [weak contextController] in + contextController?.dismiss() + } } case let .forum(pinnedIndex, _, threadId, _, _): let isPinned: Bool diff --git a/submodules/ChatListUI/Sources/Node/ChatListItem.swift b/submodules/ChatListUI/Sources/Node/ChatListItem.swift index 456698e945b..98a90849d93 100644 --- a/submodules/ChatListUI/Sources/Node/ChatListItem.swift +++ b/submodules/ChatListUI/Sources/Node/ChatListItem.swift @@ -3751,15 +3751,14 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { } strongSelf.onlineNode.setImage(onlineIcon, color: item.presentationData.theme.list.itemCheckColors.foregroundColor, transition: .immediate) - if isSubscription { + if isSubscription, autoremoveTimeout == nil { let starView: StarView if let current = strongSelf.starView { starView = current } else { starView = StarView() strongSelf.starView = starView - strongSelf.view.addSubview(starView) -// strongSelf.mainContentContainerNode.view.addSubview(starView) + strongSelf.contextContainer.view.addSubview(starView) } starView.outlineColor = effectiveBackgroundColor diff --git a/submodules/ChatSendMessageActionUI/Sources/ChatSendMessageActionSheetController.swift b/submodules/ChatSendMessageActionUI/Sources/ChatSendMessageActionSheetController.swift index 5c5647b2f12..8271fbb40d2 100644 --- a/submodules/ChatSendMessageActionUI/Sources/ChatSendMessageActionSheetController.swift +++ b/submodules/ChatSendMessageActionUI/Sources/ChatSendMessageActionSheetController.swift @@ -22,6 +22,7 @@ public enum SendMessageActionSheetControllerParams { public let forwardMessageIds: [EngineMessage.Id] public let canMakePaidContent: Bool public let currentPrice: Int64? + public let hasTimers: Bool public init( isScheduledMessages: Bool, @@ -32,7 +33,8 @@ public enum SendMessageActionSheetControllerParams { canSendWhenOnline: Bool, forwardMessageIds: [EngineMessage.Id], canMakePaidContent: Bool, - currentPrice: Int64? + currentPrice: Int64?, + hasTimers: Bool ) { self.isScheduledMessages = isScheduledMessages self.mediaPreview = mediaPreview @@ -43,6 +45,7 @@ public enum SendMessageActionSheetControllerParams { self.forwardMessageIds = forwardMessageIds self.canMakePaidContent = canMakePaidContent self.currentPrice = currentPrice + self.hasTimers = hasTimers } } diff --git a/submodules/ChatSendMessageActionUI/Sources/ChatSendMessageContextScreen.swift b/submodules/ChatSendMessageActionUI/Sources/ChatSendMessageContextScreen.swift index e5d074f9449..f463d2d3f38 100644 --- a/submodules/ChatSendMessageActionUI/Sources/ChatSendMessageContextScreen.swift +++ b/submodules/ChatSendMessageActionUI/Sources/ChatSendMessageContextScreen.swift @@ -490,6 +490,9 @@ final class ChatSendMessageContextScreenComponent: Component { if sendMessage.isScheduledMessages { canSchedule = false } + if sendMessage.hasTimers { + canSchedule = false + } canMakePaidContent = sendMessage.canMakePaidContent currentPrice = sendMessage.currentPrice case .editMessage: diff --git a/submodules/ComponentFlow/Source/Base/Transition.swift b/submodules/ComponentFlow/Source/Base/Transition.swift index aa595927fe9..b42f21063e2 100644 --- a/submodules/ComponentFlow/Source/Base/Transition.swift +++ b/submodules/ComponentFlow/Source/Base/Transition.swift @@ -17,7 +17,7 @@ public extension UIView { } private extension CALayer { - func animate(from: AnyObject, to: AnyObject, keyPath: String, duration: Double, delay: Double, curve: ComponentTransition.Animation.Curve, removeOnCompletion: Bool, additive: Bool, completion: ((Bool) -> Void)? = nil) { + func animate(from: Any, to: Any, keyPath: String, duration: Double, delay: Double, curve: ComponentTransition.Animation.Curve, removeOnCompletion: Bool, additive: Bool, completion: ((Bool) -> Void)? = nil) { let timingFunction: String let mediaTimingFunction: CAMediaTimingFunction? switch curve { @@ -471,7 +471,12 @@ public struct ComponentTransition { layer.removeAnimation(forKey: "opacity") completion?(true) case .curve: - let previousAlpha = layer.presentation()?.opacity ?? layer.opacity + let previousAlpha: Float + if layer.animation(forKey: "opacity") != nil { + previousAlpha = layer.presentation()?.opacity ?? layer.opacity + } else { + previousAlpha = layer.opacity + } layer.opacity = Float(alpha) self.animateAlpha(layer: layer, from: CGFloat(previousAlpha), to: alpha, delay: delay, completion: completion) } @@ -1184,6 +1189,44 @@ public struct ComponentTransition { } } + public func setGradientColors(layer: CAGradientLayer, colors: [UIColor], completion: ((Bool) -> Void)? = nil) { + if let current = layer.colors { + if current.count == colors.count { + let currentColors = current.map { UIColor(cgColor: $0 as! CGColor) } + if currentColors == colors { + completion?(true) + return + } + } + } + + switch self.animation { + case .none: + layer.colors = colors.map(\.cgColor) + completion?(true) + case let .curve(duration, curve): + let previousColors: [Any] + if let current = layer.colors { + previousColors = current + } else { + previousColors = (0 ..< colors.count).map { _ in UIColor.clear.cgColor as Any } + } + layer.colors = colors.map(\.cgColor) + + layer.animate( + from: previousColors, + to: colors.map(\.cgColor), + keyPath: "colors", + duration: duration, + delay: 0.0, + curve: curve, + removeOnCompletion: true, + additive: false, + completion: completion + ) + } + } + public func animateContentsImage(layer: CALayer, from fromImage: CGImage, to toImage: CGImage, duration: Double, curve: ComponentTransition.Animation.Curve, completion: ((Bool) -> Void)? = nil) { layer.animate( from: fromImage, diff --git a/submodules/ComponentFlow/Source/Components/RoundedRectangle.swift b/submodules/ComponentFlow/Source/Components/RoundedRectangle.swift index 2c6777b0767..1ea0212393f 100644 --- a/submodules/ComponentFlow/Source/Components/RoundedRectangle.swift +++ b/submodules/ComponentFlow/Source/Components/RoundedRectangle.swift @@ -1,5 +1,6 @@ import Foundation import UIKit +import Display public final class RoundedRectangle: Component { public enum GradientDirection: Equatable { @@ -117,3 +118,275 @@ public final class RoundedRectangle: Component { return view.update(component: self, availableSize: availableSize, transition: transition) } } + +public final class FilledRoundedRectangleComponent: Component { + public let color: UIColor + public let cornerRadius: CGFloat + public let smoothCorners: Bool + + public init( + color: UIColor, + cornerRadius: CGFloat, + smoothCorners: Bool + ) { + self.color = color + self.cornerRadius = cornerRadius + self.smoothCorners = smoothCorners + } + + public static func ==(lhs: FilledRoundedRectangleComponent, rhs: FilledRoundedRectangleComponent) -> Bool { + if lhs === rhs { + return true + } + if lhs.color != rhs.color { + return false + } + if lhs.cornerRadius != rhs.cornerRadius { + return false + } + if lhs.smoothCorners != rhs.smoothCorners { + return false + } + return true + } + + public final class View: UIImageView { + private var component: FilledRoundedRectangleComponent? + + private var currentCornerRadius: CGFloat? + private var cornerImage: UIImage? + + override init(frame: CGRect) { + super.init(frame: frame) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func applyStaticCornerRadius() { + guard let component = self.component else { + return + } + guard let cornerRadius = self.currentCornerRadius else { + return + } + if cornerRadius == 0.0 { + if let cornerImage = self.cornerImage, cornerImage.size.width == 1.0 { + } else { + self.cornerImage = generateImage(CGSize(width: 1.0, height: 1.0), rotatedContext: { size, context in + context.setFillColor(UIColor.white.cgColor) + context.fill(CGRect(origin: CGPoint(), size: size)) + })?.stretchableImage(withLeftCapWidth: Int(cornerRadius) + 5, topCapHeight: Int(cornerRadius) + 5).withRenderingMode(.alwaysTemplate) + } + } else { + if component.smoothCorners { + let size = CGSize(width: cornerRadius * 2.0 + 10.0, height: cornerRadius * 2.0 + 10.0) + if let cornerImage = self.cornerImage, cornerImage.size == size { + } else { + self.cornerImage = generateImage(size, rotatedContext: { size, context in + context.clear(CGRect(origin: CGPoint(), size: size)) + context.addPath(UIBezierPath(roundedRect: CGRect(origin: CGPoint(), size: size), cornerRadius: cornerRadius).cgPath) + context.setFillColor(UIColor.white.cgColor) + context.fillPath() + })?.stretchableImage(withLeftCapWidth: Int(cornerRadius) + 5, topCapHeight: Int(cornerRadius) + 5).withRenderingMode(.alwaysTemplate) + } + } else { + let size = CGSize(width: cornerRadius * 2.0, height: cornerRadius * 2.0) + if let cornerImage = self.cornerImage, cornerImage.size == size { + } else { + self.cornerImage = generateStretchableFilledCircleImage(diameter: size.width, color: UIColor.white)?.withRenderingMode(.alwaysTemplate) + } + } + } + self.image = self.cornerImage + self.clipsToBounds = false + self.backgroundColor = nil + self.layer.cornerRadius = 0.0 + } + + func update(component: FilledRoundedRectangleComponent, availableSize: CGSize, transition: ComponentTransition) -> CGSize { + self.component = component + + transition.setTintColor(view: self, color: component.color) + + if self.currentCornerRadius != component.cornerRadius { + let previousCornerRadius = self.currentCornerRadius + self.currentCornerRadius = component.cornerRadius + if transition.animation.isImmediate { + self.applyStaticCornerRadius() + } else { + self.image = nil + self.clipsToBounds = true + self.backgroundColor = component.color + if let previousCornerRadius, self.layer.animation(forKey: "cornerRadius") == nil { + self.layer.cornerRadius = previousCornerRadius + } + if #available(iOS 13.0, *) { + if component.smoothCorners { + self.layer.cornerCurve = .continuous + } else { + self.layer.cornerCurve = .circular + } + + } + transition.setCornerRadius(layer: self.layer, cornerRadius: component.cornerRadius, completion: { [weak self] completed in + guard let self, completed else { + return + } + self.applyStaticCornerRadius() + }) + } + } + + return availableSize + } + } + + public func makeView() -> View { + return View(frame: CGRect()) + } + + public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + return view.update(component: self, availableSize: availableSize, transition: transition) + } +} + +open class SolidRoundedCornersContainer: UIView { + public final class Params: Equatable { + public let size: CGSize + public let color: UIColor + public let cornerRadius: CGFloat + public let smoothCorners: Bool + + public init( + size: CGSize, + color: UIColor, + cornerRadius: CGFloat, + smoothCorners: Bool + ) { + self.size = size + self.color = color + self.cornerRadius = cornerRadius + self.smoothCorners = smoothCorners + } + + public static func ==(lhs: Params, rhs: Params) -> Bool { + if lhs === rhs { + return true + } + if lhs.size != rhs.size { + return false + } + if lhs.color != rhs.color { + return false + } + if lhs.cornerRadius != rhs.cornerRadius { + return false + } + if lhs.smoothCorners != rhs.smoothCorners { + return false + } + return true + } + } + + public let cornersView: UIImageView + + private var params: Params? + private var currentCornerRadius: CGFloat? + private var cornerImage: UIImage? + + override public init(frame: CGRect) { + self.cornersView = UIImageView() + + super.init(frame: frame) + + self.clipsToBounds = true + + self.addSubview(self.cornersView) + } + + required public init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func applyStaticCornerRadius() { + guard let params = self.params else { + return + } + guard let cornerRadius = self.currentCornerRadius else { + return + } + if cornerRadius == 0.0 { + if let cornerImage = self.cornerImage, cornerImage.size.width == 1.0 { + } else { + self.cornerImage = generateImage(CGSize(width: 1.0, height: 1.0), rotatedContext: { size, context in + context.setFillColor(UIColor.clear.cgColor) + context.fill(CGRect(origin: CGPoint(), size: size)) + })?.stretchableImage(withLeftCapWidth: Int(cornerRadius) + 5, topCapHeight: Int(cornerRadius) + 5).withRenderingMode(.alwaysTemplate) + } + } else { + if params.smoothCorners { + let size = CGSize(width: cornerRadius * 2.0 + 10.0, height: cornerRadius * 2.0 + 10.0) + if let cornerImage = self.cornerImage, cornerImage.size == size { + } else { + self.cornerImage = generateImage(size, rotatedContext: { size, context in + context.setFillColor(UIColor.white.cgColor) + context.fill(CGRect(origin: CGPoint(), size: size)) + context.addPath(UIBezierPath(roundedRect: CGRect(origin: CGPoint(), size: size), cornerRadius: cornerRadius).cgPath) + context.setFillColor(UIColor.clear.cgColor) + context.setBlendMode(.copy) + context.fillPath() + })?.stretchableImage(withLeftCapWidth: Int(cornerRadius) + 5, topCapHeight: Int(cornerRadius) + 5).withRenderingMode(.alwaysTemplate) + } + } else { + let size = CGSize(width: cornerRadius * 2.0, height: cornerRadius * 2.0) + if let cornerImage = self.cornerImage, cornerImage.size == size { + } else { + self.cornerImage = generateStretchableFilledCircleImage(diameter: size.width, color: nil, backgroundColor: .white)?.withRenderingMode(.alwaysTemplate) + } + } + } + self.cornersView.image = self.cornerImage + self.backgroundColor = nil + self.layer.cornerRadius = 0.0 + } + + public func update(params: Params, transition: ComponentTransition) { + if self.params == params { + return + } + self.params = params + + transition.setTintColor(view: self.cornersView, color: params.color) + + if self.currentCornerRadius != params.cornerRadius { + let previousCornerRadius = self.currentCornerRadius + self.currentCornerRadius = params.cornerRadius + if transition.animation.isImmediate { + self.applyStaticCornerRadius() + } else { + self.cornersView.image = nil + self.clipsToBounds = true + if let previousCornerRadius, self.layer.animation(forKey: "cornerRadius") == nil { + self.layer.cornerRadius = previousCornerRadius + } + if #available(iOS 13.0, *) { + if params.smoothCorners { + self.layer.cornerCurve = .continuous + } else { + self.layer.cornerCurve = .circular + } + + } + transition.setCornerRadius(layer: self.layer, cornerRadius: params.cornerRadius, completion: { [weak self] completed in + guard let self, completed else { + return + } + self.applyStaticCornerRadius() + }) + } + } + } +} diff --git a/submodules/Components/ReactionButtonListComponent/Sources/ReactionButtonListComponent.swift b/submodules/Components/ReactionButtonListComponent/Sources/ReactionButtonListComponent.swift index b640158a247..7ad116ee033 100644 --- a/submodules/Components/ReactionButtonListComponent/Sources/ReactionButtonListComponent.swift +++ b/submodules/Components/ReactionButtonListComponent/Sources/ReactionButtonListComponent.swift @@ -94,6 +94,7 @@ public final class ReactionIconView: PortalSourceView { private var animateIdle: Bool? private var reaction: MessageReaction.Reaction? + private var isPaused: Bool = false private var isAnimationHidden: Bool = false private var disposable: Disposable? @@ -198,6 +199,29 @@ public final class ReactionIconView: PortalSourceView { } } + func updateIsPaused(isPaused: Bool) { + guard let context = self.context, let animateIdle = self.animateIdle, let animationLayer = self.animationLayer else { + return + } + self.isPaused = isPaused + + let isVisibleForAnimations = !self.isPaused && animateIdle && context.sharedContext.energyUsageSettings.loopEmoji + if isVisibleForAnimations != animationLayer.isVisibleForAnimations { + if isPaused { + animationLayer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false, completion: { [weak animationLayer] _ in + animationLayer?.removeFromSuperlayer() + }) + self.animationLayer = nil + self.reloadFile() + if let animationLayer = self.animationLayer { + animationLayer.animateAlpha(from: 0.0, to: 1.0, duration: 0.12) + } + } else { + animationLayer.isVisibleForAnimations = !self.isPaused && animateIdle && context.sharedContext.energyUsageSettings.loopEmoji + } + } + } + private func reloadFile() { guard let context = self.context, let file = self.file, let animationCache = self.animationCache, let animationRenderer = self.animationRenderer, let placeholderColor = self.placeholderColor, let size = self.size, let animateIdle = self.animateIdle, let reaction = self.reaction else { return @@ -217,7 +241,7 @@ public final class ReactionIconView: PortalSourceView { } let animationLayer = InlineStickerItemLayer( - context: context, + context: .account(context), userLocation: .other, attemptSynchronousLoad: false, emoji: ChatTextInputTextCustomEmojiAttribute( @@ -228,6 +252,7 @@ public final class ReactionIconView: PortalSourceView { file: file, cache: animationCache, renderer: animationRenderer, + unique: true, placeholderColor: placeholderColor, pointSize: CGSize(width: iconSize.width * 2.0, height: iconSize.height * 2.0) ) @@ -248,7 +273,7 @@ public final class ReactionIconView: PortalSourceView { animationLayer.frame = CGRect(origin: CGPoint(x: floor((size.width - iconSize.width) / 2.0), y: floor((size.height - iconSize.height) / 2.0)), size: iconSize) - animationLayer.isVisibleForAnimations = animateIdle && context.sharedContext.energyUsageSettings.loopEmoji + animationLayer.isVisibleForAnimations = !self.isPaused && animateIdle && context.sharedContext.energyUsageSettings.loopEmoji self.updateTintColor() } @@ -883,10 +908,14 @@ public final class ReactionButtonAsyncNode: ContextControllerSourceView { self.beginDelay = 0.0 self.containerView.willUpdateIsExtractedToContextPreview = { [weak self] isExtracted, _ in - guard let strongSelf = self else { + guard let self else { return } - strongSelf.buttonNode.updateIsExtracted(isExtracted: isExtracted, animated: true) + self.buttonNode.updateIsExtracted(isExtracted: isExtracted, animated: true) + + if let iconView = self.iconView { + iconView.updateIsPaused(isPaused: isExtracted) + } } if self.activateAfterCompletion { diff --git a/submodules/Components/ViewControllerComponent/Sources/ViewControllerComponent.swift b/submodules/Components/ViewControllerComponent/Sources/ViewControllerComponent.swift index cc205210cc8..517e3158639 100644 --- a/submodules/Components/ViewControllerComponent/Sources/ViewControllerComponent.swift +++ b/submodules/Components/ViewControllerComponent/Sources/ViewControllerComponent.swift @@ -65,7 +65,7 @@ open class ViewControllerComponentContainer: ViewController { inputHeight: CGFloat, metrics: LayoutMetrics, deviceMetrics: DeviceMetrics, - orientation: UIInterfaceOrientation? = nil, + orientation: UIInterfaceOrientation?, isVisible: Bool, theme: PresentationTheme, strings: PresentationStrings, @@ -177,6 +177,7 @@ open class ViewControllerComponentContainer: ViewController { inputHeight: layout.inputHeight ?? 0.0, metrics: layout.metrics, deviceMetrics: layout.deviceMetrics, + orientation: layout.metrics.orientation, isVisible: self.currentIsVisible, theme: self.resolvedTheme, strings: self.presentationData.strings, @@ -273,17 +274,19 @@ open class ViewControllerComponentContainer: ViewController { theme = theme.withModalBlocksBackground() resolvedTheme = resolvedTheme.withModalBlocksBackground() } + + let presentationData = presentationData.withUpdated(theme: theme) - strongSelf.node.presentationData = presentationData.withUpdated(theme: theme) + strongSelf.node.presentationData = presentationData strongSelf.node.resolvedTheme = resolvedTheme - + switch statusBarStyle { case .none: strongSelf.statusBar.statusBarStyle = .Hide case .ignore: strongSelf.statusBar.statusBarStyle = .Ignore case .default: - strongSelf.statusBar.statusBarStyle = presentationData.theme.rootController.statusBarStyle.style + strongSelf.statusBar.statusBarStyle = resolvedTheme.rootController.statusBarStyle.style } let navigationBarPresentationData: NavigationBarPresentationData? @@ -305,13 +308,14 @@ open class ViewControllerComponentContainer: ViewController { } }).strict() + let resolvedTheme = resolveTheme(baseTheme: presentationData.theme, theme: self.theme) switch statusBarStyle { case .none: self.statusBar.statusBarStyle = .Hide case .ignore: self.statusBar.statusBarStyle = .Ignore case .default: - self.statusBar.statusBarStyle = presentationData.theme.rootController.statusBarStyle.style + self.statusBar.statusBarStyle = resolvedTheme.rootController.statusBarStyle.style } } diff --git a/submodules/ComposePollUI/Sources/ComposePollScreen.swift b/submodules/ComposePollUI/Sources/ComposePollScreen.swift index 19026f35d17..66284469659 100644 --- a/submodules/ComposePollUI/Sources/ComposePollScreen.swift +++ b/submodules/ComposePollUI/Sources/ComposePollScreen.swift @@ -488,6 +488,8 @@ final class ComposePollScreenComponent: Component { self.environment = environment if self.component == nil { + self.isQuiz = component.isQuiz ?? false + self.pollOptions.append(ComposePollScreenComponent.PollOption( id: self.nextPollOptionId )) @@ -1002,28 +1004,35 @@ final class ComposePollScreenComponent: Component { contentHeight += pollOptionsSectionFooterSize.height contentHeight += sectionSpacing + var canBePublic = true + if case let .channel(channel) = component.peer, case .broadcast = channel.info { + canBePublic = false + } + var pollSettingsSectionItems: [AnyComponentWithIdentity] = [] - pollSettingsSectionItems.append(AnyComponentWithIdentity(id: "anonymous", component: AnyComponent(ListActionItemComponent( - theme: environment.theme, - title: AnyComponent(VStack([ - AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent(MultilineTextComponent( - text: .plain(NSAttributedString( - string: environment.strings.CreatePoll_Anonymous, - font: Font.regular(presentationData.listsFontSize.baseDisplaySize), - textColor: environment.theme.list.itemPrimaryTextColor - )), - maximumNumberOfLines: 1 - ))), - ], alignment: .left, spacing: 2.0)), - accessory: .toggle(ListActionItemComponent.Toggle(style: .regular, isOn: self.isAnonymous, action: { [weak self] _ in - guard let self else { - return - } - self.isAnonymous = !self.isAnonymous - self.state?.updated(transition: .spring(duration: 0.4)) - })), - action: nil - )))) + if canBePublic { + pollSettingsSectionItems.append(AnyComponentWithIdentity(id: "anonymous", component: AnyComponent(ListActionItemComponent( + theme: environment.theme, + title: AnyComponent(VStack([ + AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString( + string: environment.strings.CreatePoll_Anonymous, + font: Font.regular(presentationData.listsFontSize.baseDisplaySize), + textColor: environment.theme.list.itemPrimaryTextColor + )), + maximumNumberOfLines: 1 + ))), + ], alignment: .left, spacing: 2.0)), + accessory: .toggle(ListActionItemComponent.Toggle(style: .regular, isOn: self.isAnonymous, action: { [weak self] _ in + guard let self else { + return + } + self.isAnonymous = !self.isAnonymous + self.state?.updated(transition: .spring(duration: 0.4)) + })), + action: nil + )))) + } pollSettingsSectionItems.append(AnyComponentWithIdentity(id: "multiAnswer", component: AnyComponent(ListActionItemComponent( theme: environment.theme, title: AnyComponent(VStack([ @@ -1569,7 +1578,7 @@ public class ComposePollScreen: ViewControllerComponentContainer, AttachmentCont public static func initialData(context: AccountContext) -> InitialData { return InitialData( - maxPollTextLength: Int(255), + maxPollTextLength: Int(200), maxPollOptionLength: 100 ) } diff --git a/submodules/ComposePollUI/Sources/CreatePollController.swift b/submodules/ComposePollUI/Sources/CreatePollController.swift index 91d027f8991..86fb01227b7 100644 --- a/submodules/ComposePollUI/Sources/CreatePollController.swift +++ b/submodules/ComposePollUI/Sources/CreatePollController.swift @@ -134,7 +134,7 @@ private struct OrderedLinkedList: Sequence, Equatable { } } -private let maxTextLength = 255 +private let maxTextLength = 200 private let maxOptionLength = 100 private let maxOptionCount = 10 diff --git a/submodules/ContactListUI/Sources/ContactsController.swift b/submodules/ContactListUI/Sources/ContactsController.swift index 1204cd5b396..4d281252856 100644 --- a/submodules/ContactListUI/Sources/ContactsController.swift +++ b/submodules/ContactListUI/Sources/ContactsController.swift @@ -787,17 +787,6 @@ public class ContactsController: ViewController { }) }))) - items.append(.action(ContextMenuActionItem(text: self.presentationData.strings.Contacts_AddPeopleNearby, icon: { theme in - return generateTintedImage(image: UIImage(bundleImageName: "Contact List/Context Menu/PeopleNearby"), color: theme.contextMenu.primaryColor) - }, action: { [weak self] c, f in - c?.dismiss(completion: { [weak self] in - guard let strongSelf = self else { - return - } - strongSelf.contactsNode.openPeopleNearby?() - }) - }))) - let controller = ContextController(presentationData: self.presentationData, source: .extracted(ContactsTabBarContextExtractedContentSource(controller: self, sourceNode: sourceNode)), items: .single(ContextController.Items(content: .list(items))), recognizer: nil, gesture: gesture) self.context.sharedContext.mainWindow?.presentInGlobalOverlay(controller) } diff --git a/submodules/ContactListUI/Sources/ContactsControllerNode.swift b/submodules/ContactListUI/Sources/ContactsControllerNode.swift index 7f2fd359c9b..36d11ff2a37 100644 --- a/submodules/ContactListUI/Sources/ContactsControllerNode.swift +++ b/submodules/ContactListUI/Sources/ContactsControllerNode.swift @@ -93,17 +93,13 @@ final class ContactsControllerNode: ASDisplayNode, ASGestureRecognizerDelegate { self.presentationData = context.sharedContext.currentPresentationData.with { $0 } self.stringsPromise.set(.single(self.presentationData.strings)) - var addNearbyImpl: (() -> Void)? var inviteImpl: (() -> Void)? let presentation = combineLatest(sortOrder, self.stringsPromise.get()) |> map { sortOrder, strings -> ContactListPresentation in - let options = [ContactListAdditionalOption(title: strings.Contacts_AddPeopleNearby, icon: .generic(UIImage(bundleImageName: "Contact List/PeopleNearbyIcon")!), action: { - addNearbyImpl?() - }), ContactListAdditionalOption(title: strings.Contacts_InviteFriends, icon: .generic(UIImage(bundleImageName: "Contact List/AddMemberIcon")!), action: { + let options = [ContactListAdditionalOption(title: strings.Contacts_InviteFriends, icon: .generic(UIImage(bundleImageName: "Contact List/AddMemberIcon")!), action: { inviteImpl?() })] - switch sortOrder { case .presence: return .orderedByPresence(options: options) @@ -145,13 +141,7 @@ final class ContactsControllerNode: ASDisplayNode, ASGestureRecognizerDelegate { } } }).strict() - - addNearbyImpl = { [weak self] in - if let strongSelf = self { - strongSelf.openPeopleNearby?() - } - } - + inviteImpl = { [weak self] in if let strongSelf = self { strongSelf.openInvite?() diff --git a/submodules/DebugSettingsUI/Sources/DebugController.swift b/submodules/DebugSettingsUI/Sources/DebugController.swift index ab4ac828c77..19de5033840 100644 --- a/submodules/DebugSettingsUI/Sources/DebugController.swift +++ b/submodules/DebugSettingsUI/Sources/DebugController.swift @@ -113,6 +113,8 @@ private enum DebugControllerEntry: ItemListNodeEntry { case playlistPlayback(Bool) case enableQuickReactionSwitch(Bool) case disableReloginTokens(Bool) + case disableCallV2(Bool) + case experimentalCallMute(Bool) case liveStreamV2(Bool) case preferredVideoCodec(Int, String, String?, Bool) case disableVideoAspectScaling(Bool) @@ -143,7 +145,7 @@ private enum DebugControllerEntry: ItemListNodeEntry { return DebugControllerSection.web.rawValue case .keepChatNavigationStack, .skipReadHistory, .dustEffect, .crashOnSlowQueries, .crashOnMemoryPressure: return DebugControllerSection.experiments.rawValue - case .clearTips, .resetNotifications, .crash, .fillLocalSavedMessageCache, .resetDatabase, .resetDatabaseAndCache, .resetHoles, .resetTagHoles, .reindexUnread, .resetCacheIndex, .reindexCache, .resetBiometricsData, .optimizeDatabase, .photoPreview, .knockoutWallpaper, .storiesExperiment, .storiesJpegExperiment, .playlistPlayback, .enableQuickReactionSwitch, .experimentalCompatibility, .enableDebugDataDisplay, .rippleEffect, .browserExperiment, .localTranscription, .enableReactionOverrides, .restorePurchases, .disableReloginTokens, .liveStreamV2: + case .clearTips, .resetNotifications, .crash, .fillLocalSavedMessageCache, .resetDatabase, .resetDatabaseAndCache, .resetHoles, .resetTagHoles, .reindexUnread, .resetCacheIndex, .reindexCache, .resetBiometricsData, .optimizeDatabase, .photoPreview, .knockoutWallpaper, .storiesExperiment, .storiesJpegExperiment, .playlistPlayback, .enableQuickReactionSwitch, .experimentalCompatibility, .enableDebugDataDisplay, .rippleEffect, .browserExperiment, .localTranscription, .enableReactionOverrides, .restorePurchases, .disableReloginTokens, .disableCallV2, .experimentalCallMute, .liveStreamV2: return DebugControllerSection.experiments.rawValue case .logTranslationRecognition, .resetTranslationStates: return DebugControllerSection.translation.rawValue @@ -262,10 +264,14 @@ private enum DebugControllerEntry: ItemListNodeEntry { return 49 case .enableQuickReactionSwitch: return 50 - case .liveStreamV2: + case .disableCallV2: return 51 + case .experimentalCallMute: + return 52 + case .liveStreamV2: + return 53 case let .preferredVideoCodec(index, _, _, _): - return 52 + index + return 54 + index case .disableVideoAspectScaling: return 100 case .enableNetworkFramework: @@ -1367,6 +1373,26 @@ private enum DebugControllerEntry: ItemListNodeEntry { }) }).start() }) + case let .disableCallV2(value): + return ItemListSwitchItem(presentationData: presentationData, title: "Disable Video Chat V2", value: value, sectionId: self.section, style: .blocks, updated: { value in + let _ = arguments.sharedContext.accountManager.transaction ({ transaction in + transaction.updateSharedData(ApplicationSpecificSharedDataKeys.experimentalUISettings, { settings in + var settings = settings?.get(ExperimentalUISettings.self) ?? ExperimentalUISettings.defaultSettings + settings.disableCallV2 = value + return PreferencesEntry(settings) + }) + }).start() + }) + case let .experimentalCallMute(value): + return ItemListSwitchItem(presentationData: presentationData, title: "[WIP] OS mic mute", value: value, sectionId: self.section, style: .blocks, updated: { value in + let _ = arguments.sharedContext.accountManager.transaction ({ transaction in + transaction.updateSharedData(ApplicationSpecificSharedDataKeys.experimentalUISettings, { settings in + var settings = settings?.get(ExperimentalUISettings.self) ?? ExperimentalUISettings.defaultSettings + settings.experimentalCallMute = value + return PreferencesEntry(settings) + }) + }).start() + }) case let .liveStreamV2(value): return ItemListSwitchItem(presentationData: presentationData, title: "Live Stream V2", value: value, sectionId: self.section, style: .blocks, updated: { value in let _ = arguments.sharedContext.accountManager.transaction ({ transaction in @@ -1553,10 +1579,12 @@ private func debugControllerEntries(sharedContext: SharedAccountContext, present } entries.append(.playlistPlayback(experimentalSettings.playlistPlayback)) entries.append(.enableQuickReactionSwitch(!experimentalSettings.disableQuickReaction)) + entries.append(.disableCallV2(experimentalSettings.disableCallV2)) + entries.append(.experimentalCallMute(experimentalSettings.experimentalCallMute)) entries.append(.liveStreamV2(experimentalSettings.liveStreamV2)) } - let codecs: [(String, String?)] = [ + /*let codecs: [(String, String?)] = [ ("No Preference", nil), ("H265", "H265"), ("H264", "H264"), @@ -1566,7 +1594,7 @@ private func debugControllerEntries(sharedContext: SharedAccountContext, present for i in 0 ..< codecs.count { entries.append(.preferredVideoCodec(i, codecs[i].0, codecs[i].1, experimentalSettings.preferredVideoCodec == codecs[i].1)) - } + }*/ if isMainApp { entries.append(.disableVideoAspectScaling(experimentalSettings.disableVideoAspectScaling)) diff --git a/submodules/Display/Source/CAAnimationUtils.swift b/submodules/Display/Source/CAAnimationUtils.swift index b2673b2b9be..ad9317de39e 100644 --- a/submodules/Display/Source/CAAnimationUtils.swift +++ b/submodules/Display/Source/CAAnimationUtils.swift @@ -69,7 +69,7 @@ private func adjustFrameRate(animation: CAAnimation) { } public extension CALayer { - func makeAnimation(from: AnyObject?, to: AnyObject, keyPath: String, timingFunction: String, duration: Double, delay: Double = 0.0, mediaTimingFunction: CAMediaTimingFunction? = nil, removeOnCompletion: Bool = true, additive: Bool = false, completion: ((Bool) -> Void)? = nil) -> CAAnimation { + func makeAnimation(from: Any?, to: Any, keyPath: String, timingFunction: String, duration: Double, delay: Double = 0.0, mediaTimingFunction: CAMediaTimingFunction? = nil, removeOnCompletion: Bool = true, additive: Bool = false, completion: ((Bool) -> Void)? = nil) -> CAAnimation { if timingFunction.hasPrefix(kCAMediaTimingFunctionCustomSpringPrefix) { let components = timingFunction.components(separatedBy: "_") let damping = Float(components[1]) ?? 100.0 @@ -203,7 +203,7 @@ public extension CALayer { } } - func animate(from: AnyObject?, to: AnyObject, keyPath: String, timingFunction: String, duration: Double, delay: Double = 0.0, mediaTimingFunction: CAMediaTimingFunction? = nil, removeOnCompletion: Bool = true, additive: Bool = false, completion: ((Bool) -> Void)? = nil, key: String? = nil) { + func animate(from: Any?, to: Any, keyPath: String, timingFunction: String, duration: Double, delay: Double = 0.0, mediaTimingFunction: CAMediaTimingFunction? = nil, removeOnCompletion: Bool = true, additive: Bool = false, completion: ((Bool) -> Void)? = nil, key: String? = nil) { let animation = self.makeAnimation(from: from, to: to, keyPath: keyPath, timingFunction: timingFunction, duration: duration, delay: delay, mediaTimingFunction: mediaTimingFunction, removeOnCompletion: removeOnCompletion, additive: additive, completion: completion) self.add(animation, forKey: key ?? (additive ? nil : keyPath)) } diff --git a/submodules/Display/Source/GenerateImage.swift b/submodules/Display/Source/GenerateImage.swift index 18bafbed6d7..085da48be88 100644 --- a/submodules/Display/Source/GenerateImage.swift +++ b/submodules/Display/Source/GenerateImage.swift @@ -506,7 +506,7 @@ public func getSharedDevideGraphicsContextSettings() -> DeviceGraphicsContextSet } else { self.colorSpace = context.colorSpace! } - assert(self.rowAlignment == 32) + assert(self.rowAlignment == 32 || self.rowAlignment == 64) assert(self.bitsPerPixel == 32) assert(self.bitsPerComponent == 8) } @@ -576,7 +576,8 @@ public struct DeviceGraphicsContextSettings { public func bytesPerRow(forWidth width: Int) -> Int { let baseValue = self.bitsPerPixel * width / 8 - return (baseValue + 31) & ~0x1F + let alignmentMask = self.rowAlignment - 1 + return (baseValue + alignmentMask) & ~alignmentMask } } diff --git a/submodules/Display/Source/GlobalOverlayPresentationContext.swift b/submodules/Display/Source/GlobalOverlayPresentationContext.swift index 8ebc83f1a8d..f7346c5c505 100644 --- a/submodules/Display/Source/GlobalOverlayPresentationContext.swift +++ b/submodules/Display/Source/GlobalOverlayPresentationContext.swift @@ -24,10 +24,10 @@ public func isViewVisibleInHierarchy(_ view: UIView, _ initial: Bool = true) -> } public final class HierarchyTrackingNode: ASDisplayNode { - private let f: (Bool) -> Void + public var updated: (Bool) -> Void - public init(_ f: @escaping (Bool) -> Void) { - self.f = f + public init(_ f: @escaping (Bool) -> Void = { _ in }) { + self.updated = f super.init() @@ -37,13 +37,13 @@ public final class HierarchyTrackingNode: ASDisplayNode { override public func didEnterHierarchy() { super.didEnterHierarchy() - self.f(true) + self.updated(true) } override public func didExitHierarchy() { super.didExitHierarchy() - self.f(false) + self.updated(false) } } diff --git a/submodules/Display/Source/NavigationBar.swift b/submodules/Display/Source/NavigationBar.swift index b33274d4806..5392dde83fa 100644 --- a/submodules/Display/Source/NavigationBar.swift +++ b/submodules/Display/Source/NavigationBar.swift @@ -140,6 +140,10 @@ private var sharedIsReduceTransparencyEnabled = UIAccessibility.isReduceTranspar public final class NavigationBackgroundNode: ASDisplayNode { private var _color: UIColor + public var color: UIColor { + return self._color + } + private var enableBlur: Bool private var enableSaturation: Bool diff --git a/submodules/GalleryUI/Sources/ChatItemGalleryFooterContentNode.swift b/submodules/GalleryUI/Sources/ChatItemGalleryFooterContentNode.swift index c73e666e38c..127cce67fa3 100644 --- a/submodules/GalleryUI/Sources/ChatItemGalleryFooterContentNode.swift +++ b/submodules/GalleryUI/Sources/ChatItemGalleryFooterContentNode.swift @@ -1714,6 +1714,7 @@ final class ChatItemGalleryFooterContentNode: GalleryFooterContentNode, ASScroll shareController.dismissed = { [weak self] _ in self?.interacting?(false) } + shareController.actionCompleted = { [weak self] in if let strongSelf = self, let actionCompletionText = actionCompletionText { let presentationData = strongSelf.context.sharedContext.currentPresentationData.with { $0 } diff --git a/submodules/GalleryUI/Sources/Items/ChatImageGalleryItem.swift b/submodules/GalleryUI/Sources/Items/ChatImageGalleryItem.swift index 1a39e933c7e..bb911f25ea8 100644 --- a/submodules/GalleryUI/Sources/Items/ChatImageGalleryItem.swift +++ b/submodules/GalleryUI/Sources/Items/ChatImageGalleryItem.swift @@ -259,6 +259,7 @@ final class ChatImageGalleryItemNode: ZoomableContentGalleryItemNode { private let dataDisposable = MetaDisposable() private let recognitionDisposable = MetaDisposable() private var status: MediaResourceStatus? + private var fetchedDimensions: PixelDimensions? private let pagingEnabledPromise = ValuePromise(true) @@ -806,9 +807,9 @@ final class ChatImageGalleryItemNode: ZoomableContentGalleryItemNode { }) } - func setFile(context: AccountContext, userLocation: MediaResourceUserLocation, fileReference: FileMediaReference) { - if self.contextAndMedia == nil || !self.contextAndMedia!.1.media.isEqual(to: fileReference.media) { - if var largestSize = fileReference.media.dimensions { + func setFile(context: AccountContext, userLocation: MediaResourceUserLocation, fileReference: FileMediaReference, force: Bool = false) { + if self.contextAndMedia == nil || !self.contextAndMedia!.1.media.isEqual(to: fileReference.media) || force { + if var largestSize = (fileReference.media.dimensions ?? self.fetchedDimensions) { var displaySize = largestSize.cgSize.dividedByScreenScale() if let previewDimensions = largestImageRepresentation(fileReference.media.previewRepresentations)?.dimensions { let previewAspect = CGFloat(previewDimensions.width) / CGFloat(previewDimensions.height) @@ -848,6 +849,22 @@ final class ChatImageGalleryItemNode: ZoomableContentGalleryItemNode { self.fetchDisposable.set(fetchedMediaResource(mediaBox: self.context.account.postbox.mediaBox, userLocation: userLocation, userContentType: .image, reference: fileReference.resourceReference(fileReference.media.resource)).start()) } else { + let _ = (chatMessageFileDatas(account: context.account, userLocation: userLocation, fileReference: fileReference, progressive: false, fetched: true) + |> mapToSignal { value -> Signal in + if value._2, let path = value._1, let data = try? Data(contentsOf: URL(fileURLWithPath: path)) { + return .single(UIImage(data: data)) + } + return .complete() + } + |> deliverOnMainQueue).start(next: { [weak self] image in + if let self, let image { + self.fetchedDimensions = PixelDimensions(image.size) + self.setFile(context: context, userLocation: userLocation, fileReference: fileReference, force: true) + } + }) + + + self._ready.set(.single(Void())) } } diff --git a/submodules/GraphCore/Sources/Charts/Controllers/GeneralChartComponentController.swift b/submodules/GraphCore/Sources/Charts/Controllers/GeneralChartComponentController.swift index c8e6a429af6..8d9774fa20c 100644 --- a/submodules/GraphCore/Sources/Charts/Controllers/GeneralChartComponentController.swift +++ b/submodules/GraphCore/Sources/Charts/Controllers/GeneralChartComponentController.swift @@ -35,6 +35,9 @@ class GeneralChartComponentController: ChartThemeContainer { var initialHorizontalRange: ClosedRange = BaseConstants.defaultRange var initialVerticalRange: ClosedRange = BaseConstants.defaultRange + var currency: GraphCurrency? + var conversionRate: Double = 1.0 + public var cartViewBounds: (() -> CGRect) = { fatalError() } public var chartFrame: (() -> CGRect) = { fatalError() } @@ -201,7 +204,7 @@ class GeneralChartComponentController: ChartThemeContainer { var detailsVisible = false func showDetailsView(at chartPosition: CGFloat, detailsViewPosition: CGFloat, dataIndex: Int, date: Date, animated: Bool, feedback: Bool) { - setDetailsViewModel?(chartDetailsViewModel(closestDate: date, pointIndex: dataIndex), animated, feedback) + setDetailsViewModel?(chartDetailsViewModel(closestDate: date, pointIndex: dataIndex, currency: self.currency, rate: self.conversionRate), animated, feedback) setDetailsChartVisibleClosure?(true, true) setDetailsViewPositionClosure?(detailsViewPosition) detailsVisible = true @@ -329,8 +332,8 @@ class GeneralChartComponentController: ChartThemeContainer { return (updatedRange, verticalLabels) } - func chartDetailsViewModel(closestDate: Date, pointIndex: Int) -> ChartDetailsViewModel { - let values: [ChartDetailsViewModel.Value] = chartsCollection.chartValues.enumerated().map { arg in + func chartDetailsViewModel(closestDate: Date, pointIndex: Int, currency: GraphCurrency? = nil, rate: Double = 1.0) -> ChartDetailsViewModel { + var values: [ChartDetailsViewModel.Value] = chartsCollection.chartValues.enumerated().map { arg in let (index, component) = arg return ChartDetailsViewModel.Value(prefix: nil, title: component.name, @@ -338,6 +341,39 @@ class GeneralChartComponentController: ChartThemeContainer { color: component.color, visible: chartVisibility[index]) } + + if let currency, let firstValue = values.first, let color = GColor(hexString: "#dda747") { + let updatedTitle: String + switch currency { + case .ton: + updatedTitle = self.strings.revenueInTon + case .xtr: + updatedTitle = self.strings.revenueInStars + } + values[0] = ChartDetailsViewModel.Value( + prefix: nil, + title: updatedTitle, + value: firstValue.value, + color: color, + visible: firstValue.visible + ) + + let convertedValue = (self.verticalLimitsNumberFormatter.number(from: firstValue.value) as? Double ?? 0.0) * rate + let convertedValueString: String + if convertedValue > 1.0 { + convertedValueString = String(format: "%0.1f", convertedValue) + } else { + convertedValueString = String(format: "%0.3f", convertedValue) + } + values.append(ChartDetailsViewModel.Value( + prefix: nil, + title: self.strings.revenueInUsd, + value: "≈$\(convertedValueString)", + color: color, + visible: firstValue.visible + )) + } + let dateString: String if isZoomed { dateString = BaseConstants.timeDateFormatter.string(from: closestDate) diff --git a/submodules/GraphCore/Sources/Charts/Controllers/Percent And Pie/PercentChartComponentController.swift b/submodules/GraphCore/Sources/Charts/Controllers/Percent And Pie/PercentChartComponentController.swift index 53b3cd87cbe..d050e9f09d9 100644 --- a/submodules/GraphCore/Sources/Charts/Controllers/Percent And Pie/PercentChartComponentController.swift +++ b/submodules/GraphCore/Sources/Charts/Controllers/Percent And Pie/PercentChartComponentController.swift @@ -131,7 +131,7 @@ class PercentChartComponentController: GeneralChartComponentController { verticalScalesRenderer.setVisible(visibility.contains(true), animated: animated) } - override func chartDetailsViewModel(closestDate: Date, pointIndex: Int) -> ChartDetailsViewModel { + override func chartDetailsViewModel(closestDate: Date, pointIndex: Int, currency: GraphCurrency? = nil, rate: Double = 1.0) -> ChartDetailsViewModel { let visibleValues = visibleDetailsChartValues let total = visibleValues.map { $0.values[pointIndex] }.reduce(0, +) diff --git a/submodules/GraphCore/Sources/Charts/Controllers/Stacked Bars/BarsComponentController.swift b/submodules/GraphCore/Sources/Charts/Controllers/Stacked Bars/BarsComponentController.swift index b1738c797fd..9f1339a6e66 100644 --- a/submodules/GraphCore/Sources/Charts/Controllers/Stacked Bars/BarsComponentController.swift +++ b/submodules/GraphCore/Sources/Charts/Controllers/Stacked Bars/BarsComponentController.swift @@ -149,7 +149,6 @@ class BarsComponentController: GeneralChartComponentController { components: visibleComponents) } - var conversionRate: Double = 1.0 func verticalLimitsLabels(verticalRange: ClosedRange, secondary: Bool) -> (ClosedRange, [LinesChartLabel]) { var (range, labels) = super.verticalLimitsLabels(verticalRange: verticalRange) if secondary { @@ -223,8 +222,8 @@ class BarsComponentController: GeneralChartComponentController { return visibleCharts } - override func chartDetailsViewModel(closestDate: Date, pointIndex: Int) -> ChartDetailsViewModel { - var viewModel = super.chartDetailsViewModel(closestDate: closestDate, pointIndex: pointIndex) + override func chartDetailsViewModel(closestDate: Date, pointIndex: Int, currency: GraphCurrency? = nil, rate: Double = 1.0) -> ChartDetailsViewModel { + var viewModel = super.chartDetailsViewModel(closestDate: closestDate, pointIndex: pointIndex, currency: currency, rate: rate) let visibleChartValues = self.visibleChartValues let totalSumm: CGFloat = visibleChartValues.map { CGFloat($0.values[pointIndex]) }.reduce(0, +) viewModel.hideAction = { [weak self] in diff --git a/submodules/GraphCore/Sources/Charts/Controllers/Stacked Bars/StackedBarsChartController.swift b/submodules/GraphCore/Sources/Charts/Controllers/Stacked Bars/StackedBarsChartController.swift index 613e946a42e..af304c683fd 100644 --- a/submodules/GraphCore/Sources/Charts/Controllers/Stacked Bars/StackedBarsChartController.swift +++ b/submodules/GraphCore/Sources/Charts/Controllers/Stacked Bars/StackedBarsChartController.swift @@ -28,8 +28,6 @@ public enum GraphCurrency : String { } public class StackedBarsChartController: BaseChartController { - - let barsController: BarsComponentController let zoomedBarsController: BarsComponentController @@ -55,6 +53,7 @@ public class StackedBarsChartController: BaseChartController { secondaryScalesRenderer: secondaryScalesRenderer, previewBarsChartRenderer: BarChartRenderer()) if let currency { + barsController.currency = currency barsController.conversionRate = rate barsController.verticalLimitsNumberFormatter = currency.formatter barsController.detailsNumberFormatter = currency.formatter diff --git a/submodules/GraphCore/Sources/Models/ColorMode.swift b/submodules/GraphCore/Sources/Models/ColorMode.swift index 0cce8000647..94a6ce01868 100644 --- a/submodules/GraphCore/Sources/Models/ColorMode.swift +++ b/submodules/GraphCore/Sources/Models/ColorMode.swift @@ -30,13 +30,31 @@ public protocol ChartThemeContainer { public class ChartStrings { public let zoomOut: String public let total: String + public let revenueInTon: String + public let revenueInStars: String + public let revenueInUsd: String - public init(zoomOut: String, total: String) { + public init( + zoomOut: String, + total: String, + revenueInTon: String, + revenueInStars: String, + revenueInUsd: String + ) { self.zoomOut = zoomOut self.total = total + self.revenueInTon = revenueInTon + self.revenueInStars = revenueInStars + self.revenueInUsd = revenueInUsd } - public static var defaultStrings = ChartStrings(zoomOut: "Zoom Out", total: "Total") + public static var defaultStrings = ChartStrings( + zoomOut: "Zoom Out", + total: "Total", + revenueInTon: "Revenue in TON", + revenueInStars: "Revenue in Stars", + revenueInUsd: "Revenue in USD" + ) } public class ChartTheme { diff --git a/submodules/InAppPurchaseManager/Sources/InAppPurchaseManager.swift b/submodules/InAppPurchaseManager/Sources/InAppPurchaseManager.swift index 1b26d6921e9..955a03d7645 100644 --- a/submodules/InAppPurchaseManager/Sources/InAppPurchaseManager.swift +++ b/submodules/InAppPurchaseManager/Sources/InAppPurchaseManager.swift @@ -622,6 +622,7 @@ private final class PendingInAppPurchaseState: Codable { case randomId case untilDate case stars + case users } enum PurposeType: Int32 { @@ -633,6 +634,7 @@ private final class PendingInAppPurchaseState: Codable { case giveaway case stars case starsGift + case starsGiveaway } case subscription @@ -643,6 +645,7 @@ private final class PendingInAppPurchaseState: Codable { case giveaway(boostPeer: EnginePeer.Id, additionalPeerIds: [EnginePeer.Id], countries: [String], onlyNewSubscribers: Bool, showWinners: Bool, prizeDescription: String?, randomId: Int64, untilDate: Int32) case stars(count: Int64) case starsGift(peerId: EnginePeer.Id, count: Int64) + case starsGiveaway(stars: Int64, boostPeer: EnginePeer.Id, additionalPeerIds: [EnginePeer.Id], countries: [String], onlyNewSubscribers: Bool, showWinners: Bool, prizeDescription: String?, randomId: Int64, untilDate: Int32, users: Int32) public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) @@ -684,6 +687,19 @@ private final class PendingInAppPurchaseState: Codable { peerId: EnginePeer.Id(try container.decode(Int64.self, forKey: .peer)), count: try container.decode(Int64.self, forKey: .stars) ) + case .starsGiveaway: + self = .starsGiveaway( + stars: try container.decode(Int64.self, forKey: .stars), + boostPeer: EnginePeer.Id(try container.decode(Int64.self, forKey: .boostPeer)), + additionalPeerIds: try container.decode([Int64].self, forKey: .randomId).map { EnginePeer.Id($0) }, + countries: try container.decodeIfPresent([String].self, forKey: .countries) ?? [], + onlyNewSubscribers: try container.decode(Bool.self, forKey: .onlyNewSubscribers), + showWinners: try container.decodeIfPresent(Bool.self, forKey: .showWinners) ?? false, + prizeDescription: try container.decodeIfPresent(String.self, forKey: .prizeDescription), + randomId: try container.decode(Int64.self, forKey: .randomId), + untilDate: try container.decode(Int32.self, forKey: .untilDate), + users: try container.decode(Int32.self, forKey: .users) + ) default: throw DecodingError.generic } @@ -723,6 +739,18 @@ private final class PendingInAppPurchaseState: Codable { try container.encode(PurposeType.starsGift.rawValue, forKey: .type) try container.encode(peerId.toInt64(), forKey: .peer) try container.encode(count, forKey: .stars) + case let .starsGiveaway(stars, boostPeer, additionalPeerIds, countries, onlyNewSubscribers, showWinners, prizeDescription, randomId, untilDate, users): + try container.encode(PurposeType.starsGiveaway.rawValue, forKey: .type) + try container.encode(stars, forKey: .stars) + try container.encode(boostPeer.toInt64(), forKey: .boostPeer) + try container.encode(additionalPeerIds.map { $0.toInt64() }, forKey: .additionalPeerIds) + try container.encode(countries, forKey: .countries) + try container.encode(onlyNewSubscribers, forKey: .onlyNewSubscribers) + try container.encode(showWinners, forKey: .showWinners) + try container.encodeIfPresent(prizeDescription, forKey: .prizeDescription) + try container.encode(randomId, forKey: .randomId) + try container.encode(untilDate, forKey: .untilDate) + try container.encode(users, forKey: .users) } } @@ -744,6 +772,8 @@ private final class PendingInAppPurchaseState: Codable { self = .stars(count: count) case let .starsGift(peerId, count, _, _): self = .starsGift(peerId: peerId, count: count) + case let .starsGiveaway(stars, boostPeer, additionalPeerIds, countries, onlyNewSubscribers, showWinners, prizeDescription, randomId, untilDate, _, _, users): + self = .starsGiveaway(stars: stars, boostPeer: boostPeer, additionalPeerIds: additionalPeerIds, countries: countries, onlyNewSubscribers: onlyNewSubscribers, showWinners: showWinners, prizeDescription: prizeDescription, randomId: randomId, untilDate: untilDate, users: users) } } @@ -766,6 +796,8 @@ private final class PendingInAppPurchaseState: Codable { return .stars(count: count, currency: currency, amount: amount) case let .starsGift(peerId, count): return .starsGift(peerId: peerId, count: count, currency: currency, amount: amount) + case let .starsGiveaway(stars, boostPeer, additionalPeerIds, countries, onlyNewSubscribers, showWinners, prizeDescription, randomId, untilDate, users): + return .starsGiveaway(stars: stars, boostPeer: boostPeer, additionalPeerIds: additionalPeerIds, countries: countries, onlyNewSubscribers: onlyNewSubscribers, showWinners: showWinners, prizeDescription: prizeDescription, randomId: randomId, untilDate: untilDate, currency: currency, amount: amount, users: users) } } } diff --git a/submodules/InstantPageUI/BUILD b/submodules/InstantPageUI/BUILD index 5d75f41cbad..488473b51b4 100644 --- a/submodules/InstantPageUI/BUILD +++ b/submodules/InstantPageUI/BUILD @@ -26,6 +26,7 @@ swift_library( "//submodules/LocationResources:LocationResources", "//submodules/UndoUI:UndoUI", "//submodules/TranslateUI:TranslateUI", + "//submodules/Tuples:Tuples", ], visibility = [ "//visibility:public", diff --git a/submodules/InstantPageUI/Sources/InstantImageGalleryItem.swift b/submodules/InstantPageUI/Sources/InstantImageGalleryItem.swift index d397279f8a5..1ed0820c7be 100644 --- a/submodules/InstantPageUI/Sources/InstantImageGalleryItem.swift +++ b/submodules/InstantPageUI/Sources/InstantImageGalleryItem.swift @@ -9,6 +9,7 @@ import TelegramPresentationData import AccountContext import PhotoResources import GalleryUI +import Tuples private struct InstantImageGalleryThumbnailItem: GalleryThumbnailItem { let account: Account @@ -50,8 +51,9 @@ class InstantImageGalleryItem: GalleryItem { let location: InstantPageGalleryEntryLocation? let openUrl: (InstantPageUrlItem) -> Void let openUrlOptions: (InstantPageUrlItem) -> Void + let getPreloadedResource: (String) -> Data? - init(context: AccountContext, presentationData: PresentationData, itemId: AnyHashable, userLocation: MediaResourceUserLocation, imageReference: ImageMediaReference, caption: NSAttributedString, credit: NSAttributedString, location: InstantPageGalleryEntryLocation?, openUrl: @escaping (InstantPageUrlItem) -> Void, openUrlOptions: @escaping (InstantPageUrlItem) -> Void) { + init(context: AccountContext, presentationData: PresentationData, itemId: AnyHashable, userLocation: MediaResourceUserLocation, imageReference: ImageMediaReference, caption: NSAttributedString, credit: NSAttributedString, location: InstantPageGalleryEntryLocation?, openUrl: @escaping (InstantPageUrlItem) -> Void, openUrlOptions: @escaping (InstantPageUrlItem) -> Void, getPreloadedResource: @escaping (String) -> Data?) { self.itemId = itemId self.userLocation = userLocation self.context = context @@ -62,10 +64,11 @@ class InstantImageGalleryItem: GalleryItem { self.location = location self.openUrl = openUrl self.openUrlOptions = openUrlOptions + self.getPreloadedResource = getPreloadedResource } func node(synchronous: Bool) -> GalleryItemNode { - let node = InstantImageGalleryItemNode(context: self.context, presentationData: self.presentationData, openUrl: self.openUrl, openUrlOptions: self.openUrlOptions) + let node = InstantImageGalleryItemNode(context: self.context, presentationData: self.presentationData, openUrl: self.openUrl, openUrlOptions: self.openUrlOptions, getPreloadedResource: self.getPreloadedResource) node.setImage(userLocation: self.userLocation, imageReference: self.imageReference) @@ -106,8 +109,11 @@ final class InstantImageGalleryItemNode: ZoomableContentGalleryItemNode { private var fetchDisposable = MetaDisposable() - init(context: AccountContext, presentationData: PresentationData, openUrl: @escaping (InstantPageUrlItem) -> Void, openUrlOptions: @escaping (InstantPageUrlItem) -> Void) { + private var getPreloadedResource: (String) -> Data? + + init(context: AccountContext, presentationData: PresentationData, openUrl: @escaping (InstantPageUrlItem) -> Void, openUrlOptions: @escaping (InstantPageUrlItem) -> Void, getPreloadedResource: @escaping (String) -> Data?) { self.context = context + self.getPreloadedResource = getPreloadedResource self.imageNode = TransformImageNode() self.footerContentNode = InstantPageGalleryFooterContentNode(context: context, presentationData: presentationData) @@ -147,9 +153,38 @@ final class InstantImageGalleryItemNode: ZoomableContentGalleryItemNode { if let largestSize = largestRepresentationForPhoto(imageReference.media) { let displaySize = largestSize.dimensions.cgSize.fitted(CGSize(width: 1280.0, height: 1280.0)).dividedByScreenScale().integralFloor self.imageNode.asyncLayout()(TransformImageArguments(corners: ImageCorners(), imageSize: displaySize, boundingSize: displaySize, intrinsicInsets: UIEdgeInsets(), emptyColor: .black))() - self.imageNode.setSignal(chatMessagePhoto(postbox: self.context.account.postbox, userLocation: userLocation, photoReference: imageReference), dispatchOnDisplayLink: false) self.zoomableContent = (largestSize.dimensions.cgSize, self.imageNode) - self.fetchDisposable.set(fetchedMediaResource(mediaBox: self.context.account.postbox.mediaBox, userLocation: userLocation, userContentType: .image, reference: imageReference.resourceReference(largestSize.resource)).start()) + + if let externalResource = largestSize.resource as? InstantPageExternalMediaResource { + var url = externalResource.url + if !url.hasPrefix("http") && !url.hasPrefix("https") && url.hasPrefix("//") { + url = "https:\(url)" + } + let photoData: Signal, NoError> + if let preloadedData = getPreloadedResource(externalResource.url) { + photoData = .single(Tuple4(nil, preloadedData, .full, true)) + } else { + photoData = self.context.engine.resources.httpData(url: url, preserveExactUrl: true) + |> map(Optional.init) + |> `catch` { _ -> Signal in + return .single(nil) + } + |> map { data in + if let data { + return Tuple4(nil, data, .full, true) + } else { + return Tuple4(nil, nil, .full, false) + } + } + } + self.imageNode.setSignal(chatMessagePhotoInternal(photoData: photoData) + |> map { _, _, generate in + return generate + }) + } else { + self.imageNode.setSignal(chatMessagePhoto(postbox: self.context.account.postbox, userLocation: userLocation, photoReference: imageReference), dispatchOnDisplayLink: false) + self.fetchDisposable.set(fetchedMediaResource(mediaBox: self.context.account.postbox.mediaBox, userLocation: userLocation, userContentType: .image, reference: imageReference.resourceReference(largestSize.resource)).start()) + } } else { self._ready.set(.single(Void())) } diff --git a/submodules/InstantPageUI/Sources/InstantPageAnchorItem.swift b/submodules/InstantPageUI/Sources/InstantPageAnchorItem.swift index 3ba3e794a6b..185d9fd4d54 100644 --- a/submodules/InstantPageUI/Sources/InstantPageAnchorItem.swift +++ b/submodules/InstantPageUI/Sources/InstantPageAnchorItem.swift @@ -27,7 +27,7 @@ public final class InstantPageAnchorItem: InstantPageItem { public func drawInTile(context: CGContext) { } - public func node(context: AccountContext, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, theme: InstantPageTheme, sourceLocation: InstantPageSourceLocation, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, openPeer: @escaping (EnginePeer) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void, updateWebEmbedHeight: @escaping (CGFloat) -> Void, updateDetailsExpanded: @escaping (Bool) -> Void, currentExpandedDetails: [Int : Bool]?) -> InstantPageNode? { + public func node(context: AccountContext, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, theme: InstantPageTheme, sourceLocation: InstantPageSourceLocation, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, openPeer: @escaping (EnginePeer) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void, updateWebEmbedHeight: @escaping (CGFloat) -> Void, updateDetailsExpanded: @escaping (Bool) -> Void, currentExpandedDetails: [Int : Bool]?, getPreloadedResource: @escaping (String) -> Data?) -> InstantPageNode? { return nil } diff --git a/submodules/InstantPageUI/Sources/InstantPageArticleItem.swift b/submodules/InstantPageUI/Sources/InstantPageArticleItem.swift index 38d2a817e14..c602f5d9740 100644 --- a/submodules/InstantPageUI/Sources/InstantPageArticleItem.swift +++ b/submodules/InstantPageUI/Sources/InstantPageArticleItem.swift @@ -36,7 +36,7 @@ public final class InstantPageArticleItem: InstantPageItem { self.hasRTL = hasRTL } - public func node(context: AccountContext, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, theme: InstantPageTheme, sourceLocation: InstantPageSourceLocation, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, openPeer: @escaping (EnginePeer) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void, updateWebEmbedHeight: @escaping (CGFloat) -> Void, updateDetailsExpanded: @escaping (Bool) -> Void, currentExpandedDetails: [Int : Bool]?) -> InstantPageNode? { + public func node(context: AccountContext, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, theme: InstantPageTheme, sourceLocation: InstantPageSourceLocation, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, openPeer: @escaping (EnginePeer) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void, updateWebEmbedHeight: @escaping (CGFloat) -> Void, updateDetailsExpanded: @escaping (Bool) -> Void, currentExpandedDetails: [Int : Bool]?, getPreloadedResource: @escaping (String) -> Data?) -> InstantPageNode? { return InstantPageArticleNode(context: context, item: self, webPage: self.webPage, strings: strings, theme: theme, contentItems: self.contentItems, contentSize: self.contentSize, cover: self.cover, url: self.url, webpageId: self.webpageId, openUrl: openUrl) } diff --git a/submodules/InstantPageUI/Sources/InstantPageAudioItem.swift b/submodules/InstantPageUI/Sources/InstantPageAudioItem.swift index b1962eb6844..f007db85305 100644 --- a/submodules/InstantPageUI/Sources/InstantPageAudioItem.swift +++ b/submodules/InstantPageUI/Sources/InstantPageAudioItem.swift @@ -23,7 +23,7 @@ public final class InstantPageAudioItem: InstantPageItem { self.medias = [media] } - public func node(context: AccountContext, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, theme: InstantPageTheme, sourceLocation: InstantPageSourceLocation, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, openPeer: @escaping (EnginePeer) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void, updateWebEmbedHeight: @escaping (CGFloat) -> Void, updateDetailsExpanded: @escaping (Bool) -> Void, currentExpandedDetails: [Int : Bool]?) -> InstantPageNode? { + public func node(context: AccountContext, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, theme: InstantPageTheme, sourceLocation: InstantPageSourceLocation, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, openPeer: @escaping (EnginePeer) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void, updateWebEmbedHeight: @escaping (CGFloat) -> Void, updateDetailsExpanded: @escaping (Bool) -> Void, currentExpandedDetails: [Int : Bool]?, getPreloadedResource: @escaping (String) -> Data?) -> InstantPageNode? { return InstantPageAudioNode(context: context, strings: strings, theme: theme, webPage: self.webpage, media: self.media, openMedia: openMedia) } diff --git a/submodules/InstantPageUI/Sources/InstantPageContentNode.swift b/submodules/InstantPageUI/Sources/InstantPageContentNode.swift index cf0c2aafbd9..71505c13ee6 100644 --- a/submodules/InstantPageUI/Sources/InstantPageContentNode.swift +++ b/submodules/InstantPageUI/Sources/InstantPageContentNode.swift @@ -22,6 +22,7 @@ public final class InstantPageContentNode : ASDisplayNode { private let openUrl: (InstantPageUrlItem) -> Void private let activatePinchPreview: ((PinchSourceContainerNode) -> Void)? private let pinchPreviewFinished: ((InstantPageNode) -> Void)? + private let getPreloadedResource: (String) -> Data? var currentLayoutTiles: [InstantPageTile] = [] var currentLayoutItemsWithNodes: [InstantPageItem] = [] @@ -42,7 +43,7 @@ public final class InstantPageContentNode : ASDisplayNode { private var previousVisibleBounds: CGRect? - init(context: AccountContext, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, sourceLocation: InstantPageSourceLocation, theme: InstantPageTheme, items: [InstantPageItem], contentSize: CGSize, inOverlayPanel: Bool = false, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, openPeer: @escaping (EnginePeer) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void) { + init(context: AccountContext, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, sourceLocation: InstantPageSourceLocation, theme: InstantPageTheme, items: [InstantPageItem], contentSize: CGSize, inOverlayPanel: Bool = false, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, openPeer: @escaping (EnginePeer) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void, getPreloadedResource: @escaping (String) -> Data?) { self.context = context self.strings = strings self.nameDisplayOrder = nameDisplayOrder @@ -55,6 +56,7 @@ public final class InstantPageContentNode : ASDisplayNode { self.pinchPreviewFinished = pinchPreviewFinished self.openPeer = openPeer self.openUrl = openUrl + self.getPreloadedResource = getPreloadedResource self.currentLayout = InstantPageLayout(origin: CGPoint(), contentSize: contentSize, items: items) self.contentSize = contentSize @@ -210,7 +212,7 @@ public final class InstantPageContentNode : ASDisplayNode { }, updateWebEmbedHeight: { _ in }, updateDetailsExpanded: { [weak self] expanded in self?.updateDetailsExpanded(detailsIndex, expanded) - }, currentExpandedDetails: self.currentExpandedDetails) { + }, currentExpandedDetails: self.currentExpandedDetails, getPreloadedResource: self.getPreloadedResource) { newNode.frame = itemFrame newNode.updateLayout(size: itemFrame.size, transition: transition) if let topNode = topNode { diff --git a/submodules/InstantPageUI/Sources/InstantPageControllerNode.swift b/submodules/InstantPageUI/Sources/InstantPageControllerNode.swift index e2e9a15e1e9..b5a98b2eefc 100644 --- a/submodules/InstantPageUI/Sources/InstantPageControllerNode.swift +++ b/submodules/InstantPageUI/Sources/InstantPageControllerNode.swift @@ -649,7 +649,7 @@ final class InstantPageControllerNode: ASDisplayNode, ASScrollViewDelegate { self?.updateWebEmbedHeight(embedIndex, height) }, updateDetailsExpanded: { [weak self] expanded in self?.updateDetailsExpanded(detailsIndex, expanded) - }, currentExpandedDetails: self.currentExpandedDetails) { + }, currentExpandedDetails: self.currentExpandedDetails, getPreloadedResource: { _ in return nil }) { newNode.frame = itemFrame newNode.updateLayout(size: itemFrame.size, transition: transition) if let topNode = topNode { diff --git a/submodules/InstantPageUI/Sources/InstantPageDetailsItem.swift b/submodules/InstantPageUI/Sources/InstantPageDetailsItem.swift index a590813aa14..cbf3545fd5f 100644 --- a/submodules/InstantPageUI/Sources/InstantPageDetailsItem.swift +++ b/submodules/InstantPageUI/Sources/InstantPageDetailsItem.swift @@ -39,12 +39,12 @@ public final class InstantPageDetailsItem: InstantPageItem { self.index = index } - public func node(context: AccountContext, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, theme: InstantPageTheme, sourceLocation: InstantPageSourceLocation, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, openPeer: @escaping (EnginePeer) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void, updateWebEmbedHeight: @escaping (CGFloat) -> Void, updateDetailsExpanded: @escaping (Bool) -> Void, currentExpandedDetails: [Int : Bool]?) -> InstantPageNode? { + public func node(context: AccountContext, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, theme: InstantPageTheme, sourceLocation: InstantPageSourceLocation, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, openPeer: @escaping (EnginePeer) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void, updateWebEmbedHeight: @escaping (CGFloat) -> Void, updateDetailsExpanded: @escaping (Bool) -> Void, currentExpandedDetails: [Int : Bool]?, getPreloadedResource: @escaping (String) -> Data?) -> InstantPageNode? { var expanded: Bool? if let expandedDetails = currentExpandedDetails, let currentlyExpanded = expandedDetails[self.index] { expanded = currentlyExpanded } - return InstantPageDetailsNode(context: context, sourceLocation: sourceLocation, strings: strings, nameDisplayOrder: nameDisplayOrder, theme: theme, item: self, openMedia: openMedia, longPressMedia: longPressMedia, activatePinchPreview: activatePinchPreview, pinchPreviewFinished: pinchPreviewFinished, openPeer: openPeer, openUrl: openUrl, currentlyExpanded: expanded, updateDetailsExpanded: updateDetailsExpanded) + return InstantPageDetailsNode(context: context, sourceLocation: sourceLocation, strings: strings, nameDisplayOrder: nameDisplayOrder, theme: theme, item: self, openMedia: openMedia, longPressMedia: longPressMedia, activatePinchPreview: activatePinchPreview, pinchPreviewFinished: pinchPreviewFinished, openPeer: openPeer, openUrl: openUrl, currentlyExpanded: expanded, updateDetailsExpanded: updateDetailsExpanded, getPreloadedResource: getPreloadedResource) } public func matchesAnchor(_ anchor: String) -> Bool { diff --git a/submodules/InstantPageUI/Sources/InstantPageDetailsNode.swift b/submodules/InstantPageUI/Sources/InstantPageDetailsNode.swift index db6a1f74454..667213af39d 100644 --- a/submodules/InstantPageUI/Sources/InstantPageDetailsNode.swift +++ b/submodules/InstantPageUI/Sources/InstantPageDetailsNode.swift @@ -35,7 +35,7 @@ public final class InstantPageDetailsNode: ASDisplayNode, InstantPageNode { public var requestLayoutUpdate: ((Bool) -> Void)? - init(context: AccountContext, sourceLocation: InstantPageSourceLocation, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, theme: InstantPageTheme, item: InstantPageDetailsItem, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, openPeer: @escaping (EnginePeer) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void, currentlyExpanded: Bool?, updateDetailsExpanded: @escaping (Bool) -> Void) { + init(context: AccountContext, sourceLocation: InstantPageSourceLocation, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, theme: InstantPageTheme, item: InstantPageDetailsItem, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, openPeer: @escaping (EnginePeer) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void, currentlyExpanded: Bool?, updateDetailsExpanded: @escaping (Bool) -> Void, getPreloadedResource: @escaping (String) -> Data?) { self.context = context self.strings = strings self.nameDisplayOrder = nameDisplayOrder @@ -65,7 +65,7 @@ public final class InstantPageDetailsNode: ASDisplayNode, InstantPageNode { self.arrowNode = InstantPageDetailsArrowNode(color: theme.controlColor, open: self.expanded) self.separatorNode = ASDisplayNode() - self.contentNode = InstantPageContentNode(context: context, strings: strings, nameDisplayOrder: nameDisplayOrder, sourceLocation: sourceLocation, theme: theme, items: item.items, contentSize: CGSize(width: item.frame.width, height: item.frame.height - item.titleHeight), openMedia: openMedia, longPressMedia: longPressMedia, activatePinchPreview: activatePinchPreview, pinchPreviewFinished: pinchPreviewFinished, openPeer: openPeer, openUrl: openUrl) + self.contentNode = InstantPageContentNode(context: context, strings: strings, nameDisplayOrder: nameDisplayOrder, sourceLocation: sourceLocation, theme: theme, items: item.items, contentSize: CGSize(width: item.frame.width, height: item.frame.height - item.titleHeight), openMedia: openMedia, longPressMedia: longPressMedia, activatePinchPreview: activatePinchPreview, pinchPreviewFinished: pinchPreviewFinished, openPeer: openPeer, openUrl: openUrl, getPreloadedResource: getPreloadedResource) super.init() diff --git a/submodules/InstantPageUI/Sources/InstantPageExternalMediaResource.swift b/submodules/InstantPageUI/Sources/InstantPageExternalMediaResource.swift new file mode 100644 index 00000000000..f26d07a0804 --- /dev/null +++ b/submodules/InstantPageUI/Sources/InstantPageExternalMediaResource.swift @@ -0,0 +1,48 @@ +import Foundation +import Postbox +import TelegramCore +import PersistentStringHash + +public struct InstantPageExternalMediaResourceId { + public let url: String + + public var uniqueId: String { + return "instantpage-media-\(persistentHash32(self.url))" + } + + public var hashValue: Int { + return self.uniqueId.hashValue + } +} + +public class InstantPageExternalMediaResource: TelegramMediaResource { + public let url: String + + public var size: Int64? { + return nil + } + + public init(url: String) { + self.url = url + } + + public required init(decoder: PostboxDecoder) { + self.url = decoder.decodeStringForKey("u", orElse: "") + } + + public func encode(_ encoder: PostboxEncoder) { + encoder.encodeString(self.url, forKey: "u") + } + + public var id: MediaResourceId { + return MediaResourceId(InstantPageExternalMediaResourceId(url: self.url).uniqueId) + } + + public func isEqual(to: MediaResource) -> Bool { + if let to = to as? InstantPageExternalMediaResource { + return self.url == to.url + } else { + return false + } + } +} diff --git a/submodules/InstantPageUI/Sources/InstantPageFeedbackItem.swift b/submodules/InstantPageUI/Sources/InstantPageFeedbackItem.swift index 84d23506555..dcbcb72a98e 100644 --- a/submodules/InstantPageUI/Sources/InstantPageFeedbackItem.swift +++ b/submodules/InstantPageUI/Sources/InstantPageFeedbackItem.swift @@ -20,7 +20,7 @@ public final class InstantPageFeedbackItem: InstantPageItem { self.webPage = webPage } - public func node(context: AccountContext, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, theme: InstantPageTheme, sourceLocation: InstantPageSourceLocation, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, openPeer: @escaping (EnginePeer) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void, updateWebEmbedHeight: @escaping (CGFloat) -> Void, updateDetailsExpanded: @escaping (Bool) -> Void, currentExpandedDetails: [Int : Bool]?) -> InstantPageNode? { + public func node(context: AccountContext, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, theme: InstantPageTheme, sourceLocation: InstantPageSourceLocation, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, openPeer: @escaping (EnginePeer) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void, updateWebEmbedHeight: @escaping (CGFloat) -> Void, updateDetailsExpanded: @escaping (Bool) -> Void, currentExpandedDetails: [Int : Bool]?, getPreloadedResource: @escaping (String) -> Data?) -> InstantPageNode? { return InstantPageFeedbackNode(context: context, strings: strings, theme: theme, webPage: self.webPage, openUrl: openUrl) } diff --git a/submodules/InstantPageUI/Sources/InstantPageGalleryController.swift b/submodules/InstantPageUI/Sources/InstantPageGalleryController.swift index c33f7e4da77..2a3040ef386 100644 --- a/submodules/InstantPageUI/Sources/InstantPageGalleryController.swift +++ b/submodules/InstantPageUI/Sources/InstantPageGalleryController.swift @@ -48,7 +48,7 @@ public struct InstantPageGalleryEntry: Equatable { return lhs.index == rhs.index && lhs.pageId == rhs.pageId && lhs.media == rhs.media && lhs.caption == rhs.caption && lhs.credit == rhs.credit && lhs.location == rhs.location } - func item(context: AccountContext, userLocation: MediaResourceUserLocation, webPage: TelegramMediaWebpage, message: Message?, presentationData: PresentationData, fromPlayingVideo: Bool, landscape: Bool, openUrl: @escaping (InstantPageUrlItem) -> Void, openUrlOptions: @escaping (InstantPageUrlItem) -> Void) -> GalleryItem { + func item(context: AccountContext, userLocation: MediaResourceUserLocation, webPage: TelegramMediaWebpage, message: Message?, presentationData: PresentationData, fromPlayingVideo: Bool, landscape: Bool, openUrl: @escaping (InstantPageUrlItem) -> Void, openUrlOptions: @escaping (InstantPageUrlItem) -> Void, getPreloadedResource: @escaping (String) -> Data?) -> GalleryItem { let caption: NSAttributedString let credit: NSAttributedString @@ -97,7 +97,7 @@ public struct InstantPageGalleryEntry: Equatable { } if case let .image(image) = self.media.media { - return InstantImageGalleryItem(context: context, presentationData: presentationData, itemId: self.index, userLocation: userLocation, imageReference: .webPage(webPage: WebpageReference(webPage), media: image), caption: caption, credit: credit, location: self.location, openUrl: openUrl, openUrlOptions: openUrlOptions) + return InstantImageGalleryItem(context: context, presentationData: presentationData, itemId: self.index, userLocation: userLocation, imageReference: .webPage(webPage: WebpageReference(webPage), media: image), caption: caption, credit: credit, location: self.location, openUrl: openUrl, openUrlOptions: openUrlOptions, getPreloadedResource: getPreloadedResource) } else if case let .file(file) = self.media.media { if file.isVideo { var indexData: GalleryItemIndexData? @@ -120,7 +120,7 @@ public struct InstantPageGalleryEntry: Equatable { representations.append(TelegramMediaImageRepresentation(dimensions: dimensions, resource: file.resource, progressiveSizes: [], immediateThumbnailData: nil, hasVideo: false, isPersonal: false)) } let image = TelegramMediaImage(imageId: MediaId(namespace: 0, id: 0), representations: representations, immediateThumbnailData: file.immediateThumbnailData, reference: nil, partialReference: nil, flags: []) - return InstantImageGalleryItem(context: context, presentationData: presentationData, itemId: self.index, userLocation: userLocation, imageReference: .webPage(webPage: WebpageReference(webPage), media: image), caption: caption, credit: credit, location: self.location, openUrl: openUrl, openUrlOptions: openUrlOptions) + return InstantImageGalleryItem(context: context, presentationData: presentationData, itemId: self.index, userLocation: userLocation, imageReference: .webPage(webPage: WebpageReference(webPage), media: image), caption: caption, credit: credit, location: self.location, openUrl: openUrl, openUrlOptions: openUrlOptions, getPreloadedResource: getPreloadedResource) } } else if case let .webpage(embedWebpage) = self.media.media, case let .Loaded(webpageContent) = embedWebpage.content { if webpageContent.url.hasSuffix(".m3u8") { @@ -201,8 +201,9 @@ public class InstantPageGalleryController: ViewController, StandalonePresentable public var openUrl: ((InstantPageUrlItem) -> Void)? private var innerOpenUrl: (InstantPageUrlItem) -> Void private var openUrlOptions: (InstantPageUrlItem) -> Void + private let getPreloadedResource: (String) -> Data? - public init(context: AccountContext, userLocation: MediaResourceUserLocation, webPage: TelegramMediaWebpage, message: Message? = nil, entries: [InstantPageGalleryEntry], centralIndex: Int, fromPlayingVideo: Bool = false, landscape: Bool = false, timecode: Double? = nil, replaceRootController: @escaping (ViewController, Promise?) -> Void, baseNavigationController: NavigationController?) { + public init(context: AccountContext, userLocation: MediaResourceUserLocation, webPage: TelegramMediaWebpage, message: Message? = nil, entries: [InstantPageGalleryEntry], centralIndex: Int, fromPlayingVideo: Bool = false, landscape: Bool = false, timecode: Double? = nil, replaceRootController: @escaping (ViewController, Promise?) -> Void, baseNavigationController: NavigationController?, getPreloadedResource: @escaping (String) -> Data? = { _ in return nil }) { self.context = context self.userLocation = userLocation self.webPage = webPage @@ -211,6 +212,7 @@ public class InstantPageGalleryController: ViewController, StandalonePresentable self.landscape = landscape self.replaceRootController = replaceRootController self.baseNavigationController = baseNavigationController + self.getPreloadedResource = getPreloadedResource self.presentationData = context.sharedContext.currentPresentationData.with { $0 } @@ -238,7 +240,7 @@ public class InstantPageGalleryController: ViewController, StandalonePresentable strongSelf.centralEntryIndex = centralIndex if strongSelf.isViewLoaded { strongSelf.galleryNode.pager.replaceItems(strongSelf.entries.map({ - $0.item(context: context, userLocation: userLocation, webPage: webPage, message: message, presentationData: strongSelf.presentationData, fromPlayingVideo: fromPlayingVideo, landscape: landscape, openUrl: strongSelf.innerOpenUrl, openUrlOptions: strongSelf.openUrlOptions) + $0.item(context: context, userLocation: userLocation, webPage: webPage, message: message, presentationData: strongSelf.presentationData, fromPlayingVideo: fromPlayingVideo, landscape: landscape, openUrl: strongSelf.innerOpenUrl, openUrlOptions: strongSelf.openUrlOptions, getPreloadedResource: strongSelf.getPreloadedResource) }), centralItemIndex: centralIndex) let ready = strongSelf.galleryNode.pager.ready() |> timeout(2.0, queue: Queue.mainQueue(), alternate: .single(Void())) |> afterNext { [weak strongSelf] _ in @@ -402,7 +404,7 @@ public class InstantPageGalleryController: ViewController, StandalonePresentable } self.galleryNode.pager.replaceItems(self.entries.map({ - $0.item(context: self.context, userLocation: self.userLocation, webPage: self.webPage, message: self.message, presentationData: self.presentationData, fromPlayingVideo: self.fromPlayingVideo, landscape: self.landscape, openUrl: self.innerOpenUrl, openUrlOptions: self.openUrlOptions) + $0.item(context: self.context, userLocation: self.userLocation, webPage: self.webPage, message: self.message, presentationData: self.presentationData, fromPlayingVideo: self.fromPlayingVideo, landscape: self.landscape, openUrl: self.innerOpenUrl, openUrlOptions: self.openUrlOptions, getPreloadedResource: self.getPreloadedResource) }), centralItemIndex: self.centralEntryIndex) self.galleryNode.pager.centralItemIndexUpdated = { [weak self] index in diff --git a/submodules/InstantPageUI/Sources/InstantPageImageItem.swift b/submodules/InstantPageUI/Sources/InstantPageImageItem.swift index 86bfdbbfdae..e29fcab2d70 100644 --- a/submodules/InstantPageUI/Sources/InstantPageImageItem.swift +++ b/submodules/InstantPageUI/Sources/InstantPageImageItem.swift @@ -44,8 +44,8 @@ public final class InstantPageImageItem: InstantPageItem { self.fit = fit } - public func node(context: AccountContext, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, theme: InstantPageTheme, sourceLocation: InstantPageSourceLocation, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, openPeer: @escaping (EnginePeer) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void, updateWebEmbedHeight: @escaping (CGFloat) -> Void, updateDetailsExpanded: @escaping (Bool) -> Void, currentExpandedDetails: [Int : Bool]?) -> InstantPageNode? { - return InstantPageImageNode(context: context, sourceLocation: sourceLocation, theme: theme, webPage: self.webPage, media: self.media, attributes: self.attributes, interactive: self.interactive, roundCorners: self.roundCorners, fit: self.fit, openMedia: openMedia, longPressMedia: longPressMedia, activatePinchPreview: activatePinchPreview, pinchPreviewFinished: pinchPreviewFinished) + public func node(context: AccountContext, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, theme: InstantPageTheme, sourceLocation: InstantPageSourceLocation, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, openPeer: @escaping (EnginePeer) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void, updateWebEmbedHeight: @escaping (CGFloat) -> Void, updateDetailsExpanded: @escaping (Bool) -> Void, currentExpandedDetails: [Int : Bool]?, getPreloadedResource: @escaping (String) -> Data?) -> InstantPageNode? { + return InstantPageImageNode(context: context, sourceLocation: sourceLocation, theme: theme, webPage: self.webPage, media: self.media, attributes: self.attributes, interactive: self.interactive, roundCorners: self.roundCorners, fit: self.fit, openMedia: openMedia, longPressMedia: longPressMedia, activatePinchPreview: activatePinchPreview, pinchPreviewFinished: pinchPreviewFinished, getPreloadedResource: getPreloadedResource) } public func matchesAnchor(_ anchor: String) -> Bool { diff --git a/submodules/InstantPageUI/Sources/InstantPageImageNode.swift b/submodules/InstantPageUI/Sources/InstantPageImageNode.swift index 3607bbdff32..76fc85e0fcd 100644 --- a/submodules/InstantPageUI/Sources/InstantPageImageNode.swift +++ b/submodules/InstantPageUI/Sources/InstantPageImageNode.swift @@ -14,6 +14,7 @@ import LiveLocationPositionNode import AppBundle import TelegramUIPreferences import ContextUI +import Tuples private struct FetchControls { let fetch: (Bool) -> Void @@ -48,7 +49,7 @@ final class InstantPageImageNode: ASDisplayNode, InstantPageNode { private var themeUpdated: Bool = false - init(context: AccountContext, sourceLocation: InstantPageSourceLocation, theme: InstantPageTheme, webPage: TelegramMediaWebpage, media: InstantPageMedia, attributes: [InstantPageImageAttribute], interactive: Bool, roundCorners: Bool, fit: Bool, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?) { + init(context: AccountContext, sourceLocation: InstantPageSourceLocation, theme: InstantPageTheme, webPage: TelegramMediaWebpage, media: InstantPageMedia, attributes: [InstantPageImageAttribute], interactive: Bool, roundCorners: Bool, fit: Bool, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, getPreloadedResource: @escaping (String) -> Data?) { self.context = context self.theme = theme self.webPage = webPage @@ -72,51 +73,103 @@ final class InstantPageImageNode: ASDisplayNode, InstantPageNode { self.addSubnode(self.pinchContainerNode) if case let .image(image) = media.media, let largest = largestImageRepresentation(image.representations) { - let imageReference = ImageMediaReference.webPage(webPage: WebpageReference(webPage), media: image) - self.imageNode.setSignal(chatMessagePhoto(postbox: context.account.postbox, userLocation: sourceLocation.userLocation, photoReference: imageReference)) - - if !interactive || shouldDownloadMediaAutomatically(settings: context.sharedContext.currentAutomaticMediaDownloadSettings, peerType: sourceLocation.peerType, networkType: MediaAutoDownloadNetworkType(context.account.immediateNetworkType), authorPeerId: nil, contactsPeerIds: Set(), media: image) { - self.fetchedDisposable.set(chatMessagePhotoInteractiveFetched(context: context, userLocation: sourceLocation.userLocation, photoReference: imageReference, displayAtSize: nil, storeToDownloadsPeerId: nil).start()) - } - - self.fetchControls = FetchControls(fetch: { [weak self] manual in - if let strongSelf = self { - strongSelf.fetchedDisposable.set(chatMessagePhotoInteractiveFetched(context: context, userLocation: sourceLocation.userLocation, photoReference: imageReference, displayAtSize: nil, storeToDownloadsPeerId: nil).start()) + if let externalResource = largest.resource as? InstantPageExternalMediaResource { + var url = externalResource.url + if !url.hasPrefix("http") && !url.hasPrefix("https") && url.hasPrefix("//") { + url = "https:\(url)" } - }, cancel: { - chatMessagePhotoCancelInteractiveFetch(account: context.account, photoReference: imageReference) - }) - - if interactive { - self.statusDisposable.set((context.account.postbox.mediaBox.resourceStatus(largest.resource) |> deliverOnMainQueue).start(next: { [weak self] status in - displayLinkDispatcher.dispatch { - if let strongSelf = self { - strongSelf.fetchStatus = EngineMediaResource.FetchStatus(status) - strongSelf.updateFetchStatus() + let photoData: Signal, NoError> + if let preloadedData = getPreloadedResource(externalResource.url) { + photoData = .single(Tuple4(nil, preloadedData, .full, true)) + } else { + photoData = context.engine.resources.httpData(url: url, preserveExactUrl: true) + |> map(Optional.init) + |> `catch` { _ -> Signal in + return .single(nil) + } + |> map { data in + if let data { + return Tuple4(nil, data, .full, true) + } else { + return Tuple4(nil, nil, .full, false) } } - })) + } + self.imageNode.setSignal(chatMessagePhotoInternal(photoData: photoData) + |> map { _, _, generate in + return generate + }) + } else { + let imageReference = ImageMediaReference.webPage(webPage: WebpageReference(webPage), media: image) + self.imageNode.setSignal(chatMessagePhoto(postbox: context.account.postbox, userLocation: sourceLocation.userLocation, photoReference: imageReference)) - if media.url != nil { - self.linkIconNode.image = UIImage(bundleImageName: "Instant View/ImageLink") - self.pinchContainerNode.contentNode.addSubnode(self.linkIconNode) + if !interactive || shouldDownloadMediaAutomatically(settings: context.sharedContext.currentAutomaticMediaDownloadSettings, peerType: sourceLocation.peerType, networkType: MediaAutoDownloadNetworkType(context.account.immediateNetworkType), authorPeerId: nil, contactsPeerIds: Set(), media: image) { + self.fetchedDisposable.set(chatMessagePhotoInteractiveFetched(context: context, userLocation: sourceLocation.userLocation, photoReference: imageReference, displayAtSize: nil, storeToDownloadsPeerId: nil).start()) + } + + self.fetchControls = FetchControls(fetch: { [weak self] manual in + if let strongSelf = self { + strongSelf.fetchedDisposable.set(chatMessagePhotoInteractiveFetched(context: context, userLocation: sourceLocation.userLocation, photoReference: imageReference, displayAtSize: nil, storeToDownloadsPeerId: nil).start()) + } + }, cancel: { + chatMessagePhotoCancelInteractiveFetch(account: context.account, photoReference: imageReference) + }) + + if interactive { + self.statusDisposable.set((context.account.postbox.mediaBox.resourceStatus(largest.resource) |> deliverOnMainQueue).start(next: { [weak self] status in + displayLinkDispatcher.dispatch { + if let strongSelf = self { + strongSelf.fetchStatus = EngineMediaResource.FetchStatus(status) + strongSelf.updateFetchStatus() + } + } + })) + + if media.url != nil { + self.linkIconNode.image = UIImage(bundleImageName: "Instant View/ImageLink") + self.pinchContainerNode.contentNode.addSubnode(self.linkIconNode) + } + + self.pinchContainerNode.contentNode.addSubnode(self.statusNode) } - - self.pinchContainerNode.contentNode.addSubnode(self.statusNode) } } else if case let .file(file) = media.media { - let fileReference = FileMediaReference.webPage(webPage: WebpageReference(webPage), media: file) - if file.mimeType.hasPrefix("image/") { - if !interactive || shouldDownloadMediaAutomatically(settings: context.sharedContext.currentAutomaticMediaDownloadSettings, peerType: sourceLocation.peerType, networkType: MediaAutoDownloadNetworkType(context.account.immediateNetworkType), authorPeerId: nil, contactsPeerIds: Set(), media: file) { - _ = freeMediaFileInteractiveFetched(account: context.account, userLocation: sourceLocation.userLocation, fileReference: fileReference).start() + if let externalResource = file.resource as? InstantPageExternalMediaResource { + let photoData: Signal, NoError> + if let preloadedData = getPreloadedResource(externalResource.url) { + photoData = .single(Tuple4(nil, preloadedData, .full, true)) + } else { + photoData = context.engine.resources.httpData(url: externalResource.url, preserveExactUrl: true) + |> map(Optional.init) + |> `catch` { _ -> Signal in + return .single(nil) + } + |> map { data in + if let data { + return Tuple4(nil, data, .full, true) + } else { + return Tuple4(nil, nil, .full, false) + } + } } - self.imageNode.setSignal(instantPageImageFile(account: context.account, userLocation: sourceLocation.userLocation, fileReference: fileReference, fetched: true)) + self.imageNode.setSignal(chatMessagePhotoInternal(photoData: photoData) + |> map { _, _, generate in + return generate + }) } else { - self.imageNode.setSignal(chatMessageVideo(postbox: context.account.postbox, userLocation: sourceLocation.userLocation, videoReference: fileReference)) - } - if file.isVideo { - self.statusNode.transitionToState(.play(.white), animated: false, completion: {}) - self.pinchContainerNode.contentNode.addSubnode(self.statusNode) + let fileReference = FileMediaReference.webPage(webPage: WebpageReference(webPage), media: file) + if file.mimeType.hasPrefix("image/") { + if !interactive || shouldDownloadMediaAutomatically(settings: context.sharedContext.currentAutomaticMediaDownloadSettings, peerType: sourceLocation.peerType, networkType: MediaAutoDownloadNetworkType(context.account.immediateNetworkType), authorPeerId: nil, contactsPeerIds: Set(), media: file) { + _ = freeMediaFileInteractiveFetched(account: context.account, userLocation: sourceLocation.userLocation, fileReference: fileReference).start() + } + self.imageNode.setSignal(instantPageImageFile(account: context.account, userLocation: sourceLocation.userLocation, fileReference: fileReference, fetched: true)) + } else { + self.imageNode.setSignal(chatMessageVideo(postbox: context.account.postbox, userLocation: sourceLocation.userLocation, videoReference: fileReference)) + } + if file.isVideo { + self.statusNode.transitionToState(.play(.white), animated: false, completion: {}) + self.pinchContainerNode.contentNode.addSubnode(self.statusNode) + } } } else if case let .geo(map) = media.media { self.addSubnode(self.pinNode) diff --git a/submodules/InstantPageUI/Sources/InstantPageItem.swift b/submodules/InstantPageUI/Sources/InstantPageItem.swift index 9daf839f5cf..c58ff351db5 100644 --- a/submodules/InstantPageUI/Sources/InstantPageItem.swift +++ b/submodules/InstantPageUI/Sources/InstantPageItem.swift @@ -15,7 +15,7 @@ public protocol InstantPageItem { func matchesAnchor(_ anchor: String) -> Bool func drawInTile(context: CGContext) - func node(context: AccountContext, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, theme: InstantPageTheme, sourceLocation: InstantPageSourceLocation, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, openPeer: @escaping (EnginePeer) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void, updateWebEmbedHeight: @escaping (CGFloat) -> Void, updateDetailsExpanded: @escaping (Bool) -> Void, currentExpandedDetails: [Int : Bool]?) -> InstantPageNode? + func node(context: AccountContext, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, theme: InstantPageTheme, sourceLocation: InstantPageSourceLocation, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, openPeer: @escaping (EnginePeer) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void, updateWebEmbedHeight: @escaping (CGFloat) -> Void, updateDetailsExpanded: @escaping (Bool) -> Void, currentExpandedDetails: [Int : Bool]?, getPreloadedResource: @escaping (String) -> Data?) -> InstantPageNode? func matchesNode(_ node: InstantPageNode) -> Bool func linkSelectionRects(at point: CGPoint) -> [CGRect] diff --git a/submodules/InstantPageUI/Sources/InstantPageLayout.swift b/submodules/InstantPageUI/Sources/InstantPageLayout.swift index be4312b6e65..27d11e6dd0d 100644 --- a/submodules/InstantPageUI/Sources/InstantPageLayout.swift +++ b/submodules/InstantPageUI/Sources/InstantPageLayout.swift @@ -884,9 +884,11 @@ public func instantPageLayoutForWebPage(_ webPage: TelegramMediaWebpage, userLoc let closingSpacing = spacingBetweenBlocks(upper: previousBlock, lower: nil) contentSize.height += closingSpacing - let feedbackItem = InstantPageFeedbackItem(frame: CGRect(x: 0.0, y: contentSize.height, width: boundingWidth, height: 40.0), webPage: webPage) - contentSize.height += feedbackItem.frame.height - items.append(feedbackItem) + if webPage.webpageId.id != 0 { + let feedbackItem = InstantPageFeedbackItem(frame: CGRect(x: 0.0, y: contentSize.height, width: boundingWidth, height: 40.0), webPage: webPage) + contentSize.height += feedbackItem.frame.height + items.append(feedbackItem) + } return InstantPageLayout(origin: CGPoint(), contentSize: contentSize, items: items) } diff --git a/submodules/InstantPageUI/Sources/InstantPagePeerReferenceItem.swift b/submodules/InstantPageUI/Sources/InstantPagePeerReferenceItem.swift index a5f7adf60ae..c429d9ca768 100644 --- a/submodules/InstantPageUI/Sources/InstantPagePeerReferenceItem.swift +++ b/submodules/InstantPageUI/Sources/InstantPagePeerReferenceItem.swift @@ -26,7 +26,7 @@ public final class InstantPagePeerReferenceItem: InstantPageItem { self.rtl = rtl } - public func node(context: AccountContext, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, theme: InstantPageTheme, sourceLocation: InstantPageSourceLocation, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, openPeer: @escaping (EnginePeer) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void, updateWebEmbedHeight: @escaping (CGFloat) -> Void, updateDetailsExpanded: @escaping (Bool) -> Void, currentExpandedDetails: [Int : Bool]?) -> InstantPageNode? { + public func node(context: AccountContext, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, theme: InstantPageTheme, sourceLocation: InstantPageSourceLocation, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, openPeer: @escaping (EnginePeer) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void, updateWebEmbedHeight: @escaping (CGFloat) -> Void, updateDetailsExpanded: @escaping (Bool) -> Void, currentExpandedDetails: [Int : Bool]?, getPreloadedResource: @escaping (String) -> Data?) -> InstantPageNode? { return InstantPagePeerReferenceNode(context: context, strings: strings, nameDisplayOrder: nameDisplayOrder, theme: theme, initialPeer: self.initialPeer, safeInset: self.safeInset, transparent: self.transparent, rtl: self.rtl, openPeer: openPeer) } diff --git a/submodules/InstantPageUI/Sources/InstantPagePlayableVideoItem.swift b/submodules/InstantPageUI/Sources/InstantPagePlayableVideoItem.swift index 778b7033059..fdfa50afa8c 100644 --- a/submodules/InstantPageUI/Sources/InstantPagePlayableVideoItem.swift +++ b/submodules/InstantPageUI/Sources/InstantPagePlayableVideoItem.swift @@ -28,7 +28,7 @@ public final class InstantPagePlayableVideoItem: InstantPageItem { self.interactive = interactive } - public func node(context: AccountContext, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, theme: InstantPageTheme, sourceLocation: InstantPageSourceLocation, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, openPeer: @escaping (EnginePeer) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void, updateWebEmbedHeight: @escaping (CGFloat) -> Void, updateDetailsExpanded: @escaping (Bool) -> Void, currentExpandedDetails: [Int : Bool]?) -> InstantPageNode? { + public func node(context: AccountContext, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, theme: InstantPageTheme, sourceLocation: InstantPageSourceLocation, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, openPeer: @escaping (EnginePeer) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void, updateWebEmbedHeight: @escaping (CGFloat) -> Void, updateDetailsExpanded: @escaping (Bool) -> Void, currentExpandedDetails: [Int : Bool]?, getPreloadedResource: @escaping (String) -> Data?) -> InstantPageNode? { return InstantPagePlayableVideoNode(context: context, userLocation: sourceLocation.userLocation, webPage: self.webPage, theme: theme, media: self.media, interactive: self.interactive, openMedia: openMedia) } diff --git a/submodules/InstantPageUI/Sources/InstantPageReferenceControllerNode.swift b/submodules/InstantPageUI/Sources/InstantPageReferenceControllerNode.swift index 9ef8a460f5f..887574d31a8 100644 --- a/submodules/InstantPageUI/Sources/InstantPageReferenceControllerNode.swift +++ b/submodules/InstantPageUI/Sources/InstantPageReferenceControllerNode.swift @@ -203,7 +203,7 @@ class InstantPageReferenceControllerNode: ViewControllerTracingNode, ASScrollVie let sideInset: CGFloat = 16.0 let (_, items, contentSize) = layoutTextItemWithString(self.anchorText, boundingWidth: width - sideInset * 2.0, offset: CGPoint(x: sideInset, y: sideInset), media: media, webpage: self.webPage) - let contentNode = InstantPageContentNode(context: self.context, strings: self.presentationData.strings, nameDisplayOrder: self.presentationData.nameDisplayOrder, sourceLocation: self.sourceLocation, theme: self.theme, items: items, contentSize: CGSize(width: width, height: contentSize.height), inOverlayPanel: true, openMedia: { _ in }, longPressMedia: { _ in }, activatePinchPreview: nil, pinchPreviewFinished: nil, openPeer: { _ in }, openUrl: { _ in }) + let contentNode = InstantPageContentNode(context: self.context, strings: self.presentationData.strings, nameDisplayOrder: self.presentationData.nameDisplayOrder, sourceLocation: self.sourceLocation, theme: self.theme, items: items, contentSize: CGSize(width: width, height: contentSize.height), inOverlayPanel: true, openMedia: { _ in }, longPressMedia: { _ in }, activatePinchPreview: nil, pinchPreviewFinished: nil, openPeer: { _ in }, openUrl: { _ in }, getPreloadedResource: { url in return nil}) transition.updateFrame(node: contentNode, frame: CGRect(origin: CGPoint(x: 0.0, y: titleAreaHeight), size: CGSize(width: width, height: contentSize.height))) self.contentContainerNode.insertSubnode(contentNode, at: 0) self.contentNode = contentNode diff --git a/submodules/InstantPageUI/Sources/InstantPageShapeItem.swift b/submodules/InstantPageUI/Sources/InstantPageShapeItem.swift index 0d463b3d902..c7510a5c96e 100644 --- a/submodules/InstantPageUI/Sources/InstantPageShapeItem.swift +++ b/submodules/InstantPageUI/Sources/InstantPageShapeItem.swift @@ -61,7 +61,7 @@ public final class InstantPageShapeItem: InstantPageItem { return false } - public func node(context: AccountContext, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, theme: InstantPageTheme, sourceLocation: InstantPageSourceLocation, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, openPeer: @escaping (EnginePeer) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void, updateWebEmbedHeight: @escaping (CGFloat) -> Void, updateDetailsExpanded: @escaping (Bool) -> Void, currentExpandedDetails: [Int : Bool]?) -> InstantPageNode? { + public func node(context: AccountContext, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, theme: InstantPageTheme, sourceLocation: InstantPageSourceLocation, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, openPeer: @escaping (EnginePeer) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void, updateWebEmbedHeight: @escaping (CGFloat) -> Void, updateDetailsExpanded: @escaping (Bool) -> Void, currentExpandedDetails: [Int : Bool]?, getPreloadedResource: @escaping (String) -> Data?) -> InstantPageNode? { return nil } diff --git a/submodules/InstantPageUI/Sources/InstantPageSlideshowItem.swift b/submodules/InstantPageUI/Sources/InstantPageSlideshowItem.swift index d812d7e85ed..d9a39cabfc1 100644 --- a/submodules/InstantPageUI/Sources/InstantPageSlideshowItem.swift +++ b/submodules/InstantPageUI/Sources/InstantPageSlideshowItem.swift @@ -20,7 +20,7 @@ final class InstantPageSlideshowItem: InstantPageItem { self.medias = medias } - func node(context: AccountContext, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, theme: InstantPageTheme, sourceLocation: InstantPageSourceLocation, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, openPeer: @escaping (EnginePeer) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void, updateWebEmbedHeight: @escaping (CGFloat) -> Void, updateDetailsExpanded: @escaping (Bool) -> Void, currentExpandedDetails: [Int : Bool]?) -> InstantPageNode? { + func node(context: AccountContext, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, theme: InstantPageTheme, sourceLocation: InstantPageSourceLocation, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, openPeer: @escaping (EnginePeer) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void, updateWebEmbedHeight: @escaping (CGFloat) -> Void, updateDetailsExpanded: @escaping (Bool) -> Void, currentExpandedDetails: [Int : Bool]?, getPreloadedResource: @escaping (String) -> Data?) -> InstantPageNode? { return InstantPageSlideshowNode(context: context, sourceLocation: sourceLocation, theme: theme, webPage: webPage, medias: self.medias, openMedia: openMedia, longPressMedia: longPressMedia, activatePinchPreview: activatePinchPreview, pinchPreviewFinished: pinchPreviewFinished) } diff --git a/submodules/InstantPageUI/Sources/InstantPageSlideshowItemNode.swift b/submodules/InstantPageUI/Sources/InstantPageSlideshowItemNode.swift index 5551d7343b5..6baa616027a 100644 --- a/submodules/InstantPageUI/Sources/InstantPageSlideshowItemNode.swift +++ b/submodules/InstantPageUI/Sources/InstantPageSlideshowItemNode.swift @@ -187,7 +187,7 @@ private final class InstantPageSlideshowPagerNode: ASDisplayNode, ASScrollViewDe let media = self.items[index] let contentNode: ASDisplayNode if case .image = media.media { - contentNode = InstantPageImageNode(context: self.context, sourceLocation: self.sourceLocation, theme: self.theme, webPage: self.webPage, media: media, attributes: [], interactive: true, roundCorners: false, fit: false, openMedia: self.openMedia, longPressMedia: self.longPressMedia, activatePinchPreview: self.activatePinchPreview, pinchPreviewFinished: self.pinchPreviewFinished) + contentNode = InstantPageImageNode(context: self.context, sourceLocation: self.sourceLocation, theme: self.theme, webPage: self.webPage, media: media, attributes: [], interactive: true, roundCorners: false, fit: false, openMedia: self.openMedia, longPressMedia: self.longPressMedia, activatePinchPreview: self.activatePinchPreview, pinchPreviewFinished: self.pinchPreviewFinished, getPreloadedResource: { _ in return nil }) } else if case .file = media.media { contentNode = ASDisplayNode() } else { diff --git a/submodules/InstantPageUI/Sources/InstantPageSubContentNode.swift b/submodules/InstantPageUI/Sources/InstantPageSubContentNode.swift index bc552dc995f..30bdf1c6d95 100644 --- a/submodules/InstantPageUI/Sources/InstantPageSubContentNode.swift +++ b/submodules/InstantPageUI/Sources/InstantPageSubContentNode.swift @@ -198,7 +198,7 @@ final class InstantPageSubContentNode : ASDisplayNode { }, updateWebEmbedHeight: { _ in }, updateDetailsExpanded: { [weak self] expanded in self?.updateDetailsExpanded(detailsIndex, expanded) - }, currentExpandedDetails: self.currentExpandedDetails) { + }, currentExpandedDetails: self.currentExpandedDetails, getPreloadedResource: { _ in return nil }) { newNode.frame = itemFrame newNode.updateLayout(size: itemFrame.size, transition: transition) if let topNode = topNode { diff --git a/submodules/InstantPageUI/Sources/InstantPageTableItem.swift b/submodules/InstantPageUI/Sources/InstantPageTableItem.swift index 01895ba2aca..8be13ed574c 100644 --- a/submodules/InstantPageUI/Sources/InstantPageTableItem.swift +++ b/submodules/InstantPageUI/Sources/InstantPageTableItem.swift @@ -199,12 +199,12 @@ public final class InstantPageTableItem: InstantPageScrollableItem { return false } - public func node(context: AccountContext, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, theme: InstantPageTheme, sourceLocation: InstantPageSourceLocation, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, openPeer: @escaping (EnginePeer) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void, updateWebEmbedHeight: @escaping (CGFloat) -> Void, updateDetailsExpanded: @escaping (Bool) -> Void, currentExpandedDetails: [Int : Bool]?) -> InstantPageNode? { + public func node(context: AccountContext, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, theme: InstantPageTheme, sourceLocation: InstantPageSourceLocation, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, openPeer: @escaping (EnginePeer) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void, updateWebEmbedHeight: @escaping (CGFloat) -> Void, updateDetailsExpanded: @escaping (Bool) -> Void, currentExpandedDetails: [Int : Bool]?, getPreloadedResource: @escaping (String) -> Data?) -> InstantPageNode? { var additionalNodes: [InstantPageNode] = [] for cell in self.cells { for item in cell.additionalItems { if item.wantsNode { - if let node = item.node(context: context, strings: strings, nameDisplayOrder: nameDisplayOrder, theme: theme, sourceLocation: sourceLocation, openMedia: { _ in }, longPressMedia: { _ in }, activatePinchPreview: nil, pinchPreviewFinished: nil, openPeer: { _ in }, openUrl: { _ in}, updateWebEmbedHeight: { _ in }, updateDetailsExpanded: { _ in }, currentExpandedDetails: nil) { + if let node = item.node(context: context, strings: strings, nameDisplayOrder: nameDisplayOrder, theme: theme, sourceLocation: sourceLocation, openMedia: { _ in }, longPressMedia: { _ in }, activatePinchPreview: nil, pinchPreviewFinished: nil, openPeer: { _ in }, openUrl: { _ in}, updateWebEmbedHeight: { _ in }, updateDetailsExpanded: { _ in }, currentExpandedDetails: nil, getPreloadedResource: getPreloadedResource) { node.frame = item.frame.offsetBy(dx: cell.frame.minX, dy: cell.frame.minY) additionalNodes.append(node) } diff --git a/submodules/InstantPageUI/Sources/InstantPageTextItem.swift b/submodules/InstantPageUI/Sources/InstantPageTextItem.swift index af427754de9..1b4c08197c5 100644 --- a/submodules/InstantPageUI/Sources/InstantPageTextItem.swift +++ b/submodules/InstantPageUI/Sources/InstantPageTextItem.swift @@ -435,7 +435,7 @@ public final class InstantPageTextItem: InstantPageItem { return false } - public func node(context: AccountContext, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, theme: InstantPageTheme, sourceLocation: InstantPageSourceLocation, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, openPeer: @escaping (EnginePeer) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void, updateWebEmbedHeight: @escaping (CGFloat) -> Void, updateDetailsExpanded: @escaping (Bool) -> Void, currentExpandedDetails: [Int : Bool]?) -> InstantPageNode? { + public func node(context: AccountContext, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, theme: InstantPageTheme, sourceLocation: InstantPageSourceLocation, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, openPeer: @escaping (EnginePeer) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void, updateWebEmbedHeight: @escaping (CGFloat) -> Void, updateDetailsExpanded: @escaping (Bool) -> Void, currentExpandedDetails: [Int : Bool]?, getPreloadedResource: @escaping (String) -> Data?) -> InstantPageNode? { return nil } @@ -484,11 +484,11 @@ final class InstantPageScrollableTextItem: InstantPageScrollableItem { context.restoreGState() } - func node(context: AccountContext, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, theme: InstantPageTheme, sourceLocation: InstantPageSourceLocation, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, openPeer: @escaping (EnginePeer) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void, updateWebEmbedHeight: @escaping (CGFloat) -> Void, updateDetailsExpanded: @escaping (Bool) -> Void, currentExpandedDetails: [Int : Bool]?) -> InstantPageNode? { + func node(context: AccountContext, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, theme: InstantPageTheme, sourceLocation: InstantPageSourceLocation, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, openPeer: @escaping (EnginePeer) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void, updateWebEmbedHeight: @escaping (CGFloat) -> Void, updateDetailsExpanded: @escaping (Bool) -> Void, currentExpandedDetails: [Int : Bool]?, getPreloadedResource: @escaping (String) -> Data?) -> InstantPageNode? { var additionalNodes: [InstantPageNode] = [] for item in additionalItems { if item.wantsNode { - if let node = item.node(context: context, strings: strings, nameDisplayOrder: nameDisplayOrder, theme: theme, sourceLocation: sourceLocation, openMedia: { _ in }, longPressMedia: { _ in }, activatePinchPreview: nil, pinchPreviewFinished: nil, openPeer: { _ in }, openUrl: { _ in}, updateWebEmbedHeight: { _ in }, updateDetailsExpanded: { _ in }, currentExpandedDetails: nil) { + if let node = item.node(context: context, strings: strings, nameDisplayOrder: nameDisplayOrder, theme: theme, sourceLocation: sourceLocation, openMedia: { _ in }, longPressMedia: { _ in }, activatePinchPreview: nil, pinchPreviewFinished: nil, openPeer: { _ in }, openUrl: { _ in}, updateWebEmbedHeight: { _ in }, updateDetailsExpanded: { _ in }, currentExpandedDetails: nil, getPreloadedResource: getPreloadedResource) { node.frame = item.frame additionalNodes.append(node) } @@ -753,7 +753,7 @@ func layoutTextItemWithString(_ string: NSAttributedString, boundingWidth: CGFlo let runRange = NSMakeRange(cfRunRange.location == kCFNotFound ? NSNotFound : cfRunRange.location, cfRunRange.length) string.enumerateAttributes(in: runRange, options: []) { attributes, range, _ in if let id = attributes[NSAttributedString.Key.init(rawValue: InstantPageMediaIdAttribute)] as? Int64, let dimensions = attributes[NSAttributedString.Key.init(rawValue: InstantPageMediaDimensionsAttribute)] as? PixelDimensions { - var imageFrame = CGRect(origin: CGPoint(), size: dimensions.cgSize) + var imageFrame = CGRect(origin: CGPoint(), size: dimensions.cgSize.fitted(CGSize(width: boundingWidth, height: boundingWidth))) let xOffset = CTLineGetOffsetForStringIndex(line, CTRunGetStringRange(run).location, nil) let yOffset = fontLineHeight.isZero ? 0.0 : floorToScreenPixels((fontLineHeight - imageFrame.size.height) / 2.0) @@ -895,8 +895,7 @@ func layoutTextItemWithString(_ string: NSAttributedString, boundingWidth: CGFlo for var item in additionalItems { item.frame = item.frame.offsetBy(dx: 0.0, dy: abs(topInset)) } - - let scrollableItem = InstantPageScrollableTextItem(frame: CGRect(x: 0.0, y: 0.0, width: boundingWidth + horizontalInset * 2.0, height: height + abs(topInset) + bottomInset), item: textItem, additionalItems: additionalItems, totalWidth: textWidth, horizontalInset: horizontalInset, rtl: textItem.containsRTL) + let scrollableItem = InstantPageScrollableTextItem(frame: CGRect(origin: offset, size: CGSize(width: boundingWidth + horizontalInset * 2.0, height: height + abs(topInset) + bottomInset)), item: textItem, additionalItems: additionalItems, totalWidth: textWidth, horizontalInset: horizontalInset, rtl: textItem.containsRTL) items.append(scrollableItem) } else { items.append(contentsOf: additionalItems) diff --git a/submodules/InstantPageUI/Sources/InstantPageTextStyleStack.swift b/submodules/InstantPageUI/Sources/InstantPageTextStyleStack.swift index 335b66684f5..413ff558401 100644 --- a/submodules/InstantPageUI/Sources/InstantPageTextStyleStack.swift +++ b/submodules/InstantPageUI/Sources/InstantPageTextStyleStack.swift @@ -100,12 +100,12 @@ final class InstantPageTextStyleStack { } case .subscript: if baselineOffset == nil { - baselineOffset = 0.35 + baselineOffset = -0.35 underline = false } case .superscript: if baselineOffset == nil { - baselineOffset = -0.35 + baselineOffset = 0.35 } case let .markerColor(color): if markerColor == nil { diff --git a/submodules/InstantPageUI/Sources/InstantPageWebEmbedItem.swift b/submodules/InstantPageUI/Sources/InstantPageWebEmbedItem.swift index b8863934f5f..ef16f96043e 100644 --- a/submodules/InstantPageUI/Sources/InstantPageWebEmbedItem.swift +++ b/submodules/InstantPageUI/Sources/InstantPageWebEmbedItem.swift @@ -24,7 +24,7 @@ public final class InstantPageWebEmbedItem: InstantPageItem { self.enableScrolling = enableScrolling } - public func node(context: AccountContext, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, theme: InstantPageTheme, sourceLocation: InstantPageSourceLocation, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, openPeer: @escaping (EnginePeer) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void, updateWebEmbedHeight: @escaping (CGFloat) -> Void, updateDetailsExpanded: @escaping (Bool) -> Void, currentExpandedDetails: [Int : Bool]?) -> InstantPageNode? { + public func node(context: AccountContext, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, theme: InstantPageTheme, sourceLocation: InstantPageSourceLocation, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, openPeer: @escaping (EnginePeer) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void, updateWebEmbedHeight: @escaping (CGFloat) -> Void, updateDetailsExpanded: @escaping (Bool) -> Void, currentExpandedDetails: [Int : Bool]?, getPreloadedResource: @escaping (String) -> Data?) -> InstantPageNode? { return InstantPageWebEmbedNode(frame: self.frame, url: self.url, html: self.html, enableScrolling: self.enableScrolling, updateWebEmbedHeight: updateWebEmbedHeight) } diff --git a/submodules/InviteLinksUI/Sources/InviteLinkViewController.swift b/submodules/InviteLinksUI/Sources/InviteLinkViewController.swift index 442350e6419..c120c2c9d70 100644 --- a/submodules/InviteLinksUI/Sources/InviteLinkViewController.swift +++ b/submodules/InviteLinksUI/Sources/InviteLinkViewController.swift @@ -268,7 +268,7 @@ private enum InviteLinkViewEntry: Comparable, Identifiable { } else { interaction.shareLink(invite) } - }, contextAction: { node, gesture in + }, contextAction: invite.link?.hasSuffix("...") == true ? nil : { node, gesture in interaction.contextAction(invite, node, gesture) }, viewAction: { }) diff --git a/submodules/LocationUI/Sources/LocationMapHeaderNode.swift b/submodules/LocationUI/Sources/LocationMapHeaderNode.swift index 8ed12e8b16f..8c72f2fa9a6 100644 --- a/submodules/LocationUI/Sources/LocationMapHeaderNode.swift +++ b/submodules/LocationUI/Sources/LocationMapHeaderNode.swift @@ -183,7 +183,7 @@ public final class LocationMapHeaderNode: ASDisplayNode { let mapHeight: CGFloat = floor(layout.size.height * 1.3) + layout.intrinsicInsets.top * 2.0 let mapFrame = CGRect(x: 0.0, y: floorToScreenPixels((size.height - mapHeight + navigationBarHeight) / 2.0) + offset + floor(layout.intrinsicInsets.top * 0.5), width: size.width, height: mapHeight) transition.updateFrame(node: self.mapNode, frame: mapFrame) - self.mapNode.updateLayout(size: mapFrame.size, topPadding: 0.0) + self.mapNode.updateLayout(size: mapFrame.size, topPadding: topPadding, inset: mapFrame.origin.y * -1.0 + navigationBarHeight, transition: transition) let inset: CGFloat = 6.0 diff --git a/submodules/LocationUI/Sources/LocationMapNode.swift b/submodules/LocationUI/Sources/LocationMapNode.swift index 9025e079fd7..32d0029ac98 100644 --- a/submodules/LocationUI/Sources/LocationMapNode.swift +++ b/submodules/LocationUI/Sources/LocationMapNode.swift @@ -219,6 +219,7 @@ public final class LocationMapNode: ASDisplayNode, MKMapViewDelegateTarget { private let pickerAnnotationContainerView: PickerAnnotationContainerView private weak var userLocationAnnotationView: MKAnnotationView? private var headingArrowView: UIImageView? + private var compassView: MKCompassButton? private weak var defaultUserLocationAnnotation: MKAnnotation? @@ -301,11 +302,15 @@ public final class LocationMapNode: ASDisplayNode, MKMapViewDelegateTarget { override public func didLoad() { super.didLoad() + guard let mapView = self.mapView else { + return + } + self.headingArrowView = UIImageView() self.headingArrowView?.frame = CGRect(origin: CGPoint(), size: CGSize(width: 88.0, height: 88.0)) self.headingArrowView?.image = generateHeadingArrowImage() - self.mapView?.interactiveTransitionGestureRecognizerTest = { p in + mapView.interactiveTransitionGestureRecognizerTest = { p in if p.x > 44.0 { return true } else { @@ -313,19 +318,25 @@ public final class LocationMapNode: ASDisplayNode, MKMapViewDelegateTarget { } } - self.mapView?.disablesInteractiveTransitionGestureRecognizerNow = { [weak self] in + mapView.disablesInteractiveTransitionGestureRecognizerNow = { [weak self] in return self?.disableHorizontalTransitionGesture == true } let delegateImpl = MKMapViewDelegateImpl(target: self) self.delegateImpl = delegateImpl - self.mapView?.delegate = delegateImpl - self.mapView?.mapType = self.mapMode.mapType - self.mapView?.isRotateEnabled = self.isRotateEnabled - self.mapView?.showsUserLocation = true - self.mapView?.showsPointsOfInterest = false - self.mapView?.customHitTest = { [weak self] point in + let compassView = MKCompassButton(mapView: mapView) + compassView.isUserInteractionEnabled = true + self.compassView = compassView + mapView.addSubview(compassView) + + mapView.delegate = delegateImpl + mapView.mapType = self.mapMode.mapType + mapView.isRotateEnabled = self.isRotateEnabled + mapView.showsUserLocation = true + mapView.showsPointsOfInterest = false + mapView.showsCompass = false + mapView.customHitTest = { [weak self] point in guard let strongSelf = self else { return false } @@ -826,7 +837,7 @@ public final class LocationMapNode: ASDisplayNode, MKMapViewDelegateTarget { } } - public func updateLayout(size: CGSize, topPadding: CGFloat) { + public func updateLayout(size: CGSize, topPadding: CGFloat, inset: CGFloat, transition: ContainedViewLayoutTransition) { self.hasValidLayout = true self.topPadding = topPadding @@ -837,6 +848,10 @@ public final class LocationMapNode: ASDisplayNode, MKMapViewDelegateTarget { pickerAnnotationView.center = CGPoint(x: self.pickerAnnotationContainerView.frame.width / 2.0, y: self.pickerAnnotationContainerView.frame.height / 2.0) } + if let compassView = self.compassView { + transition.updateFrame(view: compassView, frame: CGRect(origin: CGPoint(x: size.width - compassView.frame.width - 11.0, y: inset + 110.0 + topPadding), size: compassView.frame.size)) + } + self.applyPendingSetMapCenter() } } diff --git a/submodules/LocationUI/Sources/LocationPickerControllerNode.swift b/submodules/LocationUI/Sources/LocationPickerControllerNode.swift index 77df9cf0d44..f34b5e95271 100644 --- a/submodules/LocationUI/Sources/LocationPickerControllerNode.swift +++ b/submodules/LocationUI/Sources/LocationPickerControllerNode.swift @@ -266,6 +266,7 @@ struct LocationPickerState { var mapMode: LocationMapMode var displayingMapModeOptions: Bool var selectedLocation: LocationPickerLocation + var appxCoordinate: CLLocationCoordinate2D? var geoAddress: MapGeoAddress? var city: String? var street: String? @@ -279,6 +280,7 @@ struct LocationPickerState { self.mapMode = .map self.displayingMapModeOptions = false self.selectedLocation = .none + self.appxCoordinate = nil self.geoAddress = nil self.city = nil self.street = nil @@ -636,7 +638,7 @@ final class LocationPickerControllerNode: ViewControllerTracingNode, CLLocationM let title: String var coordinate = userLocation?.coordinate switch strongSelf.mode { - case .share: + case .share: if source == .story { if let initialLocation = strongSelf.controller?.initialLocation { title = presentationData.strings.Location_AddThisLocation @@ -647,12 +649,24 @@ final class LocationPickerControllerNode: ViewControllerTracingNode, CLLocationM } else { title = presentationData.strings.Map_SendMyCurrentLocation } - case .pick: - title = presentationData.strings.Map_SetThisLocation + case .pick: + title = presentationData.strings.Map_SetThisLocation } if source == .story { if state.city != "" { - entries.append(.city(presentationData.theme, state.city ?? presentationData.strings.Map_Locating, presentationData.strings.Location_TypeCity, nil, nil, nil, coordinate, state.city, state.geoAddress)) + let title: String + let name: String? + let geoAddress: MapGeoAddress? + if let city = state.city, let _ = state.appxCoordinate { + title = city + name = city + geoAddress = state.geoAddress + } else { + title = presentationData.strings.Map_Locating + name = nil + geoAddress = nil + } + entries.append(.city(presentationData.theme, title, presentationData.strings.Location_TypeCity, nil, nil, nil, state.appxCoordinate, name, geoAddress)) } if state.street != "" { entries.append(.location(presentationData.theme, state.street ?? presentationData.strings.Map_Locating, state.isStreet ? presentationData.strings.Location_TypeStreet : presentationData.strings.Location_TypeLocation, nil, nil, nil, coordinate, state.street, state.geoAddress, false)) @@ -801,13 +815,44 @@ final class LocationPickerControllerNode: ViewControllerTracingNode, CLLocationM let locale = localeWithStrings(presentationData.strings) let enLocale = Locale(identifier: "en-US") - let setupGeocoding: (CLLocationCoordinate2D, @escaping (MapGeoAddress?, String, String?, String?, String?, Bool) -> Void) -> Void = { coordinate, completion in + let setupGeocoding: (CLLocationCoordinate2D, Bool, @escaping (MapGeoAddress?, CLLocationCoordinate2D?, String, String?, String?, String?, Bool) -> Void) -> Void = { coordinate, current, completion in strongSelf.geocodingDisposable.set( combineLatest( queue: Queue.mainQueue(), reverseGeocodeLocation(latitude: coordinate.latitude, longitude: coordinate.longitude, locale: locale), reverseGeocodeLocation(latitude: coordinate.latitude, longitude: coordinate.longitude, locale: enLocale) - ).start(next: { placemark, enPlacemark in + |> mapToSignal { placemark -> Signal<(ReverseGeocodedPlacemark, CLLocationCoordinate2D)?, NoError> in + guard let placemark else { + return .single(nil) + } + if current { + var cityName: String + if let city = placemark.city { + if let countryCode = placemark.countryCode { + cityName = "\(city), \(displayCountryName(countryCode, locale: locale))" + } else { + cityName = city + } + } else { + cityName = "" + } + if !cityName.isEmpty { + return geocodeLocation(address: cityName, locale: enLocale) + |> map { placemarks in + if let location = placemarks?.first(where: { $0.thoroughfare == nil })?.location { + return (placemark, location.coordinate) + } else { + return (placemark, coordinate) + } + } + } else { + return .single((placemark, coordinate)) + } + } else { + return .single((placemark, coordinate)) + } + } + ).start(next: { placemark, enPlacemarkAndAppCoordinate in var address = placemark?.fullAddress ?? "" if address.isEmpty { address = presentationData.strings.Map_Unknown @@ -842,16 +887,24 @@ final class LocationPickerControllerNode: ViewControllerTracingNode, CLLocationM } var mapGeoAddress: MapGeoAddress? - if let countryCode, let enPlacemark { + if let countryCode, let enPlacemark = enPlacemarkAndAppCoordinate?.0 { mapGeoAddress = MapGeoAddress(country: countryCode, state: enPlacemark.state, city: enPlacemark.city, street: enPlacemark.street) } - completion(mapGeoAddress, address, cityName, streetName, countryCode, placemark?.street != nil) + var resolvedAppxCoordinate: CLLocationCoordinate2D? + if current, let appxCoordinate = enPlacemarkAndAppCoordinate?.1 { + let loc = CLLocation(latitude: coordinate.latitude, longitude: coordinate.longitude) + let appxLoc = CLLocation(latitude: appxCoordinate.latitude, longitude: appxCoordinate.longitude) + if appxLoc.distance(from: loc) < 1000000 { + resolvedAppxCoordinate = appxCoordinate + } + } + completion(mapGeoAddress, resolvedAppxCoordinate, address, cityName, streetName, countryCode, placemark?.street != nil) } )) } if case let .location(coordinate, address, global) = state.selectedLocation, address == nil { - setupGeocoding(coordinate, { [weak self] geoAddress, address, cityName, streetName, countryCode, isStreet in + setupGeocoding(coordinate, false, { [weak self] geoAddress, _, address, cityName, streetName, countryCode, isStreet in self?.updateState { state in var state = state state.selectedLocation = .location(coordinate, address, global) @@ -866,10 +919,11 @@ final class LocationPickerControllerNode: ViewControllerTracingNode, CLLocationM } else { let coordinate = controller.initialLocation ?? userLocation?.coordinate if case .none = state.selectedLocation, let coordinate, state.city == nil { - setupGeocoding(coordinate, { [weak self] geoAddress, address, cityName, streetName, countryCode, isStreet in + setupGeocoding(coordinate, true, { [weak self] geoAddress, appxCoordinate, address, cityName, streetName, countryCode, isStreet in self?.updateState { state in var state = state state.geoAddress = geoAddress + state.appxCoordinate = appxCoordinate state.city = cityName state.street = streetName state.countryCode = countryCode diff --git a/submodules/LocationUI/Sources/LocationViewControllerNode.swift b/submodules/LocationUI/Sources/LocationViewControllerNode.swift index 8a5ebd03ece..d8593c0b55b 100644 --- a/submodules/LocationUI/Sources/LocationViewControllerNode.swift +++ b/submodules/LocationUI/Sources/LocationViewControllerNode.swift @@ -321,7 +321,7 @@ final class LocationViewControllerNode: ViewControllerTracingNode, CLLocationMan self.headerNode = LocationMapHeaderNode(presentationData: presentationData, toggleMapModeSelection: interaction.toggleMapModeSelection, goToUserLocation: interaction.toggleTrackingMode, setupProximityNotification: { reset in setupProximityNotificationImpl?(reset) }) - self.headerNode.mapNode.isRotateEnabled = false + //self.headerNode.mapNode.isRotateEnabled = false self.optionsNode = LocationOptionsNode(presentationData: presentationData, updateMapMode: interaction.updateMapMode) diff --git a/submodules/MediaPickerUI/Sources/MediaPickerScreen.swift b/submodules/MediaPickerUI/Sources/MediaPickerScreen.swift index 4a63f4bb802..9dd0894d74d 100644 --- a/submodules/MediaPickerUI/Sources/MediaPickerScreen.swift +++ b/submodules/MediaPickerUI/Sources/MediaPickerScreen.swift @@ -2853,6 +2853,20 @@ final class MediaPickerContext: AttachmentMediaPickerContext { } } + var hasTimers: Bool { + guard let controller = self.controller else { + return false + } + if let selectionContext = controller.interaction?.selectionState, let editingContext = controller.interaction?.editingState { + for case let item as TGMediaEditableItem in selectionContext.selectedItems() { + if let time = editingContext.timer(for: item), time.intValue > 0 { + return true + } + } + } + return false + } + var captionIsAboveMedia: Signal { return Signal { [weak self] subscriber in guard let interaction = self?.controller?.interaction else { @@ -3027,7 +3041,7 @@ public func wallpaperMediaPickerController( controller.animateAppearance = animateAppearance controller.requestController = { [weak controller] _, present in let presentationData = context.sharedContext.currentPresentationData.with { $0 } - let mediaPickerController = MediaPickerScreen(context: context, updatedPresentationData: updatedPresentationData, peer: nil, threadTitle: nil, chatLocation: nil, bannedSendPhotos: nil, bannedSendVideos: nil, subject: .assets(nil, .wallpaper), mainButtonState: AttachmentMainButtonState(text: presentationData.strings.Conversation_Theme_SetColorWallpaper, font: .regular, background: .color(.clear), textColor: presentationData.theme.actionSheet.controlAccentColor, isVisible: true, progress: .none, isEnabled: true), mainButtonAction: { + let mediaPickerController = MediaPickerScreen(context: context, updatedPresentationData: updatedPresentationData, peer: nil, threadTitle: nil, chatLocation: nil, bannedSendPhotos: nil, bannedSendVideos: nil, subject: .assets(nil, .wallpaper), mainButtonState: AttachmentMainButtonState(text: presentationData.strings.Conversation_Theme_SetColorWallpaper, font: .regular, background: .color(.clear), textColor: presentationData.theme.actionSheet.controlAccentColor, isVisible: true, progress: .none, isEnabled: true, hasShimmer: false), mainButtonAction: { controller?.dismiss(animated: true) openColors() }) diff --git a/submodules/PhotoResources/Sources/PhotoResources.swift b/submodules/PhotoResources/Sources/PhotoResources.swift index c3b5f6000d0..39f2a74a066 100644 --- a/submodules/PhotoResources/Sources/PhotoResources.swift +++ b/submodules/PhotoResources/Sources/PhotoResources.swift @@ -271,7 +271,7 @@ func chatMessagePhotoDatas(mediaBox: MediaBox, userLocation: MediaResourceUserLo } } -private func chatMessageFileDatas(account: Account, userLocation: MediaResourceUserLocation, fileReference: FileMediaReference, pathExtension: String? = nil, progressive: Bool = false, fetched: Bool = false) -> Signal, NoError> { +public func chatMessageFileDatas(account: Account, userLocation: MediaResourceUserLocation, fileReference: FileMediaReference, pathExtension: String? = nil, progressive: Bool = false, fetched: Bool = false) -> Signal, NoError> { let thumbnailResource = fetched ? nil : smallestImageRepresentation(fileReference.media.previewRepresentations)?.resource let fullSizeResource = fileReference.media.resource @@ -2108,13 +2108,12 @@ public func chatMessageVideo(postbox: Postbox, userLocation: MediaResourceUserLo } private func chatSecretMessageVideoData(account: Account, userLocation: MediaResourceUserLocation, fileReference: FileMediaReference, synchronousLoad: Bool) -> Signal { + let decodedThumbnailData = fileReference.media.immediateThumbnailData.flatMap(decodeTinyThumbnail) if let smallestRepresentation = smallestImageRepresentation(fileReference.media.previewRepresentations) { let thumbnailResource = smallestRepresentation.resource let fetchedThumbnail = fetchedMediaResource(mediaBox: account.postbox.mediaBox, userLocation: userLocation, userContentType: MediaResourceUserContentType(file: fileReference.media), reference: fileReference.resourceReference(thumbnailResource)) - let decodedThumbnailData = fileReference.media.immediateThumbnailData.flatMap(decodeTinyThumbnail) - let thumbnail = Signal { subscriber in let fetchedDisposable = fetchedThumbnail.start() let thumbnailDisposable = account.postbox.mediaBox.resourceData(thumbnailResource, attemptSynchronously: synchronousLoad).start(next: { next in @@ -2129,7 +2128,7 @@ private func chatSecretMessageVideoData(account: Account, userLocation: MediaRes } return thumbnail } else { - return .single(nil) + return .single(decodedThumbnailData) } } diff --git a/submodules/Postbox/Sources/MessageHistoryViewState.swift b/submodules/Postbox/Sources/MessageHistoryViewState.swift index c2812171f90..1449e611eaf 100644 --- a/submodules/Postbox/Sources/MessageHistoryViewState.swift +++ b/submodules/Postbox/Sources/MessageHistoryViewState.swift @@ -969,7 +969,7 @@ struct OrderedHistoryViewEntries { } } - #if DEBUG + #if DEBUG && TARGET_OS_IOS for entry in self.lowerOrAtAnchor { assert(self.anchor.isEqualOrGreater(than: entry.index, peerId: self.spacePeerId, namespace: self.spaceNamespace)) } @@ -1008,7 +1008,7 @@ struct OrderedHistoryViewEntries { } } - #if DEBUG + #if DEBUG && TARGET_OS_IOS for entry in self.lowerOrAtAnchor { assert(self.anchor.isEqualOrGreater(than: entry.index, peerId: self.spacePeerId, namespace: self.spaceNamespace)) } @@ -1047,7 +1047,7 @@ struct OrderedHistoryViewEntries { } } - #if DEBUG + #if DEBUG && TARGET_OS_IOS for entry in self.lowerOrAtAnchor { assert(self.anchor.isEqualOrGreater(than: entry.index, peerId: self.spacePeerId, namespace: self.spaceNamespace)) } @@ -1073,7 +1073,7 @@ struct OrderedHistoryViewEntries { } } - #if DEBUG + #if DEBUG && TARGET_OS_IOS for entry in self.lowerOrAtAnchor { assert(self.anchor.isEqualOrGreater(than: entry.index, peerId: self.spacePeerId, namespace: self.spaceNamespace)) } @@ -1099,7 +1099,7 @@ struct OrderedHistoryViewEntries { } } - #if DEBUG + #if DEBUG && TARGET_OS_IOS for entry in self.lowerOrAtAnchor { assert(self.anchor.isEqualOrGreater(than: entry.index, peerId: self.spacePeerId, namespace: self.spaceNamespace)) } @@ -1125,7 +1125,7 @@ struct OrderedHistoryViewEntries { self.lowerOrAtAnchor.remove(at: index) - #if DEBUG + #if DEBUG && TARGET_OS_IOS for entry in self.lowerOrAtAnchor { assert(self.anchor.isEqualOrGreater(than: entry.index, peerId: self.spacePeerId, namespace: self.spaceNamespace)) } @@ -1151,7 +1151,7 @@ struct OrderedHistoryViewEntries { self.higherThanAnchor.remove(at: index) - #if DEBUG + #if DEBUG && TARGET_OS_IOS for entry in self.lowerOrAtAnchor { assert(self.anchor.isEqualOrGreater(than: entry.index, peerId: self.spacePeerId, namespace: self.spaceNamespace)) } @@ -1207,7 +1207,7 @@ struct OrderedHistoryViewEntries { self.higherThanAnchor.sort(by: { $0.index.id.id < $1.index.id.id }) } - #if DEBUG + #if DEBUG && TARGET_OS_IOS for entry in self.lowerOrAtAnchor { assert(self.anchor.isEqualOrGreater(than: entry.index, peerId: self.spacePeerId, namespace: self.spaceNamespace)) } diff --git a/submodules/PremiumUI/Sources/CreateGiveawayController.swift b/submodules/PremiumUI/Sources/CreateGiveawayController.swift index 57f20ff4297..3e6c32d5b05 100644 --- a/submodules/PremiumUI/Sources/CreateGiveawayController.swift +++ b/submodules/PremiumUI/Sources/CreateGiveawayController.swift @@ -33,8 +33,9 @@ private final class CreateGiveawayControllerArguments { let scrollToDescription: () -> Void let setItemIdWithRevealedOptions: (EnginePeer.Id?, EnginePeer.Id?) -> Void let removeChannel: (EnginePeer.Id) -> Void + let expandStars: () -> 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, scrollToDescription: @escaping () -> Void, setItemIdWithRevealedOptions: @escaping (EnginePeer.Id?, EnginePeer.Id?) -> Void, removeChannel: @escaping (EnginePeer.Id) -> 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, scrollToDescription: @escaping () -> Void, setItemIdWithRevealedOptions: @escaping (EnginePeer.Id?, EnginePeer.Id?) -> Void, removeChannel: @escaping (EnginePeer.Id) -> Void, expandStars: @escaping () -> Void) { self.context = context self.updateState = updateState self.dismissInput = dismissInput @@ -46,12 +47,14 @@ private final class CreateGiveawayControllerArguments { self.scrollToDescription = scrollToDescription self.setItemIdWithRevealedOptions = setItemIdWithRevealedOptions self.removeChannel = removeChannel + self.expandStars = expandStars } } private enum CreateGiveawaySection: Int32 { case header case mode + case stars case subscriptions case channels case users @@ -77,14 +80,20 @@ private enum CreateGiveawayEntryTag: ItemListItemTag { private enum CreateGiveawayEntry: ItemListNodeEntry { case header(PresentationTheme, String, String) - case createGiveaway(PresentationTheme, String, String, Bool) - case awardUsers(PresentationTheme, String, String, Bool) + case modeHeader(PresentationTheme, String) + case giftPremium(PresentationTheme, String, String, Bool) + case giftStars(PresentationTheme, String, String, Bool) case prepaidHeader(PresentationTheme, String) case prepaid(PresentationTheme, String, String, PrepaidGiveaway) + case starsHeader(PresentationTheme, String, String) + case stars(Int32, PresentationTheme, Int32, String, String, String, Bool, Int32) + case starsMore(PresentationTheme, String) + case starsInfo(PresentationTheme, String) + case subscriptionsHeader(PresentationTheme, String, String) - case subscriptions(PresentationTheme, Int32) + case subscriptions(PresentationTheme, Int32, [Int32]) case subscriptionsInfo(PresentationTheme, String) case channelsHeader(PresentationTheme, String) @@ -117,8 +126,10 @@ private enum CreateGiveawayEntry: ItemListNodeEntry { switch self { case .header: return CreateGiveawaySection.header.rawValue - case .createGiveaway, .awardUsers, .prepaidHeader, .prepaid: + case .modeHeader, .giftPremium, .giftStars, .prepaidHeader, .prepaid: return CreateGiveawaySection.mode.rawValue + case .starsHeader, .stars, .starsMore, .starsInfo: + return CreateGiveawaySection.stars.rawValue case .subscriptionsHeader, .subscriptions, .subscriptionsInfo: return CreateGiveawaySection.subscriptions.rawValue case .channelsHeader, .channel, .channelAdd, .channelsInfo: @@ -139,61 +150,71 @@ private enum CreateGiveawayEntry: ItemListNodeEntry { var stableId: Int32 { switch self { case .header: + return -2 + case .modeHeader: return -1 - case .createGiveaway: + case .giftPremium: return 0 - case .awardUsers: + case .giftStars: return 1 case .prepaidHeader: return 2 case .prepaid: return 3 - case .subscriptionsHeader: + case .starsHeader: return 4 + case let .stars(_, _, stars, _, _, _, _, _): + return 5 + stars + case .starsMore: + return 100000 + case .starsInfo: + return 100001 + case .subscriptionsHeader: + return 100002 case .subscriptions: - return 5 + return 100003 case .subscriptionsInfo: - return 6 + return 100004 case .channelsHeader: - return 7 + return 100005 case let .channel(index, _, _, _, _): - return 8 + index + return 100006 + index case .channelAdd: - return 100 + return 100200 case .channelsInfo: - return 101 + return 100201 case .usersHeader: - return 102 + return 100202 case .usersAll: - return 103 + return 100203 case .usersNew: - return 104 + return 100204 case .usersInfo: - return 105 + return 100205 case .durationHeader: - return 106 + return 100206 case let .duration(index, _, _, _, _, _, _, _): - return 107 + index + return 100207 + index case .durationInfo: - return 200 + return 100300 case .prizeDescription: - return 201 + return 100301 case .prizeDescriptionText: - return 202 + return 100302 case .prizeDescriptionInfo: - return 203 - case .winners: - return 204 - case .winnersInfo: - return 205 + return 100303 case .timeHeader: - return 206 + return 100304 case .timeExpiryDate: - return 207 + return 100305 case .timeCustomPicker: - return 208 + return 100306 case .timeInfo: - return 209 + return 100307 + case .winners: + return 100308 + case .winnersInfo: + return 100309 } } @@ -205,14 +226,20 @@ private enum CreateGiveawayEntry: ItemListNodeEntry { } else { return false } - case let .createGiveaway(lhsTheme, lhsText, lhsSubtext, lhsSelected): - if case let .createGiveaway(rhsTheme, rhsText, rhsSubtext, rhsSelected) = rhs, lhsTheme === rhsTheme, lhsText == rhsText, lhsSubtext == rhsSubtext, lhsSelected == rhsSelected { + case let .modeHeader(lhsTheme, lhsText): + if case let .modeHeader(rhsTheme, rhsText) = rhs, lhsTheme === rhsTheme, lhsText == rhsText { + return true + } else { + return false + } + case let .giftPremium(lhsTheme, lhsText, lhsSubtext, lhsSelected): + if case let .giftPremium(rhsTheme, rhsText, rhsSubtext, rhsSelected) = rhs, lhsTheme === rhsTheme, lhsText == rhsText, lhsSubtext == rhsSubtext, lhsSelected == rhsSelected { return true } else { return false } - case let .awardUsers(lhsTheme, lhsText, lhsSubtext, lhsSelected): - if case let .awardUsers(rhsTheme, rhsText, rhsSubtext, rhsSelected) = rhs, lhsTheme === rhsTheme, lhsText == rhsText, lhsSubtext == rhsSubtext, lhsSelected == rhsSelected { + case let .giftStars(lhsTheme, lhsText, lhsSubtext, lhsSelected): + if case let .giftStars(rhsTheme, rhsText, rhsSubtext, rhsSelected) = rhs, lhsTheme === rhsTheme, lhsText == rhsText, lhsSubtext == rhsSubtext, lhsSelected == rhsSelected { return true } else { return false @@ -229,14 +256,38 @@ private enum CreateGiveawayEntry: ItemListNodeEntry { } else { return false } + case let .starsHeader(lhsTheme, lhsText, lhsAdditionalText): + if case let .starsHeader(rhsTheme, rhsText, rhsAdditionalText) = rhs, lhsTheme === rhsTheme, lhsText == rhsText, lhsAdditionalText == rhsAdditionalText { + return true + } else { + return false + } + case let .stars(lhsIndex, lhsTheme, lhsStars, lhsTitle, lhsSubtitle, lhsLabel, lhsIsSelected, lhsMaxWinners): + if case let .stars(rhsIndex, rhsTheme, rhsStars, rhsTitle, rhsSubtitle, rhsLabel, rhsIsSelected, rhsMaxWinners) = rhs, lhsIndex == rhsIndex, lhsTheme === rhsTheme, lhsStars == rhsStars, lhsTitle == rhsTitle, lhsSubtitle == rhsSubtitle, lhsLabel == rhsLabel, lhsIsSelected == rhsIsSelected, lhsMaxWinners == rhsMaxWinners { + return true + } else { + return false + } + case let .starsMore(lhsTheme, lhsText): + if case let .starsMore(rhsTheme, rhsText) = rhs, lhsTheme === rhsTheme, lhsText == rhsText { + return true + } else { + return false + } + case let .starsInfo(lhsTheme, lhsText): + if case let .starsInfo(rhsTheme, rhsText) = rhs, lhsTheme === rhsTheme, lhsText == rhsText { + return true + } else { + return false + } case let .subscriptionsHeader(lhsTheme, lhsText, lhsAdditionalText): if case let .subscriptionsHeader(rhsTheme, rhsText, rhsAdditionalText) = rhs, lhsTheme === rhsTheme, lhsText == rhsText, lhsAdditionalText == rhsAdditionalText { return true } else { return false } - case let .subscriptions(lhsTheme, lhsValue): - if case let .subscriptions(rhsTheme, rhsValue) = rhs, lhsTheme === rhsTheme, lhsValue == rhsValue { + case let .subscriptions(lhsTheme, lhsValue, lhsValues): + if case let .subscriptions(rhsTheme, rhsValue, rhsValues) = rhs, lhsTheme === rhsTheme, lhsValue == rhsValue, lhsValues == rhsValues { return true } else { return false @@ -379,51 +430,85 @@ private enum CreateGiveawayEntry: ItemListNodeEntry { switch self { case let .header(_, title, text): return ItemListTextItem(presentationData: presentationData, text: .plain(title + text), sectionId: self.section) - case let .createGiveaway(_, title, subtitle, isSelected): - return GiftOptionItem(presentationData: presentationData, context: arguments.context, icon: .image(color: .blue, name: "Premium/Giveaway"), title: title, subtitle: subtitle, isSelected: isSelected, sectionId: self.section, action: { - arguments.updateState { state in - var updatedState = state - updatedState.mode = .giveaway - return updatedState - } - }) - case let .awardUsers(_, title, subtitle, isSelected): - return GiftOptionItem(presentationData: presentationData, context: arguments.context, icon: .image(color: .violet, name: "Media Editor/Privacy/SelectedUsers"), title: title, subtitle: subtitle, subtitleActive: true, isSelected: isSelected, sectionId: self.section, action: { + case let .modeHeader(_, text): + return ItemListSectionHeaderItem(presentationData: presentationData, text: text, sectionId: self.section) + case let .giftPremium(_, title, subtitle, isSelected): + return GiftOptionItem(presentationData: presentationData, context: arguments.context, icon: .image(color: .premium, name: "Peer Info/PremiumIcon"), title: title, subtitle: subtitle, subtitleActive: true, isSelected: isSelected, sectionId: self.section, action: { var openSelection = false arguments.updateState { state in var updatedState = state - if state.mode == .gift || state.peers.isEmpty { + if (state.mode == .giveaway && state.peers.isEmpty) { openSelection = true } - updatedState.mode = .gift + updatedState.mode = .giveaway return updatedState } if openSelection { arguments.openPeersSelection() } }) + case let .giftStars(_, title, subtitle, isSelected): + return GiftOptionItem(presentationData: presentationData, context: arguments.context, icon: .image(color: .stars, name: "Peer Info/PremiumIcon"), title: title, subtitle: subtitle, subtitleActive: false, isSelected: isSelected, sectionId: self.section, action: { + arguments.updateState { state in + var updatedState = state + updatedState.mode = .starsGiveaway + return updatedState + } + }) case let .prepaidHeader(_, text): return ItemListSectionHeaderItem(presentationData: presentationData, text: text, sectionId: self.section) case let .prepaid(_, title, subtitle, prepaidGiveaway): let color: GiftOptionItem.Icon.Color - switch prepaidGiveaway.months { - case 3: - color = .green - case 6: - color = .blue - case 12: - color = .red - default: - color = .blue - } - return GiftOptionItem(presentationData: presentationData, context: arguments.context, icon: .image(color: color, name: "Premium/Giveaway"), title: title, titleFont: .bold, titleBadge: "\(prepaidGiveaway.quantity * 4)", subtitle: subtitle, sectionId: self.section, action: nil) + let icon: String + let boosts: Int32 + switch prepaidGiveaway.prize { + case let .premium(months): + switch months { + case 3: + color = .green + case 6: + color = .blue + case 12: + color = .red + default: + color = .blue + } + icon = "Premium/Giveaway" + boosts = prepaidGiveaway.quantity * 4 + case let .stars(_, boostCount): + color = .stars + icon = "Premium/PremiumStar" + boosts = boostCount + } + return GiftOptionItem(presentationData: presentationData, context: arguments.context, icon: .image(color: color, name: icon), title: title, titleFont: .bold, titleBadge: "\(boosts)", subtitle: subtitle, sectionId: self.section, action: nil) + case let .starsHeader(_, text, additionalText): + return ItemListSectionHeaderItem(presentationData: presentationData, text: text, accessoryText: ItemListSectionHeaderAccessoryText(value: additionalText, color: .generic), sectionId: self.section) + case let .stars(_, _, stars, title, subtitle, label, isSelected, maxWinners): + return GiftOptionItem(presentationData: presentationData, context: arguments.context, title: title, subtitle: subtitle, subtitleFont: .small, label: .generic(label), badge: nil, isSelected: isSelected, stars: Int64(stars), sectionId: self.section, action: { + arguments.updateState { state in + var updatedState = state + updatedState.stars = Int64(stars) + updatedState.winners = min(updatedState.winners, maxWinners) + return updatedState + } + }) + case let .starsMore(theme, title): + return ItemListPeerActionItem(presentationData: presentationData, icon: PresentationResourcesItemList.downArrowImage(theme), title: title, sectionId: self.section, editing: false, action: { + arguments.expandStars() + }) + case let .starsInfo(_, text): + return ItemListTextItem(presentationData: presentationData, text: .plain(text), sectionId: self.section) case let .subscriptionsHeader(_, text, additionalText): return ItemListSectionHeaderItem(presentationData: presentationData, text: text, accessoryText: ItemListSectionHeaderAccessoryText(value: additionalText, color: .generic), sectionId: self.section) - case let .subscriptions(_, value): - return SubscriptionsCountItem(theme: presentationData.theme, strings: presentationData.strings, value: value, sectionId: self.section, updated: { value in + case let .subscriptions(_, value, values): + return SubscriptionsCountItem(theme: presentationData.theme, strings: presentationData.strings, value: value, values: values, sectionId: self.section, updated: { value in arguments.updateState { state in var updatedState = state - updatedState.subscriptions = value + if state.mode == .giveaway { + updatedState.subscriptions = value + } else if state.mode == .starsGiveaway { + updatedState.winners = value + } return updatedState } }) @@ -626,6 +711,19 @@ private struct PremiumGiftProduct: Equatable { } } +private struct StarsGiveawayProduct: Equatable { + let giveawayOption: StarsGiveawayOption + let storeProduct: InAppPurchaseManager.Product + + var id: String { + return self.storeProduct.id + } + + var price: String { + return self.storeProduct.price + } +} + private func createGiveawayControllerEntries( peerId: EnginePeer.Id, subject: CreateGiveawaySubject, @@ -635,6 +733,7 @@ private func createGiveawayControllerEntries( peers: [EnginePeer.Id: EnginePeer], products: [PremiumGiftProduct], defaultPrice: (Int64, NSDecimalNumber), + starsGiveawayOptions: [StarsGiveawayProduct], minDate: Int32, maxDate: Int32 ) -> [CreateGiveawayEntry] { @@ -647,10 +746,8 @@ private func createGiveawayControllerEntries( switch subject { case .generic: - entries.append(.createGiveaway(presentationData.theme, presentationData.strings.BoostGift_CreateGiveaway, presentationData.strings.BoostGift_CreateGiveawayInfo, state.mode == .giveaway)) - let recipientsText: String - if !state.peers.isEmpty { + if !state.peers.isEmpty && state.mode == .gift { var peerNamesArray: [String] = [] let peersCount = state.peers.count for peerId in state.peers.prefix(2) { @@ -665,12 +762,58 @@ private func createGiveawayControllerEntries( recipientsText = presentationData.strings.PremiumGift_LabelRecipients(Int32(peersCount)) } } else { - recipientsText = presentationData.strings.BoostGift_SelectRecipients + recipientsText = presentationData.strings.BoostGift_CreateGiveawayInfo //presentationData.strings.BoostGift_SelectRecipients } - entries.append(.awardUsers(presentationData.theme, presentationData.strings.BoostGift_AwardSpecificUsers, recipientsText, state.mode == .gift)) + + entries.append(.modeHeader(presentationData.theme, presentationData.strings.BoostGift_Prize.uppercased())) + entries.append(.giftPremium(presentationData.theme, presentationData.strings.BoostGift_Prize_Premium, recipientsText, state.mode == .giveaway || state.mode == .gift)) + + entries.append(.giftStars(presentationData.theme, presentationData.strings.BoostGift_Prize_Stars, presentationData.strings.BoostGift_CreateGiveawayInfo, state.mode == .starsGiveaway)) case let .prepaid(prepaidGiveaway): entries.append(.prepaidHeader(presentationData.theme, presentationData.strings.BoostGift_PrepaidGiveawayTitle)) - entries.append(.prepaid(presentationData.theme, presentationData.strings.BoostGift_PrepaidGiveawayCount(prepaidGiveaway.quantity), presentationData.strings.BoostGift_PrepaidGiveawayMonths("\(prepaidGiveaway.months)").string, prepaidGiveaway)) + let title: String + let text: String + switch prepaidGiveaway.prize { + case let .premium(months): + title = presentationData.strings.BoostGift_PrepaidGiveawayCount(prepaidGiveaway.quantity) + text = presentationData.strings.BoostGift_PrepaidGiveawayMonths("\(months)").string + case let .stars(stars, _): + title = presentationData.strings.BoostGift_PrepaidGiveaway_StarsCount(Int32(stars)) + text = presentationData.strings.BoostGift_PrepaidGiveaway_StarsWinners(prepaidGiveaway.quantity) + } + entries.append(.prepaid(presentationData.theme, title, text, prepaidGiveaway)) + } + + var starsPerUser: Int64 = 0 + if case .generic = subject, case .starsGiveaway = state.mode, !starsGiveawayOptions.isEmpty { + let selectedOption = starsGiveawayOptions.first(where: { $0.giveawayOption.count == state.stars })! + entries.append(.starsHeader(presentationData.theme, presentationData.strings.BoostGift_Stars_Title.uppercased(), presentationData.strings.BoostGift_Stars_Boosts(selectedOption.giveawayOption.yearlyBoosts).uppercased())) + + var i: Int32 = 0 + for product in starsGiveawayOptions { + if !state.starsExpanded && product.giveawayOption.isExtended { + continue + } + let giftTitle: String = presentationData.strings.BoostGift_Stars_Stars(Int32(product.giveawayOption.count)) + let winners = product.giveawayOption.winners.first(where: { $0.users == state.winners }) ?? product.giveawayOption.winners.first! + + let maxWinners = product.giveawayOption.winners.sorted(by: { $0.users < $1.users }).last?.users ?? 1 + + let subtitle = presentationData.strings.BoostGift_Stars_PerUser("\(winners.starsPerUser)").string + let label = product.storeProduct.price + starsPerUser = winners.starsPerUser + + let isSelected = product.giveawayOption.count == state.stars + entries.append(.stars(i, presentationData.theme, Int32(product.giveawayOption.count), giftTitle, subtitle, label, isSelected, maxWinners)) + + i += 1 + } + + if !state.starsExpanded { + entries.append(.starsMore(presentationData.theme, presentationData.strings.BoostGift_Stars_ShowMoreOptions)) + } + + entries.append(.starsInfo(presentationData.theme, presentationData.strings.BoostGift_Stars_Info)) } let appendDurationEntries = { @@ -682,6 +825,8 @@ private func createGiveawayControllerEntries( recipientCount = Int(state.subscriptions) case .gift: recipientCount = state.peers.count + case .starsGiveaway: + recipientCount = Int(state.subscriptions) } var i: Int32 = 0 @@ -721,11 +866,26 @@ private func createGiveawayControllerEntries( } switch state.mode { - case .giveaway: - if case .generic = subject { - entries.append(.subscriptionsHeader(presentationData.theme, presentationData.strings.BoostGift_QuantityTitle.uppercased(), presentationData.strings.BoostGift_QuantityBoosts(state.subscriptions * 4))) - entries.append(.subscriptions(presentationData.theme, state.subscriptions)) - entries.append(.subscriptionsInfo(presentationData.theme, presentationData.strings.BoostGift_QuantityInfo)) + case .giveaway, .starsGiveaway: + if case .starsGiveaway = state.mode { + if case .prepaid = subject { + } else { + var values: [Int32] = [1] + if let selectedOption = starsGiveawayOptions.first(where: { $0.giveawayOption.count == state.stars }) { + values = selectedOption.giveawayOption.winners.map { $0.users } + } + if values.count > 1 { + entries.append(.subscriptionsHeader(presentationData.theme, presentationData.strings.BoostGift_Stars_Winners, "")) + entries.append(.subscriptions(presentationData.theme, state.winners, values)) + entries.append(.subscriptionsInfo(presentationData.theme, presentationData.strings.BoostGift_Stars_WinnersInfo)) + } + } + } else { + if case .generic = subject { + entries.append(.subscriptionsHeader(presentationData.theme, presentationData.strings.BoostGift_QuantityTitle.uppercased(), presentationData.strings.BoostGift_QuantityBoosts(state.subscriptions * 4))) + entries.append(.subscriptions(presentationData.theme, state.subscriptions, [1, 3, 5, 7, 10, 25, 50])) + entries.append(.subscriptionsInfo(presentationData.theme, presentationData.strings.BoostGift_QuantityInfo)) + } } entries.append(.channelsHeader(presentationData.theme, isGroup ? presentationData.strings.BoostGift_GroupsAndChannelsTitle.uppercased() : presentationData.strings.BoostGift_ChannelsAndGroupsTitle.uppercased())) @@ -765,38 +925,62 @@ private func createGiveawayControllerEntries( entries.append(.usersNew(presentationData.theme, isGroup ? presentationData.strings.BoostGift_Group_OnlyNewMembers : presentationData.strings.BoostGift_OnlyNewSubscribers, countriesText, state.onlyNewEligible)) entries.append(.usersInfo(presentationData.theme, isGroup ? presentationData.strings.BoostGift_Group_LimitMembersInfo : presentationData.strings.BoostGift_LimitSubscribersInfo)) - if case .generic = subject { - appendDurationEntries() + if case .starsGiveaway = state.mode { + + } else { + if case .generic = subject { + appendDurationEntries() + } } entries.append(.prizeDescription(presentationData.theme, presentationData.strings.BoostGift_AdditionalPrizes, state.showPrizeDescription)) - var prizeDescriptionInfoText = presentationData.strings.BoostGift_AdditionalPrizesInfoOff + var prizeDescriptionInfoText = state.mode == .starsGiveaway ? presentationData.strings.BoostGift_AdditionalPrizesInfoStarsOff : presentationData.strings.BoostGift_AdditionalPrizesInfoOff if state.showPrizeDescription { entries.append(.prizeDescriptionText(presentationData.theme, presentationData.strings.BoostGift_AdditionalPrizesPlaceholder, state.prizeDescription, state.subscriptions)) - let monthsString = presentationData.strings.BoostGift_AdditionalPrizesInfoForMonths(state.selectedMonths ?? 12) - if state.prizeDescription.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - let subscriptionsString = presentationData.strings.BoostGift_AdditionalPrizesInfoSubscriptions(state.subscriptions).replacingOccurrences(of: "\(state.subscriptions) ", with: "") - prizeDescriptionInfoText = presentationData.strings.BoostGift_AdditionalPrizesInfoOn("\(state.subscriptions)", subscriptionsString, monthsString).string + if state.mode == .starsGiveaway { + let starsString = presentationData.strings.BoostGift_AdditionalPrizesInfoStars(Int32(state.stars)) + if state.prizeDescription.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + let _ = starsPerUser + prizeDescriptionInfoText = presentationData.strings.BoostGift_AdditionalPrizesInfoStarsOn(starsString, "").string + } else { + prizeDescriptionInfoText = presentationData.strings.BoostGift_AdditionalPrizesInfoStarsOn(starsString, presentationData.strings.BoostGift_AdditionalPrizesInfoStarsAndOther("\(state.winners)", state.prizeDescription).string).string + } } else { - let subscriptionsString = presentationData.strings.BoostGift_AdditionalPrizesInfoWithSubscriptions(state.subscriptions).replacingOccurrences(of: "\(state.subscriptions) ", with: "") - let description = "\(state.prizeDescription) \(subscriptionsString)" - prizeDescriptionInfoText = presentationData.strings.BoostGift_AdditionalPrizesInfoOn("\(state.subscriptions)", description, monthsString).string + let monthsString = presentationData.strings.BoostGift_AdditionalPrizesInfoForMonths(state.selectedMonths ?? 12) + if state.prizeDescription.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + let subscriptionsString = presentationData.strings.BoostGift_AdditionalPrizesInfoSubscriptions(state.subscriptions).replacingOccurrences(of: "\(state.subscriptions) ", with: "") + prizeDescriptionInfoText = presentationData.strings.BoostGift_AdditionalPrizesInfoOn("\(state.subscriptions)", subscriptionsString, monthsString).string + } else { + let subscriptionsString = presentationData.strings.BoostGift_AdditionalPrizesInfoWithSubscriptions(state.subscriptions).replacingOccurrences(of: "\(state.subscriptions) ", with: "") + let description = "\(state.prizeDescription) \(subscriptionsString)" + prizeDescriptionInfoText = presentationData.strings.BoostGift_AdditionalPrizesInfoOn("\(state.subscriptions)", description, monthsString).string + } } } entries.append(.prizeDescriptionInfo(presentationData.theme, prizeDescriptionInfoText)) - - entries.append(.winners(presentationData.theme, presentationData.strings.BoostGift_Winners, state.showWinners)) - entries.append(.winnersInfo(presentationData.theme, presentationData.strings.BoostGift_WinnersInfo)) - + entries.append(.timeHeader(presentationData.theme, presentationData.strings.BoostGift_DateTitle.uppercased())) entries.append(.timeCustomPicker(presentationData.theme, presentationData.dateTimeFormat, state.time, minDate, maxDate, state.pickingExpiryDate, state.pickingExpiryTime)) + let timeInfoText: String if isGroup { - entries.append(.timeInfo(presentationData.theme, presentationData.strings.BoostGift_Group_DateInfo(presentationData.strings.BoostGift_Group_DateInfoMembers(Int32(state.subscriptions))).string)) + if case .starsGiveaway = state.mode { + timeInfoText = presentationData.strings.BoostGift_Group_StarsDateInfo(presentationData.strings.BoostGift_Group_DateInfoMembers(Int32(state.winners))).string + } else { + timeInfoText = presentationData.strings.BoostGift_Group_DateInfo(presentationData.strings.BoostGift_Group_DateInfoMembers(Int32(state.subscriptions))).string + } } else { - entries.append(.timeInfo(presentationData.theme, presentationData.strings.BoostGift_DateInfo(presentationData.strings.BoostGift_DateInfoSubscribers(Int32(state.subscriptions))).string)) + if case .starsGiveaway = state.mode { + timeInfoText = presentationData.strings.BoostGift_StarsDateInfo(presentationData.strings.BoostGift_DateInfoSubscribers(Int32(state.winners))).string + } else { + timeInfoText = presentationData.strings.BoostGift_DateInfo(presentationData.strings.BoostGift_DateInfoSubscribers(Int32(state.subscriptions))).string + } } + entries.append(.timeInfo(presentationData.theme, timeInfoText)) + + entries.append(.winners(presentationData.theme, presentationData.strings.BoostGift_Winners, state.showWinners)) + entries.append(.winnersInfo(presentationData.theme, presentationData.strings.BoostGift_WinnersInfo)) case .gift: appendDurationEntries() } @@ -808,10 +992,13 @@ private struct CreateGiveawayControllerState: Equatable { enum Mode { case giveaway case gift + case starsGiveaway } var mode: Mode var subscriptions: Int32 + var stars: Int64 + var winners: Int32 var channels: [EnginePeer.Id] = [] var peers: [EnginePeer.Id] = [] var selectedMonths: Int32? @@ -825,6 +1012,7 @@ private struct CreateGiveawayControllerState: Equatable { var pickingExpiryDate = false var revealedItemId: EnginePeer.Id? = nil var updating = false + var starsExpanded = false } public enum CreateGiveawaySubject { @@ -836,10 +1024,22 @@ public func createGiveawayController(context: AccountContext, updatedPresentatio let actionsDisposable = DisposableSet() let initialSubscriptions: Int32 + let initialStars: Int64 + let initialWinners: Int32 + var initialMode: CreateGiveawayControllerState.Mode = .giveaway if case let .prepaid(prepaidGiveaway) = subject { + if case let .stars(stars, _) = prepaidGiveaway.prize { + initialStars = stars + initialMode = .starsGiveaway + } else { + initialStars = 500 + } initialSubscriptions = prepaidGiveaway.quantity + initialWinners = prepaidGiveaway.quantity } else { initialSubscriptions = 5 + initialStars = 500 + initialWinners = 5 } let currentTime = Int32(CFAbsoluteTimeGetCurrent() + kCFAbsoluteTimeIntervalSince1970) @@ -857,7 +1057,7 @@ public func createGiveawayController(context: AccountContext, updatedPresentatio let minDate = currentTime + 60 * 1 let maxDate = currentTime + context.userLimits.maxGiveawayPeriodSeconds - let initialState: CreateGiveawayControllerState = CreateGiveawayControllerState(mode: .giveaway, subscriptions: initialSubscriptions, time: expiryTime) + let initialState: CreateGiveawayControllerState = CreateGiveawayControllerState(mode: initialMode, subscriptions: initialSubscriptions, stars: initialStars, winners: initialWinners, time: expiryTime) let statePromise = ValuePromise(initialState, ignoreRepeated: true) let stateValue = Atomic(value: initialState) @@ -868,6 +1068,7 @@ public func createGiveawayController(context: AccountContext, updatedPresentatio let isGroupValue = Atomic(value: false) let productsValue = Atomic<[PremiumGiftProduct]?>(value: nil) + let starsValue = Atomic<[StarsGiveawayProduct]?>(value: nil) var buyActionImpl: (() -> Void)? var openPeersSelectionImpl: (() -> Void)? @@ -912,6 +1113,13 @@ public func createGiveawayController(context: AccountContext, updatedPresentatio updatedState.channels = updatedState.channels.filter { $0 != id } return updatedState } + }, + expandStars: { + updateState { state in + var updatedState = state + updatedState.starsExpanded = true + return updatedState + } }) let presentationData = updatedPresentationData?.signal ?? context.sharedContext.presentationData @@ -938,6 +1146,20 @@ public func createGiveawayController(context: AccountContext, updatedPresentatio return (gifts, defaultPrice) } + let starsGiveawayOptions: Signal<[StarsGiveawayProduct], NoError> = combineLatest( + .single([]) |> then(context.engine.payments.starsGiveawayOptions()), + context.inAppPurchaseManager?.availableProducts ?? .single([]) + ) + |> map { options, products in + var result: [StarsGiveawayProduct] = [] + for option in options { + if let product = products.first(where: { $0.id == option.storeProductId }), !product.isSubscription { + result.append(StarsGiveawayProduct(giveawayOption: option, storeProduct: product)) + } + } + return result + } + let previousState = Atomic(value: nil) let signal = combineLatest( presentationData, @@ -952,10 +1174,11 @@ public func createGiveawayController(context: AccountContext, updatedPresentatio return (state, peers) } }, - productsAndDefaultPrice + productsAndDefaultPrice, + starsGiveawayOptions ) |> deliverOnMainQueue - |> map { presentationData, stateAndPeersMap, productsAndDefaultPrice -> (ItemListControllerState, (ItemListNodeState, Any)) in + |> map { presentationData, stateAndPeersMap, productsAndDefaultPrice, starsGiveawayOptions -> (ItemListControllerState, (ItemListNodeState, Any)) in var presentationData = presentationData let (products, defaultPrice) = productsAndDefaultPrice @@ -971,7 +1194,8 @@ public func createGiveawayController(context: AccountContext, updatedPresentatio } let _ = isGroupValue.swap(isGroup) - let headerItem = CreateGiveawayHeaderItem(theme: presentationData.theme, strings: presentationData.strings, title: presentationData.strings.BoostGift_Title, text: isGroup ? presentationData.strings.BoostGift_Group_Description : presentationData.strings.BoostGift_Description, cancel: { + let headerText = isGroup ? presentationData.strings.BoostGift_NewDescriptionGroup : presentationData.strings.BoostGift_NewDescription + let headerItem = CreateGiveawayHeaderItem(theme: presentationData.theme, strings: presentationData.strings, title: presentationData.strings.BoostGift_Title, text: headerText, isStars: state.mode == .starsGiveaway, cancel: { dismissImpl?() }) @@ -981,6 +1205,8 @@ public func createGiveawayController(context: AccountContext, updatedPresentatio badgeCount = state.subscriptions * 4 case .gift: badgeCount = Int32(state.peers.count) * 4 + case .starsGiveaway: + badgeCount = Int32(state.stars) / 500 } let footerItem = CreateGiveawayFooterItem(theme: presentationData.theme, title: state.mode == .gift ? presentationData.strings.BoostGift_GiftPremium : presentationData.strings.BoostGift_StartGiveaway, badgeCount: badgeCount, isLoading: state.updating, action: { if case .prepaid = subject { @@ -995,6 +1221,10 @@ public func createGiveawayController(context: AccountContext, updatedPresentatio let leftNavigationButton = ItemListNavigationButton(content: .none, style: .regular, enabled: false, action: {}) let _ = productsValue.swap(products) + let previousStars = starsValue.swap(starsGiveawayOptions) + if (previousStars ?? []).isEmpty && !starsGiveawayOptions.isEmpty { + + } let previousState = previousState.swap(state) var animateChanges = false @@ -1014,6 +1244,9 @@ public func createGiveawayController(context: AccountContext, updatedPresentatio if previousState.showPrizeDescription != state.showPrizeDescription { animateChanges = true } + if previousState.starsExpanded != state.starsExpanded { + animateChanges = true + } } var peers: [EnginePeer.Id: EnginePeer] = [:] @@ -1024,7 +1257,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, locale: locale, peers: peers, products: products, defaultPrice: defaultPrice, minDate: minDate, maxDate: maxDate), 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, starsGiveawayOptions: starsGiveawayOptions, minDate: minDate, maxDate: maxDate), style: .blocks, emptyStateItem: nil, headerItem: headerItem, footerItem: footerItem, crossfadeState: false, animateChanges: animateChanges) return (controllerState, (listState, arguments)) } @@ -1063,7 +1296,9 @@ public func createGiveawayController(context: AccountContext, updatedPresentatio return } var selectedProduct: PremiumGiftProduct? + var selectedStarsProduct: StarsGiveawayProduct? let selectedMonths = state.selectedMonths ?? 12 + let selectedStars = state.stars switch state.mode { case .giveaway: if let product = products.first(where: { $0.months == selectedMonths && $0.giftOption.users == state.subscriptions }) { @@ -1073,18 +1308,27 @@ public func createGiveawayController(context: AccountContext, updatedPresentatio if let product = products.first(where: { $0.months == selectedMonths && $0.giftOption.users == 1 }) { selectedProduct = product } + case .starsGiveaway: + guard let starsOptions = starsValue.with({ $0 }), !starsOptions.isEmpty else { + return + } + if let product = starsOptions.first(where: { $0.giveawayOption.count == selectedStars }) { + selectedStarsProduct = product + } } - guard let selectedProduct else { - let alertController = textAlertController(context: context, title: presentationData.strings.BoostGift_ReduceQuantity_Title, text: presentationData.strings.BoostGift_ReduceQuantity_Text("\(state.subscriptions)", "\(selectedMonths)", "\(25)").string, actions: [TextAlertAction(type: .genericAction, title: presentationData.strings.Common_Cancel, action: {}), TextAlertAction(type: .defaultAction, title: presentationData.strings.BoostGift_ReduceQuantity_Reduce, action: { - updateState { state in - var updatedState = state - updatedState.subscriptions = 25 - return updatedState - } - })], parseMarkdown: true) - presentControllerImpl?(alertController) - return + if [.gift, .giveaway].contains(state.mode) { + guard let _ = selectedProduct else { + let alertController = textAlertController(context: context, title: presentationData.strings.BoostGift_ReduceQuantity_Title, text: presentationData.strings.BoostGift_ReduceQuantity_Text("\(state.subscriptions)", "\(selectedMonths)", "\(25)").string, actions: [TextAlertAction(type: .genericAction, title: presentationData.strings.Common_Cancel, action: {}), TextAlertAction(type: .defaultAction, title: presentationData.strings.BoostGift_ReduceQuantity_Reduce, action: { + updateState { state in + var updatedState = state + updatedState.subscriptions = 25 + return updatedState + } + })], parseMarkdown: true) + presentControllerImpl?(alertController) + return + } } updateState { state in @@ -1093,25 +1337,48 @@ public func createGiveawayController(context: AccountContext, updatedPresentatio return updatedState } - let (currency, amount) = selectedProduct.storeProduct.priceCurrencyAndAmount - let purpose: AppStoreTransactionPurpose let quantity: Int32 + var storeProduct: InAppPurchaseManager.Product? switch state.mode { case .giveaway: + guard let selectedProduct else { + return + } + let (currency, amount) = selectedProduct.storeProduct.priceCurrencyAndAmount let currentTime = Int32(CFAbsoluteTimeGetCurrent() + kCFAbsoluteTimeIntervalSince1970) let untilDate = max(state.time, currentTime + 60) purpose = .giveaway(boostPeer: peerId, additionalPeerIds: state.channels.filter { $0 != peerId }, countries: state.countries, onlyNewSubscribers: state.onlyNewEligible, showWinners: state.showWinners, prizeDescription: state.prizeDescription.isEmpty ? nil : state.prizeDescription, randomId: Int64.random(in: .min ..< .max), untilDate: untilDate, currency: currency, amount: amount) quantity = selectedProduct.giftOption.storeQuantity + storeProduct = selectedProduct.storeProduct case .gift: + guard let selectedProduct else { + return + } + let (currency, amount) = selectedProduct.storeProduct.priceCurrencyAndAmount purpose = .giftCode(peerIds: state.peers, boostPeer: peerId, currency: currency, amount: amount) quantity = Int32(state.peers.count) + storeProduct = selectedProduct.storeProduct + case .starsGiveaway: + guard let selectedStarsProduct else { + return + } + let (currency, amount) = selectedStarsProduct.storeProduct.priceCurrencyAndAmount + let currentTime = Int32(CFAbsoluteTimeGetCurrent() + kCFAbsoluteTimeIntervalSince1970) + let untilDate = max(state.time, currentTime + 60) + purpose = .starsGiveaway(stars: selectedStarsProduct.giveawayOption.count, boostPeer: peerId, additionalPeerIds: state.channels.filter { $0 != peerId }, countries: state.countries, onlyNewSubscribers: state.onlyNewEligible, showWinners: state.showWinners, prizeDescription: state.prizeDescription.isEmpty ? nil : state.prizeDescription, randomId: Int64.random(in: .min ..< .max), untilDate: untilDate, currency: currency, amount: amount, users: state.winners) + quantity = 1 + storeProduct = selectedStarsProduct.storeProduct + } + + guard let storeProduct else { + return } let _ = (context.engine.payments.canPurchasePremium(purpose: purpose) |> deliverOnMainQueue).startStandalone(next: { [weak controller] available in if available, let inAppPurchaseManager = context.inAppPurchaseManager { - let _ = (inAppPurchaseManager.buyProduct(selectedProduct.storeProduct, quantity: quantity, purpose: purpose) + let _ = (inAppPurchaseManager.buyProduct(storeProduct, quantity: quantity, purpose: purpose) |> deliverOnMainQueue).startStandalone(next: { [weak controller] status in if case .purchased = status { if let controller, let navigationController = controller.navigationController as? NavigationController { @@ -1136,15 +1403,37 @@ public func createGiveawayController(context: AccountContext, updatedPresentatio case .giveaway: title = presentationData.strings.BoostGift_GiveawayCreated_Title text = isGroup ? presentationData.strings.BoostGift_Group_GiveawayCreated_Text : presentationData.strings.BoostGift_GiveawayCreated_Text + case .starsGiveaway: + title = presentationData.strings.BoostGift_StarsGiveawayCreated_Title + text = isGroup ? presentationData.strings.BoostGift_Group_StarsGiveawayCreated_Text : presentationData.strings.BoostGift_StarsGiveawayCreated_Text case .gift: title = presentationData.strings.BoostGift_PremiumGifted_Title text = isGroup ? presentationData.strings.BoostGift_Group_PremiumGifted_Text : presentationData.strings.BoostGift_PremiumGifted_Text } - let tooltipController = UndoOverlayController(presentationData: presentationData, content: .premiumPaywall(title: title, text: text, customUndoText: nil, timeout: nil, linkAction: { [weak navigationController] _ in - let statsController = context.sharedContext.makeChannelStatsController(context: context, updatedPresentationData: updatedPresentationData, peerId: peerId, boosts: true, boostStatus: nil) - navigationController?.pushViewController(statsController) - }), elevatedLayout: false, action: { _ in + var content: UndoOverlayContent + if case .starsGiveaway = state.mode { + content = .universal( + animation: "StarsBuy", + scale: 0.066, + colors: [:], + title: title, + text: text, + customUndoText: nil, + timeout: nil + ) + } else { + content = .premiumPaywall(title: title, text: text, customUndoText: nil, timeout: nil, linkAction: { [weak navigationController] _ in + let statsController = context.sharedContext.makeChannelStatsController(context: context, updatedPresentationData: updatedPresentationData, peerId: peerId, boosts: true, boostStatus: nil) + navigationController?.pushViewController(statsController) + }) + } + + let tooltipController = UndoOverlayController(presentationData: presentationData, content: content, elevatedLayout: false, action: { [weak navigationController] action in + if case .info = action { + let statsController = context.sharedContext.makeChannelStatsController(context: context, updatedPresentationData: updatedPresentationData, peerId: peerId, boosts: true, boostStatus: nil) + navigationController?.pushViewController(statsController) + } return true }) (controllers.last as? ViewController)?.present(tooltipController, in: .current) @@ -1195,7 +1484,15 @@ public func createGiveawayController(context: AccountContext, updatedPresentatio return updatedState } - let _ = (context.engine.payments.launchPrepaidGiveaway(peerId: peerId, id: prepaidGiveaway.id, additionalPeerIds: state.channels.filter { $0 != peerId }, countries: state.countries, onlyNewSubscribers: state.onlyNewEligible, showWinners: state.showWinners, prizeDescription: state.prizeDescription.isEmpty ? nil : state.prizeDescription, randomId: Int64.random(in: .min ..< .max), untilDate: state.time) + let purpose: LaunchGiveawayPurpose + switch prepaidGiveaway.prize { + case .premium: + purpose = .premium + case let .stars(stars, _): + purpose = .stars(stars: stars, users: state.winners) + } + + let _ = (context.engine.payments.launchPrepaidGiveaway(peerId: peerId, id: prepaidGiveaway.id, purpose: purpose, additionalPeerIds: state.channels.filter { $0 != peerId }, countries: state.countries, onlyNewSubscribers: state.onlyNewEligible, showWinners: state.showWinners, prizeDescription: state.prizeDescription.isEmpty ? nil : state.prizeDescription, randomId: Int64.random(in: .min ..< .max), untilDate: state.time) |> deliverOnMainQueue).startStandalone(completed: { if let controller, let navigationController = controller.navigationController as? NavigationController { var controllers = navigationController.viewControllers @@ -1248,6 +1545,8 @@ public func createGiveawayController(context: AccountContext, updatedPresentatio updatedState.peers = privacy.additionallyIncludePeers if updatedState.peers.isEmpty { updatedState.mode = .giveaway + } else { + updatedState.mode = .gift } return updatedState } diff --git a/submodules/PremiumUI/Sources/CreateGiveawayHeaderItem.swift b/submodules/PremiumUI/Sources/CreateGiveawayHeaderItem.swift index 6317511c4d6..f65c4525218 100644 --- a/submodules/PremiumUI/Sources/CreateGiveawayHeaderItem.swift +++ b/submodules/PremiumUI/Sources/CreateGiveawayHeaderItem.swift @@ -15,19 +15,21 @@ final class CreateGiveawayHeaderItem: ItemListControllerHeaderItem { let strings: PresentationStrings let title: String let text: String + let isStars: Bool let cancel: () -> Void - init(theme: PresentationTheme, strings: PresentationStrings, title: String, text: String, cancel: @escaping () -> Void) { + init(theme: PresentationTheme, strings: PresentationStrings, title: String, text: String, isStars: Bool, cancel: @escaping () -> Void) { self.theme = theme self.strings = strings self.title = title self.text = text + self.isStars = isStars self.cancel = cancel } func isEqual(to: ItemListControllerHeaderItem) -> Bool { if let item = to as? CreateGiveawayHeaderItem { - return self.theme === item.theme && self.title == item.title && self.text == item.text + return self.theme === item.theme && self.title == item.title && self.text == item.text && self.isStars == item.isStars } else { return false } @@ -196,16 +198,34 @@ class CreateGiveawayHeaderItemNode: ItemListControllerHeaderItemNode { self.backgroundNode.update(size: CGSize(width: layout.size.width, height: navigationBarHeight), transition: transition) + let colors: [UIColor] + let particleColor: UIColor? + if self.item.isStars { + colors = [ + UIColor(rgb: 0xe57d02), + UIColor(rgb: 0xf09903), + UIColor(rgb: 0xf9b004), + UIColor(rgb: 0xfdd219) + ] + particleColor = UIColor(rgb: 0xf9b004) + } else { + colors = [ + UIColor(rgb: 0x6a94ff), + UIColor(rgb: 0x9472fd), + UIColor(rgb: 0xe26bd3) + ] + particleColor = nil + } + let component = AnyComponent(PremiumStarComponent( theme: self.item.theme, isIntro: true, isVisible: true, hasIdleAnimations: true, - colors: [ - UIColor(rgb: 0x6a94ff), - UIColor(rgb: 0x9472fd), - UIColor(rgb: 0xe26bd3) - ] + colors: colors, + particleColor: particleColor, + backgroundColor: self.item.theme.list.blocksBackgroundColor + )) let containerSize = CGSize(width: min(414.0, layout.size.width), height: 220.0) diff --git a/submodules/PremiumUI/Sources/GiftOptionItem.swift b/submodules/PremiumUI/Sources/GiftOptionItem.swift index 60ca54d94d8..12166ff4b9e 100644 --- a/submodules/PremiumUI/Sources/GiftOptionItem.swift +++ b/submodules/PremiumUI/Sources/GiftOptionItem.swift @@ -19,6 +19,8 @@ public final class GiftOptionItem: ListViewItem, ItemListItem { case green case red case violet + case premium + case stars } case peer(EnginePeer) @@ -62,10 +64,11 @@ public final class GiftOptionItem: ListViewItem, ItemListItem { let label: Label? let badge: String? let isSelected: Bool? + let stars: Int64? public let sectionId: ItemListSectionId let action: (() -> Void)? - public init(presentationData: ItemListPresentationData, context: AccountContext, icon: Icon? = nil, title: String, titleFont: Font = .regular, titleBadge: String? = nil, subtitle: String?, subtitleFont: SubtitleFont = .regular, subtitleActive: Bool = false, label: Label? = nil, badge: String? = nil, isSelected: Bool? = nil, sectionId: ItemListSectionId, action: (() -> Void)?) { + public init(presentationData: ItemListPresentationData, context: AccountContext, icon: Icon? = nil, title: String, titleFont: Font = .regular, titleBadge: String? = nil, subtitle: String?, subtitleFont: SubtitleFont = .regular, subtitleActive: Bool = false, label: Label? = nil, badge: String? = nil, isSelected: Bool? = nil, stars: Int64? = nil, sectionId: ItemListSectionId, action: (() -> Void)?) { self.presentationData = presentationData self.icon = icon self.context = context @@ -78,6 +81,7 @@ public final class GiftOptionItem: ListViewItem, ItemListItem { self.label = label self.badge = badge self.isSelected = isSelected + self.stars = stars self.sectionId = sectionId self.action = action } @@ -148,6 +152,7 @@ class GiftOptionItemNode: ItemListRevealOptionsItemNode { private let titleBadge = ComponentView() private let statusNode: TextNode private var statusArrowNode: ASImageNode? + private var starsIconNode: ASImageNode? private var labelBackgroundNode: ASImageNode? private let labelNode: TextNode @@ -387,6 +392,7 @@ class GiftOptionItemNode: ItemListRevealOptionsItemNode { } let colors: [UIColor] + var diagonal = false switch color { case .blue: colors = [UIColor(rgb: 0x2a9ef1), UIColor(rgb: 0x71d4fc)] @@ -396,9 +402,21 @@ class GiftOptionItemNode: ItemListRevealOptionsItemNode { colors = [UIColor(rgb: 0xff516a), UIColor(rgb: 0xff885e)] case .violet: colors = [UIColor(rgb: 0xd569ec), UIColor(rgb: 0xe0a2f3)] + case .premium: + colors = [ + UIColor(rgb: 0x6b93ff), + UIColor(rgb: 0x6b93ff), + UIColor(rgb: 0x8d77ff), + UIColor(rgb: 0xb56eec), + UIColor(rgb: 0xb56eec) + ] + diagonal = true + case .stars: + colors = [UIColor(rgb: 0xdd6f12), UIColor(rgb: 0xfec80f)] + diagonal = true } if iconNode.image == nil || iconUpdated { - iconNode.image = generateAvatarImage(size: iconSize, icon: generateTintedImage(image: UIImage(bundleImageName: name), color: .white), iconScale: 1.0, cornerRadius: 20.0, color: .blue, customColors: colors) + iconNode.image = generateAvatarImage(size: iconSize, icon: generateTintedImage(image: UIImage(bundleImageName: name), color: .white), iconScale: 1.0, cornerRadius: 20.0, color: .blue, customColors: colors, diagonal: diagonal) } iconNode.frame = iconFrame } @@ -437,6 +455,26 @@ class GiftOptionItemNode: ItemListRevealOptionsItemNode { selectableControlNode?.removeFromSupernode() }) } + + var titleOffset: CGFloat = 0.0 + if let stars = item.stars { + let starsIconNode: ASImageNode + if let current = strongSelf.starsIconNode { + starsIconNode = current + } else { + starsIconNode = ASImageNode() + starsIconNode.displaysAsynchronously = false + strongSelf.addSubnode(starsIconNode) + strongSelf.starsIconNode = starsIconNode + + starsIconNode.image = generateStarsIcon(amount: stars) + } + + if let icon = starsIconNode.image { + starsIconNode.frame = CGRect(origin: CGPoint(x: leftInset + editingOffset + avatarInset, y: 10.0), size: icon.size) + titleOffset += icon.size.width + 3.0 + } + } let _ = titleApply() let _ = statusApply() @@ -494,7 +532,7 @@ class GiftOptionItemNode: ItemListRevealOptionsItemNode { } else { titleVerticalOriginY = floorToScreenPixels((contentSize.height - titleLayout.size.height) / 2.0) } - let titleFrame = CGRect(origin: CGPoint(x: leftInset + editingOffset + avatarInset, y: titleVerticalOriginY), size: titleLayout.size) + let titleFrame = CGRect(origin: CGPoint(x: leftInset + editingOffset + avatarInset + titleOffset, y: titleVerticalOriginY), size: titleLayout.size) transition.updateFrame(node: strongSelf.titleNode, frame: titleFrame) var badgeOffset: CGFloat = 0.0 @@ -678,3 +716,77 @@ class GiftOptionItemNode: ItemListRevealOptionsItemNode { self.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false) } } + +private func generateStarsIcon(amount: Int64) -> UIImage { + let stars: [Int64: Int] = [ + 15: 1, + 75: 2, + 250: 3, + 500: 4, + 1000: 5, + 2500: 6, + + 25: 1, + 50: 1, + 100: 2, + 150: 2, + 350: 3, + 750: 4, + 1500: 5, + + 5000: 6, + 10000: 6, + 25000: 7, + 35000: 7 + ] + let count = stars[amount] ?? 1 + + let image = generateGradientTintedImage( + image: UIImage(bundleImageName: "Peer Info/PremiumIcon"), + colors: [ + UIColor(rgb: 0xfed219), + UIColor(rgb: 0xf3a103), + UIColor(rgb: 0xe78104) + ], + direction: .diagonal + )! + + let imageSize = CGSize(width: 20.0, height: 20.0) + let partImage = generateImage(imageSize, contextGenerator: { size, context in + context.clear(CGRect(origin: .zero, size: size)) + if let cgImage = image.cgImage { + context.draw(cgImage, in: CGRect(origin: .zero, size: size), byTiling: false) + context.saveGState() + context.clip(to: CGRect(origin: .zero, size: size).insetBy(dx: -1.0, dy: -1.0).offsetBy(dx: -2.0, dy: 0.0), mask: cgImage) + + context.setBlendMode(.clear) + context.setFillColor(UIColor.clear.cgColor) + context.fill(CGRect(origin: .zero, size: size)) + context.restoreGState() + + context.setBlendMode(.clear) + context.setFillColor(UIColor.clear.cgColor) + context.fill(CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: size.width / 2.0, height: size.height - 4.0))) + } + })! + + let spacing: CGFloat = (3.0 - UIScreenPixel) + let totalWidth = 20.0 + spacing * CGFloat(count - 1) + + return generateImage(CGSize(width: ceil(totalWidth), height: 20.0), contextGenerator: { size, context in + context.clear(CGRect(origin: .zero, size: size)) + + var originX = floorToScreenPixels((size.width - totalWidth) / 2.0) + + let mainImage = UIImage(bundleImageName: "Premium/Stars/StarLarge") + if let cgImage = mainImage?.cgImage, let partCGImage = partImage.cgImage { + context.draw(cgImage, in: CGRect(origin: CGPoint(x: originX, y: 0.0), size: imageSize).insetBy(dx: -1.5, dy: -1.5), byTiling: false) + originX += spacing + UIScreenPixel + + for _ in 0 ..< count - 1 { + context.draw(partCGImage, in: CGRect(origin: CGPoint(x: originX, y: -UIScreenPixel), size: imageSize).insetBy(dx: -1.0 + UIScreenPixel, dy: -1.0 + UIScreenPixel), byTiling: false) + originX += spacing + } + } + })! +} diff --git a/submodules/PremiumUI/Sources/GiveawayInfoController.swift b/submodules/PremiumUI/Sources/GiveawayInfoController.swift index a3015ba6339..9893b76288d 100644 --- a/submodules/PremiumUI/Sources/GiveawayInfoController.swift +++ b/submodules/PremiumUI/Sources/GiveawayInfoController.swift @@ -43,10 +43,21 @@ public func presentGiveawayInfoController( } var months: Int32 = 0 + var stars: Int64 = 0 if let giveaway { - months = giveaway.months + switch giveaway.prize { + case let .premium(monthsValue): + months = monthsValue + case let .stars(amount): + stars = amount + } } else if let giveawayResults { - months = giveawayResults.months + switch giveawayResults.prize { + case let .premium(monthsValue): + months = monthsValue + case let .stars(amount): + stars = amount + } } var prizeDescription: String? @@ -70,11 +81,21 @@ public func presentGiveawayInfoController( onlyNewSubscribers = true } - let author = message.forwardInfo?.author ?? message.author?._asPeer() + var author = message.forwardInfo?.author ?? message.author?._asPeer() + if author is TelegramChannel { + } else { + if let peer = message.forwardInfo?.source ?? message.peers[message.id.peerId] { + author = peer + } + } var isGroup = false if let channel = author as? TelegramChannel, case .group = channel.info { isGroup = true } + var peerName = "" + if let author { + peerName = EnginePeer(author).compactDisplayTitle + } var groupsAndChannels = false var channelsCount: Int32 = 1 @@ -102,10 +123,7 @@ public func presentGiveawayInfoController( let presentationData = context.sharedContext.currentPresentationData.with { $0 } - var peerName = "" - if let channel = author as? TelegramChannel { - peerName = EnginePeer(channel).compactDisplayTitle - } + let timeZone = TimeZone.current let untilDate = stringForDate(timestamp: untilDateValue, timeZone: timeZone, strings: presentationData.strings) @@ -135,17 +153,36 @@ public func presentGiveawayInfoController( title = presentationData.strings.Chat_Giveaway_Info_Title let intro: String - if case .almostOver = status { - if isGroup { - intro = presentationData.strings.Chat_Giveaway_Info_Group_EndedIntro(peerName, presentationData.strings.Chat_Giveaway_Info_Subscriptions(quantity), presentationData.strings.Chat_Giveaway_Info_Months(months)).string + if stars > 0 { + let starsString = presentationData.strings.Chat_Giveaway_Info_Stars_Stars(Int32(stars)) + if case .almostOver = status { + if isGroup { + intro = presentationData.strings.Chat_Giveaway_Info_Stars_Group_EndedIntro(peerName, starsString).string + } else { + intro = presentationData.strings.Chat_Giveaway_Info_Stars_EndedIntro(peerName, starsString).string + } } else { - intro = presentationData.strings.Chat_Giveaway_Info_EndedIntro(peerName, presentationData.strings.Chat_Giveaway_Info_Subscriptions(quantity), presentationData.strings.Chat_Giveaway_Info_Months(months)).string + if isGroup { + intro = presentationData.strings.Chat_Giveaway_Info_Stars_Group_OngoingIntro(peerName, starsString).string + } else { + intro = presentationData.strings.Chat_Giveaway_Info_Stars_OngoingIntro(peerName, starsString).string + } } } else { - if isGroup { - intro = presentationData.strings.Chat_Giveaway_Info_Group_OngoingIntro(peerName, presentationData.strings.Chat_Giveaway_Info_Subscriptions(quantity), presentationData.strings.Chat_Giveaway_Info_Months(months)).string + let subscriptionsString = presentationData.strings.Chat_Giveaway_Info_Subscriptions(quantity) + let monthsString = presentationData.strings.Chat_Giveaway_Info_Months(months) + if case .almostOver = status { + if isGroup { + intro = presentationData.strings.Chat_Giveaway_Info_Group_EndedIntro(peerName, subscriptionsString, monthsString).string + } else { + intro = presentationData.strings.Chat_Giveaway_Info_EndedIntro(peerName, subscriptionsString, monthsString).string + } } else { - intro = presentationData.strings.Chat_Giveaway_Info_OngoingIntro(peerName, presentationData.strings.Chat_Giveaway_Info_Subscriptions(quantity), presentationData.strings.Chat_Giveaway_Info_Months(months)).string + if isGroup { + intro = presentationData.strings.Chat_Giveaway_Info_Group_OngoingIntro(peerName, subscriptionsString, monthsString).string + } else { + intro = presentationData.strings.Chat_Giveaway_Info_OngoingIntro(peerName, subscriptionsString, monthsString).string + } } } @@ -242,10 +279,21 @@ public func presentGiveawayInfoController( title = presentationData.strings.Chat_Giveaway_Info_EndedTitle let intro: String - if isGroup { - intro = presentationData.strings.Chat_Giveaway_Info_Group_EndedIntro(peerName, presentationData.strings.Chat_Giveaway_Info_Subscriptions(quantity), presentationData.strings.Chat_Giveaway_Info_Months(months)).string + if stars > 0 { + let starsString = presentationData.strings.Chat_Giveaway_Info_Stars_Stars(Int32(stars)) + if isGroup { + intro = presentationData.strings.Chat_Giveaway_Info_Stars_Group_EndedIntro(peerName, starsString).string + } else { + intro = presentationData.strings.Chat_Giveaway_Info_Stars_EndedIntro(peerName, starsString).string + } } else { - intro = presentationData.strings.Chat_Giveaway_Info_EndedIntro(peerName, presentationData.strings.Chat_Giveaway_Info_Subscriptions(quantity), presentationData.strings.Chat_Giveaway_Info_Months(months)).string + let subscriptionsString = presentationData.strings.Chat_Giveaway_Info_Subscriptions(quantity) + let monthsString = presentationData.strings.Chat_Giveaway_Info_Months(months) + if isGroup { + intro = presentationData.strings.Chat_Giveaway_Info_Group_EndedIntro(peerName, subscriptionsString, monthsString).string + } else { + intro = presentationData.strings.Chat_Giveaway_Info_EndedIntro(peerName, subscriptionsString, monthsString).string + } } var ending: String @@ -265,7 +313,7 @@ public func presentGiveawayInfoController( } } - if activatedCount > 0 { + if let activatedCount, activatedCount > 0 { ending += " " + presentationData.strings.Chat_Giveaway_Info_ActivatedLinks(activatedCount) } @@ -279,7 +327,7 @@ public func presentGiveawayInfoController( })] case .notWon: result = "**\(presentationData.strings.Chat_Giveaway_Info_DidntWin)**\n\n" - case let .won(slug): + case let .wonPremium(slug): result = "**\(presentationData.strings.Chat_Giveaway_Info_Won("").string)**\n\n" actions = [TextAlertAction(type: .defaultAction, title: presentationData.strings.Chat_Giveaway_Info_ViewPrize, action: { dismissImpl?() @@ -287,6 +335,15 @@ public func presentGiveawayInfoController( }), TextAlertAction(type: .genericAction, title: presentationData.strings.Common_Cancel, action: { dismissImpl?() })] + case let .wonStars(stars): + let _ = stars + result = "**\(presentationData.strings.Chat_Giveaway_Info_Won("").string)**\n\n" + actions = [TextAlertAction(type: .defaultAction, title: presentationData.strings.Chat_Giveaway_Info_ViewPrize, action: { + dismissImpl?() + openLink("") + }), TextAlertAction(type: .genericAction, title: presentationData.strings.Common_Cancel, action: { + dismissImpl?() + })] } text = "\(result)\(intro)\(additionalPrizes)\n\n\(ending)" diff --git a/submodules/PremiumUI/Sources/PremiumBoostLevelsScreen.swift b/submodules/PremiumUI/Sources/PremiumBoostLevelsScreen.swift index b1a7eb09634..50bea98f1ae 100644 --- a/submodules/PremiumUI/Sources/PremiumBoostLevelsScreen.swift +++ b/submodules/PremiumUI/Sources/PremiumBoostLevelsScreen.swift @@ -1021,7 +1021,8 @@ private final class SheetContent: CombinedComponent { state.cachedChevronImage = (generateTintedImage(image: UIImage(bundleImageName: "Settings/TextArrowRight"), color: linkColor)!, theme) } - let giftString = isGroup ? strings.Premium_Group_BoostByGiftDescription : strings.Premium_BoostByGiftDescription2 + + let giftString = isGroup ? strings.Premium_Group_BoostByGiveawayDescription : strings.Premium_BoostByGiveawayDescription let giftAttributedString = parseMarkdownIntoAttributedString(giftString, attributes: markdownAttributes).mutableCopy() as! NSMutableAttributedString if let range = giftAttributedString.string.range(of: ">"), let chevronImage = state.cachedChevronImage?.0 { diff --git a/submodules/PremiumUI/Sources/PremiumGiftCodeScreen.swift b/submodules/PremiumUI/Sources/PremiumGiftCodeScreen.swift index 33aad007493..8eb974d142b 100644 --- a/submodules/PremiumUI/Sources/PremiumGiftCodeScreen.swift +++ b/submodules/PremiumUI/Sources/PremiumGiftCodeScreen.swift @@ -614,6 +614,7 @@ private final class PremiumGiftCodeSheetComponent: CombinedComponent { )), backgroundColor: .color(environment.theme.actionSheet.opaqueItemBackgroundColor), followContentSizeChanges: true, + clipsContent: true, animateOut: animateOut ), environment: { diff --git a/submodules/PremiumUI/Sources/PremiumGiftScreen.swift b/submodules/PremiumUI/Sources/PremiumGiftScreen.swift index 453ee956af1..bc802fe14c3 100644 --- a/submodules/PremiumUI/Sources/PremiumGiftScreen.swift +++ b/submodules/PremiumUI/Sources/PremiumGiftScreen.swift @@ -879,7 +879,7 @@ private final class PremiumGiftScreenComponent: CombinedComponent { price = nil } let buttonText = presentationData.strings.Premium_Gift_GiftSubscription(price ?? "—").string - self.buttonStatePromise.set(.single(AttachmentMainButtonState(text: buttonText, font: .bold, background: .premium, textColor: .white, isVisible: true, progress: self.inProgress ? .center : .none, isEnabled: true))) + self.buttonStatePromise.set(.single(AttachmentMainButtonState(text: buttonText, font: .bold, background: .premium, textColor: .white, isVisible: true, progress: self.inProgress ? .center : .none, isEnabled: true, hasShimmer: true))) } func buy() { diff --git a/submodules/PremiumUI/Sources/PremiumLimitScreen.swift b/submodules/PremiumUI/Sources/PremiumLimitScreen.swift index 034163beb3e..b2ab0fa0d40 100644 --- a/submodules/PremiumUI/Sources/PremiumLimitScreen.swift +++ b/submodules/PremiumUI/Sources/PremiumLimitScreen.swift @@ -1609,7 +1609,7 @@ private final class LimitSheetContent: CombinedComponent { state.cachedChevronImage = (generateTintedImage(image: UIImage(bundleImageName: "Settings/TextArrowRight"), color: linkColor)!, environment.theme) } - let giftString = environment.strings.Premium_BoostByGiftDescription2 + let giftString = environment.strings.Premium_BoostByGiveawayDescription let giftAttributedString = parseMarkdownIntoAttributedString(giftString, attributes: markdownAttributes).mutableCopy() as! NSMutableAttributedString if let range = giftAttributedString.string.range(of: ">"), let chevronImage = state.cachedChevronImage?.0 { diff --git a/submodules/PremiumUI/Sources/ReplaceBoostScreen.swift b/submodules/PremiumUI/Sources/ReplaceBoostScreen.swift index 95cf1a7e156..1aefe3e2255 100644 --- a/submodules/PremiumUI/Sources/ReplaceBoostScreen.swift +++ b/submodules/PremiumUI/Sources/ReplaceBoostScreen.swift @@ -236,7 +236,7 @@ private final class ReplaceBoostScreenComponent: CombinedComponent { sideInset: 0.0, title: peer.compactDisplayTitle, peer: peer, - subtitle: subtitle, + subtitle: PeerListItemComponent.Subtitle(text: subtitle, color: .neutral), subtitleAccessory: .none, presence: nil, selectionState: hasSelection ? .editing(isSelected: state.selectedSlots.contains(boost.slot), isTinted: false) : .none, @@ -481,6 +481,7 @@ public class ReplaceBoostScreen: ViewController { inputHeight: layout.inputHeight ?? 0.0, metrics: layout.metrics, deviceMetrics: layout.deviceMetrics, + orientation: layout.metrics.orientation, isVisible: self.currentIsVisible, theme: self.presentationData.theme, strings: self.presentationData.strings, diff --git a/submodules/PremiumUI/Sources/SubscriptionsCountItem.swift b/submodules/PremiumUI/Sources/SubscriptionsCountItem.swift index 2d989a238c7..6fecfbfbe14 100644 --- a/submodules/PremiumUI/Sources/SubscriptionsCountItem.swift +++ b/submodules/PremiumUI/Sources/SubscriptionsCountItem.swift @@ -13,13 +13,15 @@ final class SubscriptionsCountItem: ListViewItem, ItemListItem { let theme: PresentationTheme let strings: PresentationStrings let value: Int32 + let values: [Int32] let sectionId: ItemListSectionId let updated: (Int32) -> Void - init(theme: PresentationTheme, strings: PresentationStrings, value: Int32, sectionId: ItemListSectionId, updated: @escaping (Int32) -> Void) { + init(theme: PresentationTheme, strings: PresentationStrings, value: Int32, values: [Int32], sectionId: ItemListSectionId, updated: @escaping (Int32) -> Void) { self.theme = theme self.strings = strings self.value = value + self.values = values self.sectionId = sectionId self.updated = updated } @@ -64,13 +66,7 @@ private final class SubscriptionsCountItemNode: ListViewItemNode { private let bottomStripeNode: ASDisplayNode private let maskNode: ASImageNode - private let label1TextNode: TextNode - private let label3TextNode: TextNode - private let label5TextNode: TextNode - private let label7TextNode: TextNode - private let label10TextNode: TextNode - private let label25TextNode: TextNode - private let label50TextNode: TextNode + private let textNodes: [TextNode] private var sliderView: TGPhotoEditorSliderView? private var item: SubscriptionsCountItem? @@ -88,43 +84,36 @@ private final class SubscriptionsCountItemNode: ListViewItemNode { self.maskNode = ASImageNode() - self.label1TextNode = TextNode() - self.label1TextNode.isUserInteractionEnabled = false - self.label1TextNode.displaysAsynchronously = false - - self.label3TextNode = TextNode() - self.label3TextNode.isUserInteractionEnabled = false - self.label3TextNode.displaysAsynchronously = false - - self.label5TextNode = TextNode() - self.label5TextNode.isUserInteractionEnabled = false - self.label5TextNode.displaysAsynchronously = false - - self.label7TextNode = TextNode() - self.label7TextNode.isUserInteractionEnabled = false - self.label7TextNode.displaysAsynchronously = false - - self.label10TextNode = TextNode() - self.label10TextNode.isUserInteractionEnabled = false - self.label10TextNode.displaysAsynchronously = false - - self.label25TextNode = TextNode() - self.label25TextNode.isUserInteractionEnabled = false - self.label25TextNode.displaysAsynchronously = false - - self.label50TextNode = TextNode() - self.label50TextNode.isUserInteractionEnabled = false - self.label50TextNode.displaysAsynchronously = false + self.textNodes = (0 ..< 10).map { _ -> TextNode in + let textNode = TextNode() + textNode.isUserInteractionEnabled = false + textNode.displaysAsynchronously = false + return textNode + } super.init(layerBacked: false, dynamicBounce: false) - self.addSubnode(self.label1TextNode) - self.addSubnode(self.label3TextNode) - self.addSubnode(self.label5TextNode) - self.addSubnode(self.label7TextNode) - self.addSubnode(self.label10TextNode) - self.addSubnode(self.label25TextNode) - self.addSubnode(self.label50TextNode) + self.textNodes.forEach(self.addSubnode) + } + + func updateSliderView() { + if let sliderView = self.sliderView, let item = self.item { + sliderView.maximumValue = CGFloat(item.values.count - 1) + sliderView.positionsCount = item.values.count + var value: Int32 = 0 + for i in 0 ..< item.values.count { + if item.values[i] >= item.value { + value = Int32(i) + break + } + } + + sliderView.value = CGFloat(value) + + sliderView.isUserInteractionEnabled = true + sliderView.alpha = 1.0 + sliderView.layer.allowsGroupOpacity = false + } } override func didLoad() { @@ -142,26 +131,14 @@ private final class SubscriptionsCountItemNode: ListViewItemNode { sliderView.useLinesForPositions = true sliderView.disablesInteractiveTransitionGestureRecognizer = true if let item = self.item, let params = self.layoutParams { - var mappedValue: Int32 = 0 - switch Int(item.value) { - case 1: - mappedValue = 0 - case 3: - mappedValue = 1 - case 5: - mappedValue = 2 - case 7: - mappedValue = 3 - case 10: - mappedValue = 4 - case 25: - mappedValue = 5 - case 50: - mappedValue = 6 - default: - mappedValue = 0 + var value: Int32 = 0 + for i in 0 ..< item.values.count { + if item.values[i] >= item.value { + value = Int32(i) + break + } } - sliderView.value = CGFloat(mappedValue) + sliderView.value = CGFloat(value) sliderView.backgroundColor = item.theme.list.itemBlocksBackgroundColor sliderView.backColor = item.theme.list.itemSwitchColors.frameColor sliderView.startColor = item.theme.list.itemSwitchColors.frameColor @@ -178,13 +155,7 @@ private final class SubscriptionsCountItemNode: ListViewItemNode { func asyncLayout() -> (_ item: SubscriptionsCountItem, _ params: ListViewItemLayoutParams, _ neighbors: ItemListNeighbors) -> (ListViewItemNodeLayout, () -> Void) { let currentItem = self.item - let makeLabel1TextLayout = TextNode.asyncLayout(self.label1TextNode) - let makeLabel3TextLayout = TextNode.asyncLayout(self.label3TextNode) - let makeLabel5TextLayout = TextNode.asyncLayout(self.label5TextNode) - let makeLabel7TextLayout = TextNode.asyncLayout(self.label7TextNode) - let makeLabel10TextLayout = TextNode.asyncLayout(self.label10TextNode) - let makeLabel25TextLayout = TextNode.asyncLayout(self.label25TextNode) - let makeLabel50TextLayout = TextNode.asyncLayout(self.label50TextNode) + let makeTextLayouts = self.textNodes.map(TextNode.asyncLayout) return { item, params, neighbors in var themeUpdated = false @@ -196,20 +167,16 @@ private final class SubscriptionsCountItemNode: ListViewItemNode { let insets: UIEdgeInsets let separatorHeight = UIScreenPixel - let (label1TextLayout, label1TextApply) = makeLabel1TextLayout(TextNodeLayoutArguments(attributedString: NSAttributedString(string: "1", font: Font.regular(13.0), textColor: item.theme.list.itemSecondaryTextColor), backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: params.width, height: CGFloat.greatestFiniteMagnitude), alignment: .center, lineSpacing: 0.0, cutout: nil, insets: UIEdgeInsets())) - - let (label3TextLayout, label3TextApply) = makeLabel3TextLayout(TextNodeLayoutArguments(attributedString: NSAttributedString(string: "3", font: Font.regular(13.0), textColor: item.theme.list.itemSecondaryTextColor), backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: params.width, height: CGFloat.greatestFiniteMagnitude), alignment: .center, lineSpacing: 0.0, cutout: nil, insets: UIEdgeInsets())) - - let (label5TextLayout, label5TextApply) = makeLabel5TextLayout(TextNodeLayoutArguments(attributedString: NSAttributedString(string: "5", font: Font.regular(13.0), textColor: item.theme.list.itemSecondaryTextColor), backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: params.width, height: CGFloat.greatestFiniteMagnitude), alignment: .center, lineSpacing: 0.0, cutout: nil, insets: UIEdgeInsets())) - - let (label7TextLayout, label7TextApply) = makeLabel7TextLayout(TextNodeLayoutArguments(attributedString: NSAttributedString(string: "7", font: Font.regular(13.0), textColor: item.theme.list.itemSecondaryTextColor), backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: params.width, height: CGFloat.greatestFiniteMagnitude), alignment: .center, lineSpacing: 0.0, cutout: nil, insets: UIEdgeInsets())) - - let (label10TextLayout, label10TextApply) = makeLabel10TextLayout(TextNodeLayoutArguments(attributedString: NSAttributedString(string: "10", font: Font.regular(13.0), textColor: item.theme.list.itemSecondaryTextColor), backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: params.width, height: CGFloat.greatestFiniteMagnitude), alignment: .center, lineSpacing: 0.0, cutout: nil, insets: UIEdgeInsets())) - - let (label25TextLayout, label25TextApply) = makeLabel25TextLayout(TextNodeLayoutArguments(attributedString: NSAttributedString(string: "25", font: Font.regular(13.0), textColor: item.theme.list.itemSecondaryTextColor), backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: params.width, height: CGFloat.greatestFiniteMagnitude), alignment: .center, lineSpacing: 0.0, cutout: nil, insets: UIEdgeInsets())) - - let (label50TextLayout, label50TextApply) = makeLabel50TextLayout(TextNodeLayoutArguments(attributedString: NSAttributedString(string: "50", font: Font.regular(13.0), textColor: item.theme.list.itemSecondaryTextColor), backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: params.width, height: CGFloat.greatestFiniteMagnitude), alignment: .center, lineSpacing: 0.0, cutout: nil, insets: UIEdgeInsets())) + var textLayoutAndApply: [(TextNodeLayout, () -> TextNode)] = [] + for i in 0 ..< item.values.count { + let value = item.values[i] + + let valueString: String = "\(value)" + let (textLayout, textApply) = makeTextLayouts[i](TextNodeLayoutArguments(attributedString: NSAttributedString(string: valueString, font: Font.regular(13.0), textColor: item.theme.list.itemSecondaryTextColor), backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: params.width, height: CGFloat.greatestFiniteMagnitude), alignment: .center, lineSpacing: 0.0, cutout: nil, insets: UIEdgeInsets())) + textLayoutAndApply.append((textLayout, textApply)) + } + contentSize = CGSize(width: params.width, height: 88.0) insets = itemListNeighborsGroupedInsets(neighbors, params) @@ -269,30 +236,31 @@ private final class SubscriptionsCountItemNode: ListViewItemNode { strongSelf.topStripeNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -min(insets.top, separatorHeight)), size: CGSize(width: layoutSize.width, height: separatorHeight)) strongSelf.bottomStripeNode.frame = CGRect(origin: CGPoint(x: bottomStripeInset, y: contentSize.height + bottomStripeOffset), size: CGSize(width: layoutSize.width - bottomStripeInset, height: separatorHeight)) - let _ = label1TextApply() - let _ = label3TextApply() - let _ = label5TextApply() - let _ = label7TextApply() - let _ = label10TextApply() - let _ = label25TextApply() - let _ = label50TextApply() + for (_, apply) in textLayoutAndApply { + let _ = apply() + } - let textNodes: [(TextNode, CGSize)] = [ - (strongSelf.label1TextNode, label1TextLayout.size), - (strongSelf.label3TextNode, label3TextLayout.size), - (strongSelf.label5TextNode, label5TextLayout.size), - (strongSelf.label7TextNode, label7TextLayout.size), - (strongSelf.label10TextNode, label10TextLayout.size), - (strongSelf.label25TextNode, label25TextLayout.size), - (strongSelf.label50TextNode, label50TextLayout.size) - ] + let textNodes: [(TextNode, CGSize)] = textLayoutAndApply.map { layout, apply -> (TextNode, CGSize) in + let node = apply() + return (node, layout.size) + } - let delta = (params.width - params.leftInset - params.rightInset - 20.0 * 2.0) / CGFloat(textNodes.count - 1) - for i in 0 ..< textNodes.count { + let delta = (params.width - params.leftInset - params.rightInset - 18.0 * 2.0) / CGFloat(max(item.values.count - 1, 1)) + for i in 0 ..< strongSelf.textNodes.count { + guard i < item.values.count else { + strongSelf.textNodes[i].isHidden = true + continue + } let (textNode, textSize) = textNodes[i] + textNode.isHidden = false + var position = params.leftInset + 18.0 + delta * CGFloat(i) + if i == textNodes.count - 1 { + position -= textSize.width / 2.0 + 2.0 + } else if i > 0 { + position -= textSize.width / 2.0 + } - let position = params.leftInset + 20.0 + delta * CGFloat(i) - textNode.frame = CGRect(origin: CGPoint(x: floorToScreenPixels(position - textSize.width / 2.0), y: 15.0), size: textSize) + textNode.frame = CGRect(origin: CGPoint(x: position, y: 15.0), size: textSize) } if let sliderView = strongSelf.sliderView { @@ -306,28 +274,7 @@ private final class SubscriptionsCountItemNode: ListViewItemNode { sliderView.frame = CGRect(origin: CGPoint(x: params.leftInset + 15.0, y: 37.0), size: CGSize(width: params.width - params.leftInset - params.rightInset - 15.0 * 2.0, height: 44.0)) sliderView.hitTestEdgeInsets = UIEdgeInsets(top: -sliderView.frame.minX, left: 0.0, bottom: 0.0, right: -sliderView.frame.minX) - var mappedValue: Int32 = 0 - switch Int(item.value) { - case 1: - mappedValue = 0 - case 3: - mappedValue = 1 - case 5: - mappedValue = 2 - case 7: - mappedValue = 3 - case 10: - mappedValue = 4 - case 25: - mappedValue = 5 - case 50: - mappedValue = 6 - default: - mappedValue = 0 - } - if Int32(sliderView.value) != mappedValue { - sliderView.value = CGFloat(mappedValue) - } + strongSelf.updateSliderView() } } }) @@ -343,30 +290,12 @@ private final class SubscriptionsCountItemNode: ListViewItemNode { } @objc func sliderValueChanged() { - guard let sliderView = self.sliderView else { + guard let sliderView = self.sliderView, let item = self.item else { return } - - var mappedValue: Int32 = 1 - switch Int(sliderView.value) { - case 0: - mappedValue = 1 - case 1: - mappedValue = 3 - case 2: - mappedValue = 5 - case 3: - mappedValue = 7 - case 4: - mappedValue = 10 - case 5: - mappedValue = 25 - case 6: - mappedValue = 50 - default: - mappedValue = 1 + let value = Int(sliderView.value) + if value >= 0 && value < item.values.count { + self.item?.updated(item.values[value]) } - - self.item?.updated(Int32(mappedValue)) } } diff --git a/submodules/ReactionSelectionNode/Sources/ReactionContextNode.swift b/submodules/ReactionSelectionNode/Sources/ReactionContextNode.swift index 5cc21aa8ff6..368de292287 100644 --- a/submodules/ReactionSelectionNode/Sources/ReactionContextNode.swift +++ b/submodules/ReactionSelectionNode/Sources/ReactionContextNode.swift @@ -2028,14 +2028,17 @@ public final class ReactionContextNode: ASDisplayNode, ASScrollViewDelegate { )) return .single(resultGroups) } else { + let remoteSignal = context.engine.stickers.searchEmoji(emojiString: Array(allEmoticons.keys)) + return combineLatest( context.account.postbox.itemCollectionsView(orderedItemListCollectionIds: [], namespaces: [Namespaces.ItemCollection.CloudEmojiPacks], aroundIndex: nil, count: 10000000) |> take(1), context.engine.stickers.availableReactions() |> take(1), hasPremium |> take(1), remotePacksSignal, + remoteSignal, localPacksSignal ) - |> map { view, availableReactions, hasPremium, foundPacks, foundLocalPacks -> [EmojiPagerContentComponent.ItemGroup] in + |> map { view, availableReactions, hasPremium, foundPacks, foundEmoji, foundLocalPacks -> [EmojiPagerContentComponent.ItemGroup] in var result: [(String, TelegramMediaFile?, String)] = [] var allEmoticons: [String: String] = [:] @@ -2045,6 +2048,21 @@ public final class ReactionContextNode: ASDisplayNode, ASScrollViewDelegate { } } + for itemFile in foundEmoji.items { + for attribute in itemFile.attributes { + switch attribute { + case let .CustomEmoji(_, _, alt, _): + if !alt.isEmpty, let keyword = allEmoticons[alt] { + result.append((alt, itemFile, keyword)) + } else if alt == query { + result.append((alt, itemFile, alt)) + } + default: + break + } + } + } + for entry in view.entries { guard let item = entry.item as? StickerPackItem else { continue @@ -2052,7 +2070,7 @@ public final class ReactionContextNode: ASDisplayNode, ASScrollViewDelegate { for attribute in item.file.attributes { switch attribute { case let .CustomEmoji(_, _, alt, _): - if !item.file.isPremiumEmoji || hasPremium { + if !item.file.isPremiumEmoji { if !alt.isEmpty, let keyword = allEmoticons[alt] { result.append((alt, item.file, keyword)) } else if alt == query { @@ -2079,7 +2097,7 @@ public final class ReactionContextNode: ASDisplayNode, ASScrollViewDelegate { animationData: animationData, content: .animation(animationData), itemFile: itemFile, subgroupId: nil, - icon: .none, + icon: (!hasPremium && itemFile.isPremiumEmoji) ? .locked : .none, tintMode: animationData.isTemplate ? .primary : .none ) items.append(item) @@ -2141,7 +2159,7 @@ public final class ReactionContextNode: ASDisplayNode, ASScrollViewDelegate { content: .animation(animationData), itemFile: item.file, subgroupId: nil, - icon: .none, + icon: (!hasPremium && item.file.isPremiumEmoji) ? .locked : .none, tintMode: tintMode ) diff --git a/submodules/ReactionSelectionNode/Sources/ReactionSelectionNode.swift b/submodules/ReactionSelectionNode/Sources/ReactionSelectionNode.swift index 79bc8bfbee5..b58c216f438 100644 --- a/submodules/ReactionSelectionNode/Sources/ReactionSelectionNode.swift +++ b/submodules/ReactionSelectionNode/Sources/ReactionSelectionNode.swift @@ -57,11 +57,13 @@ private let lockedBackgroundImage: UIImage = generateFilledCircleImage(diameter: private let lockedBadgeIcon: UIImage? = generateTintedImage(image: UIImage(bundleImageName: "Chat/Input/Media/PanelBadgeLock"), color: .white) private final class StarsButtonEffectLayer: SimpleLayer { + let gradientLayer = SimpleGradientLayer() let emitterLayer = CAEmitterLayer() override init() { super.init() + self.addSublayer(self.gradientLayer) self.addSublayer(self.emitterLayer) } @@ -73,8 +75,8 @@ private final class StarsButtonEffectLayer: SimpleLayer { fatalError("init(coder:) has not been implemented") } - private func setup() { - let color = UIColor(rgb: 0xffbe27) + private func setup(theme: PresentationTheme) { + let color = UIColor(rgb: 0xffbe27, alpha: theme.overallDarkAppearance ? 0.2 : 1.0) let emitter = CAEmitterCell() emitter.name = "emitter" @@ -101,17 +103,31 @@ private final class StarsButtonEffectLayer: SimpleLayer { emitter.setValue([staticColorBehavior], forKey: "emitterBehaviors") self.emitterLayer.emitterCells = [emitter] + + let gradientColor = UIColor(rgb: 0xffbe27, alpha: theme.overallDarkAppearance ? 0.2 : 1.0) + + self.gradientLayer.type = .radial + self.gradientLayer.startPoint = CGPoint(x: 0.5, y: 0.5) + self.gradientLayer.endPoint = CGPoint(x: 0.0, y: 1.0) + self.gradientLayer.colors = [ + gradientColor.withMultipliedAlpha(0.4).cgColor, + gradientColor.withMultipliedAlpha(0.4).cgColor, + gradientColor.withMultipliedAlpha(0.25).cgColor, + gradientColor.withMultipliedAlpha(0.0).cgColor + ] as [CGColor] } - func update(size: CGSize) { + func update(theme: PresentationTheme, size: CGSize, transition: ContainedViewLayoutTransition) { if self.emitterLayer.emitterCells == nil { - self.setup() + self.setup(theme: theme) } self.emitterLayer.emitterShape = .circle self.emitterLayer.emitterSize = CGSize(width: size.width * 0.7, height: size.height * 0.7) self.emitterLayer.emitterMode = .surface self.emitterLayer.frame = CGRect(origin: .zero, size: size) self.emitterLayer.emitterPosition = CGPoint(x: size.width / 2.0, y: size.height / 2.0) + + transition.updateFrame(layer: self.gradientLayer, frame: CGRect(origin: CGPoint(), size: size).insetBy(dx: -6.0, dy: -6.0).offsetBy(dx: 0.0, dy: 2.0)) } } @@ -307,7 +323,7 @@ public final class ReactionNode: ASDisplayNode, ReactionItemNode { if let starsEffectLayer = self.starsEffectLayer { transition.updateFrame(layer: starsEffectLayer, frame: CGRect(origin: CGPoint(), size: size)) - starsEffectLayer.update(size: size) + starsEffectLayer.update(theme: self.theme, size: size, transition: transition) } let animationSize = self.item.stillAnimation.dimensions?.cgSize ?? CGSize(width: 512.0, height: 512.0) diff --git a/submodules/SettingsUI/Sources/Privacy and Security/PrivacyAndSecurityController.swift b/submodules/SettingsUI/Sources/Privacy and Security/PrivacyAndSecurityController.swift index 56c06c7e88c..2605f5decc0 100644 --- a/submodules/SettingsUI/Sources/Privacy and Security/PrivacyAndSecurityController.swift +++ b/submodules/SettingsUI/Sources/Privacy and Security/PrivacyAndSecurityController.swift @@ -682,7 +682,8 @@ private func privacyAndSecurityControllerEntries( } else { value = privacySettings.accountRemovalTimeout } - entries.append(.accountTimeout(presentationData.theme, presentationData.strings.PrivacySettings_DeleteAccountIfAwayFor, timeIntervalString(strings: presentationData.strings, value: value))) + + entries.append(.accountTimeout(presentationData.theme, presentationData.strings.PrivacySettings_DeleteAccountIfAwayFor, presentationData.strings.MessageTimer_Months(max(1, value / (60 * 60 * 24 * 30))))) } else { entries.append(.accountTimeout(presentationData.theme, presentationData.strings.PrivacySettings_DeleteAccountIfAwayFor, presentationData.strings.Channel_NotificationLoading)) } @@ -1174,10 +1175,12 @@ public func privacyAndSecurityController( 1 * 30 * 24 * 60 * 60, 3 * 30 * 24 * 60 * 60, 6 * 30 * 24 * 60 * 60, - 365 * 24 * 60 * 60 + 365 * 24 * 60 * 60, + 548 * 24 * 60 * 60, + 730 * 24 * 60 * 60 ] var timeoutItems: [ActionSheetItem] = timeoutValues.map { value in - return ActionSheetButtonItem(title: timeIntervalString(strings: presentationData.strings, value: value), action: { + return ActionSheetButtonItem(title: presentationData.strings.MessageTimer_Months(max(1, value / (60 * 60 * 24 * 30))), action: { dismissAction() timeoutAction(value) }) diff --git a/submodules/SettingsUI/Sources/Privacy and Security/SelectivePrivacySettingsController.swift b/submodules/SettingsUI/Sources/Privacy and Security/SelectivePrivacySettingsController.swift index 33ef3ccc3ab..c5f24be14f8 100644 --- a/submodules/SettingsUI/Sources/Privacy and Security/SelectivePrivacySettingsController.swift +++ b/submodules/SettingsUI/Sources/Privacy and Security/SelectivePrivacySettingsController.swift @@ -842,7 +842,7 @@ private func selectivePrivacySettingsControllerEntries(presentationData: Present } if case .phoneNumber = kind, state.setting == .nobody { - if state.phoneDiscoveryEnabled == false { + if state.phoneDiscoveryEnabled == false || phoneNumber.hasPrefix("888") { entries.append(.phoneDiscoveryHeader(presentationData.theme, presentationData.strings.PrivacyPhoneNumberSettings_DiscoveryHeader)) entries.append(.phoneDiscoveryEverybody(presentationData.theme, presentationData.strings.PrivacySettings_LastSeenEverybody, state.phoneDiscoveryEnabled != false)) entries.append(.phoneDiscoveryMyContacts(presentationData.theme, presentationData.strings.PrivacySettings_LastSeenContacts, state.phoneDiscoveryEnabled == false)) diff --git a/submodules/SparseItemGrid/Sources/SparseItemGrid.swift b/submodules/SparseItemGrid/Sources/SparseItemGrid.swift index 9b8015eaf4a..44c13bb3074 100644 --- a/submodules/SparseItemGrid/Sources/SparseItemGrid.swift +++ b/submodules/SparseItemGrid/Sources/SparseItemGrid.swift @@ -1743,6 +1743,15 @@ public final class SparseItemGrid: ASDisplayNode { } items.itemBinding.onTagTap() } + self.scrollingArea.isDecelerating = { [weak self] in + guard let self else { + return false + } + guard let currentViewport = self.currentViewport else { + return false + } + return currentViewport.scrollView.isDecelerating + } } @objc private func tapGesture(_ recognizer: UITapGestureRecognizer) { diff --git a/submodules/SparseItemGrid/Sources/SparseItemGridScrollingArea.swift b/submodules/SparseItemGrid/Sources/SparseItemGridScrollingArea.swift index b8068314b56..4b5eba44374 100644 --- a/submodules/SparseItemGrid/Sources/SparseItemGridScrollingArea.swift +++ b/submodules/SparseItemGrid/Sources/SparseItemGridScrollingArea.swift @@ -838,16 +838,25 @@ final class SparseItemGridScrollingIndicatorComponent: CombinedComponent { } public final class SparseItemGridScrollingArea: ASDisplayNode { + private struct ShouldBegin { + var shouldDelay: Bool + + init(shouldDelay: Bool) { + self.shouldDelay = shouldDelay + } + } + private final class DragGesture: UIGestureRecognizer { - private let shouldBegin: (CGPoint) -> Bool + private let shouldBegin: (CGPoint) -> ShouldBegin? private let began: () -> Void private let ended: () -> Void private let moved: (CGFloat) -> Void private var initialLocation: CGPoint? + private var beginDelayTimer: SwiftSignalKit.Timer? public init( - shouldBegin: @escaping (CGPoint) -> Bool, + shouldBegin: @escaping (CGPoint) -> ShouldBegin?, began: @escaping () -> Void, ended: @escaping () -> Void, moved: @escaping (CGFloat) -> Void @@ -868,6 +877,9 @@ public final class SparseItemGridScrollingArea: ASDisplayNode { self.initialLocation = nil self.initialLocation = nil + + self.beginDelayTimer?.invalidate() + self.beginDelayTimer = nil } override public func touchesBegan(_ touches: Set, with event: UIEvent) { @@ -881,10 +893,29 @@ public final class SparseItemGridScrollingArea: ASDisplayNode { if self.state == .possible { if let location = touches.first?.location(in: self.view) { - if self.shouldBegin(location) { + if let shouldBeginValue = self.shouldBegin(location) { self.initialLocation = location - self.state = .began - self.began() + + if shouldBeginValue.shouldDelay { + self.state = .began + if self.beginDelayTimer == nil { + self.beginDelayTimer = SwiftSignalKit.Timer(timeout: 0.2, repeat: false, completion: { [weak self] in + guard let self else { + return + } + + self.beginDelayTimer = nil + + if self.initialLocation != nil { + self.began() + } + }, queue: .mainQueue()) + self.beginDelayTimer?.start() + } + } else { + self.state = .began + self.began() + } } else { self.state = .failed } @@ -919,10 +950,20 @@ public final class SparseItemGridScrollingArea: ASDisplayNode { override public func touchesMoved(_ touches: Set, with event: UIEvent) { super.touchesMoved(touches, with: event) - if (self.state == .began || self.state == .changed), let initialLocation = self.initialLocation, let location = touches.first?.location(in: self.view) { - self.state = .changed + if let initialLocation = self.initialLocation, let location = touches.first?.location(in: self.view) { let offset = location.y - initialLocation.y - self.moved(offset) + + if self.beginDelayTimer != nil { + if abs(offset) > 16.0 { + self.ended() + self.state = .failed + } else { + self.initialLocation = location + } + } else if (self.state == .began || self.state == .changed) { + self.state = .changed + self.moved(offset) + } } } } @@ -951,6 +992,7 @@ public final class SparseItemGridScrollingArea: ASDisplayNode { public var finishedScrolling: (() -> Void)? public var setContentOffset: ((CGPoint) -> Void)? public var openCurrentDate: (() -> Void)? + public var isDecelerating: (() -> Bool)? private var offsetBarTimer: SwiftSignalKit.Timer? private var beganAtDateIndicator = false @@ -1002,7 +1044,7 @@ public final class SparseItemGridScrollingArea: ASDisplayNode { let dragGesture = DragGesture( shouldBegin: { [weak self] point in guard let strongSelf = self else { - return false + return nil } if strongSelf.dateIndicator.frame.contains(point) { @@ -1011,7 +1053,7 @@ public final class SparseItemGridScrollingArea: ASDisplayNode { strongSelf.beganAtDateIndicator = false } - return true + return ShouldBegin(shouldDelay: strongSelf.isDecelerating?() ?? false) }, began: { [weak self] in guard let strongSelf = self else { @@ -1115,6 +1157,7 @@ public final class SparseItemGridScrollingArea: ASDisplayNode { } private var currentDate: (String, Int32)? + private var isScrolling: Bool = false public func update( containerSize: CGSize, @@ -1130,6 +1173,8 @@ public final class SparseItemGridScrollingArea: ASDisplayNode { self.theme = theme let previousDate = self.currentDate self.currentDate = date + + self.isScrolling = isScrolling if self.dateIndicator.alpha.isZero { let transition: ContainedViewLayoutTransition = .immediate diff --git a/submodules/StatisticsUI/Sources/ChannelStatsController.swift b/submodules/StatisticsUI/Sources/ChannelStatsController.swift index ca1656d854d..177019657a5 100644 --- a/submodules/StatisticsUI/Sources/ChannelStatsController.swift +++ b/submodules/StatisticsUI/Sources/ChannelStatsController.swift @@ -962,7 +962,7 @@ private enum StatsEntry: ItemListNodeEntry { case let .booster(_, _, _, boost): let count = boost.multiplier let expiresValue = stringForDate(timestamp: boost.expires, strings: presentationData.strings) - let expiresString: String + var expiresString: String let durationMonths = Int32(round(Float(boost.expires - boost.date) / (86400.0 * 30.0))) let durationString = presentationData.strings.Stats_Boosts_ShortMonth("\(durationMonths)").string @@ -998,17 +998,23 @@ private enum StatsEntry: ItemListNodeEntry { expiresString = presentationData.strings.Stats_Boosts_ExpiresOn(expiresValue).string } } else { + expiresString = "\(durationString) • \(expiresValue)" if boost.flags.contains(.isUnclaimed) { title = presentationData.strings.Stats_Boosts_Unclaimed icon = .image(color: color, name: "Premium/Unclaimed") } else if boost.flags.contains(.isGiveaway) { - title = presentationData.strings.Stats_Boosts_ToBeDistributed - icon = .image(color: color, name: "Premium/ToBeDistributed") + if let stars = boost.stars { + title = presentationData.strings.Stats_Boosts_Stars(Int32(stars)) + icon = .image(color: .stars, name: "Premium/PremiumStar") + expiresString = expiresValue + } else { + title = presentationData.strings.Stats_Boosts_ToBeDistributed + icon = .image(color: color, name: "Premium/ToBeDistributed") + } } else { title = "Unknown" icon = .image(color: color, name: "Premium/ToBeDistributed") } - expiresString = "\(durationString) • \(expiresValue)" } return GiftOptionItem(presentationData: presentationData, context: arguments.context, icon: icon, title: title, titleFont: .bold, titleBadge: count > 1 ? "\(count)" : nil, subtitle: expiresString, label: label.flatMap { .semitransparent($0) }, sectionId: self.section, action: { arguments.openBoost(boost) @@ -1038,17 +1044,28 @@ private enum StatsEntry: ItemListNodeEntry { }) case let .boostPrepaid(_, _, title, subtitle, prepaidGiveaway): let color: GiftOptionItem.Icon.Color - switch prepaidGiveaway.months { - case 3: - color = .green - case 6: - color = .blue - case 12: - color = .red - default: - color = .blue - } - return GiftOptionItem(presentationData: presentationData, context: arguments.context, icon: .image(color: color, name: "Premium/Giveaway"), title: title, titleFont: .bold, titleBadge: "\(prepaidGiveaway.quantity * 4)", subtitle: subtitle, label: nil, sectionId: self.section, action: { + let icon: String + var boosts: Int32 + switch prepaidGiveaway.prize { + case let .premium(months): + switch months { + case 3: + color = .green + case 6: + color = .blue + case 12: + color = .red + default: + color = .blue + } + icon = "Premium/Giveaway" + boosts = prepaidGiveaway.quantity * 4 + case let .stars(_, boostCount): + color = .stars + icon = "Premium/PremiumStar" + boosts = boostCount + } + return GiftOptionItem(presentationData: presentationData, context: arguments.context, icon: .image(color: color, name: icon), title: title, titleFont: .bold, titleBadge: "\(boosts)", subtitle: subtitle, label: nil, sectionId: self.section, action: { arguments.createPrepaidGiveaway(prepaidGiveaway) }) case let .adsHeader(_, text): @@ -1423,7 +1440,17 @@ private func boostsEntries( entries.append(.boostPrepaidTitle(presentationData.theme, presentationData.strings.Stats_Boosts_PrepaidGiveawaysTitle)) var i: Int32 = 0 for giveaway in boostData.prepaidGiveaways { - entries.append(.boostPrepaid(i, presentationData.theme, presentationData.strings.Stats_Boosts_PrepaidGiveawayCount(giveaway.quantity), presentationData.strings.Stats_Boosts_PrepaidGiveawayMonths("\(giveaway.months)").string, giveaway)) + let title: String + let text: String + switch giveaway.prize { + case let .premium(months): + title = presentationData.strings.Stats_Boosts_PrepaidGiveawayCount(giveaway.quantity) + text = presentationData.strings.Stats_Boosts_PrepaidGiveawayMonths("\(months)").string + case let .stars(stars, _): + title = presentationData.strings.Stats_Boosts_Stars(Int32(stars)) + text = presentationData.strings.Stats_Boosts_StarsWinners(giveaway.quantity) + } + entries.append(.boostPrepaid(i, presentationData.theme, title, text, giveaway)) i += 1 } entries.append(.boostPrepaidInfo(presentationData.theme, presentationData.strings.Stats_Boosts_PrepaidGiveawaysInfo)) @@ -1576,7 +1603,7 @@ private func monetizationEntries( if canViewRevenue { entries.append(.adsTonBalanceTitle(presentationData.theme, presentationData.strings.Monetization_TonBalanceTitle)) - entries.append(.adsTonBalance(presentationData.theme, data, isCreator && data.balances.availableBalance > 0, monetizationConfiguration.withdrawalAvailable)) + entries.append(.adsTonBalance(presentationData.theme, data, isCreator && data.balances.availableBalance > 0, data.balances.withdrawEnabled)) if isCreator { let withdrawalInfoText: String @@ -1921,8 +1948,13 @@ public func channelStatsController(context: AccountContext, updatedPresentationD } if boost.peer == nil, boost.flags.contains(.isGiveaway) && !boost.flags.contains(.isUnclaimed) { - let presentationData = context.sharedContext.currentPresentationData.with { $0 } - presentImpl?(UndoOverlayController(presentationData: presentationData, content: .info(title: nil, text: presentationData.strings.Stats_Boosts_TooltipToBeDistributed, timeout: nil, customUndoText: nil), elevatedLayout: false, animateInAsReplacement: false, action: { _ in return false })) + if let _ = boost.stars { + let controller = context.sharedContext.makeStarsGiveawayBoostScreen(context: context, peerId: peerId, boost: boost) + pushImpl?(controller) + } else { + let presentationData = context.sharedContext.currentPresentationData.with { $0 } + presentImpl?(UndoOverlayController(presentationData: presentationData, content: .info(title: nil, text: presentationData.strings.Stats_Boosts_TooltipToBeDistributed, timeout: nil, customUndoText: nil), elevatedLayout: false, animateInAsReplacement: false, action: { _ in return false })) + } return } diff --git a/submodules/StatisticsUI/Sources/StatsGraphItem.swift b/submodules/StatisticsUI/Sources/StatsGraphItem.swift index c462f1bd27e..7f8d1c8517a 100644 --- a/submodules/StatisticsUI/Sources/StatsGraphItem.swift +++ b/submodules/StatisticsUI/Sources/StatsGraphItem.swift @@ -295,7 +295,16 @@ public final class StatsGraphItemNode: ListViewItemNode { strongSelf.activityIndicator.type = .custom(item.presentationData.theme.list.itemSecondaryTextColor, 16.0, 2.0, false) if let updatedTheme = updatedTheme { - strongSelf.chartNode.setup(theme: ChartTheme(presentationTheme: updatedTheme), strings: ChartStrings(zoomOut: item.presentationData.strings.Stats_ZoomOut, total: item.presentationData.strings.Stats_Total)) + strongSelf.chartNode.setup( + theme: ChartTheme(presentationTheme: updatedTheme), + strings: ChartStrings( + zoomOut: item.presentationData.strings.Stats_ZoomOut, + total: item.presentationData.strings.Stats_Total, + revenueInTon: item.presentationData.strings.Stats_RevenueInTon, + revenueInStars: item.presentationData.strings.Stats_RevenueInStars, + revenueInUsd: item.presentationData.strings.Stats_RevenueInUsd + ) + ) } if let updatedGraph = updatedGraph { diff --git a/submodules/StatisticsUI/Sources/StatsOverviewItem.swift b/submodules/StatisticsUI/Sources/StatsOverviewItem.swift index 7df6ea96f50..3a59f660130 100644 --- a/submodules/StatisticsUI/Sources/StatsOverviewItem.swift +++ b/submodules/StatisticsUI/Sources/StatsOverviewItem.swift @@ -873,7 +873,7 @@ class StatsOverviewItemNode: ListViewItemNode { item.presentationData, presentationStringsFormattedNumber(Int32(stats.balances.availableBalance), item.presentationData.dateTimeFormat.groupingSeparator), item.presentationData.strings.Monetization_StarsProceeds_Available, - (stats.balances.availableBalance == 0 ? "" : "≈\(formatTonUsdValue(stats.balances.availableBalance, rate: stats.usdRate, dateTimeFormat: item.presentationData.dateTimeFormat))", .generic), + (stats.balances.availableBalance == 0 ? "" : "≈\(formatTonUsdValue(stats.balances.availableBalance, divide: false, rate: stats.usdRate, dateTimeFormat: item.presentationData.dateTimeFormat))", .generic), .stars ) @@ -883,7 +883,7 @@ class StatsOverviewItemNode: ListViewItemNode { item.presentationData, presentationStringsFormattedNumber(Int32(stats.balances.currentBalance), item.presentationData.dateTimeFormat.groupingSeparator), item.presentationData.strings.Monetization_StarsProceeds_Current, - (stats.balances.currentBalance == 0 ? "" : "≈\(formatTonUsdValue(stats.balances.currentBalance, rate: stats.usdRate, dateTimeFormat: item.presentationData.dateTimeFormat))", .generic), + (stats.balances.currentBalance == 0 ? "" : "≈\(formatTonUsdValue(stats.balances.currentBalance, divide: false, rate: stats.usdRate, dateTimeFormat: item.presentationData.dateTimeFormat))", .generic), .stars ) @@ -893,7 +893,7 @@ class StatsOverviewItemNode: ListViewItemNode { item.presentationData, presentationStringsFormattedNumber(Int32(stats.balances.overallRevenue), item.presentationData.dateTimeFormat.groupingSeparator), item.presentationData.strings.Monetization_StarsProceeds_Total, - (stats.balances.overallRevenue == 0 ? "" : "≈\(formatTonUsdValue(stats.balances.overallRevenue, rate: stats.usdRate, dateTimeFormat: item.presentationData.dateTimeFormat))", .generic), + (stats.balances.overallRevenue == 0 ? "" : "≈\(formatTonUsdValue(stats.balances.overallRevenue, divide: false, rate: stats.usdRate, dateTimeFormat: item.presentationData.dateTimeFormat))", .generic), .stars ) diff --git a/submodules/TelegramApi/Sources/Api0.swift b/submodules/TelegramApi/Sources/Api0.swift index 0eeb02d587e..f093c8b3303 100644 --- a/submodules/TelegramApi/Sources/Api0.swift +++ b/submodules/TelegramApi/Sources/Api0.swift @@ -80,7 +80,7 @@ fileprivate let parsers: [Int32 : (BufferReader) -> Any?] = { dict[1821253126] = { return Api.Birthday.parse_birthday($0) } dict[-1132882121] = { return Api.Bool.parse_boolFalse($0) } dict[-1720552011] = { return Api.Bool.parse_boolTrue($0) } - dict[706514033] = { return Api.Boost.parse_boost($0) } + dict[1262359766] = { return Api.Boost.parse_boost($0) } dict[-1778593322] = { return Api.BotApp.parse_botApp($0) } dict[1571189943] = { return Api.BotApp.parse_botAppNotModified($0) } dict[-1989921868] = { return Api.BotBusinessConnection.parse_botBusinessConnection($0) } @@ -106,7 +106,7 @@ fileprivate let parsers: [Int32 : (BufferReader) -> Any?] = { dict[1113113093] = { return Api.BotMenuButton.parse_botMenuButtonCommands($0) } dict[1966318984] = { return Api.BotMenuButton.parse_botMenuButtonDefault($0) } dict[602479523] = { return Api.BotPreviewMedia.parse_botPreviewMedia($0) } - dict[-2076642874] = { return Api.BroadcastRevenueBalances.parse_broadcastRevenueBalances($0) } + dict[-1006669337] = { return Api.BroadcastRevenueBalances.parse_broadcastRevenueBalances($0) } dict[1434332356] = { return Api.BroadcastRevenueTransaction.parse_broadcastRevenueTransactionProceeds($0) } dict[1121127726] = { return Api.BroadcastRevenueTransaction.parse_broadcastRevenueTransactionRefund($0) } dict[1515784568] = { return Api.BroadcastRevenueTransaction.parse_broadcastRevenueTransactionWithdrawal($0) } @@ -156,6 +156,7 @@ fileprivate let parsers: [Int32 : (BufferReader) -> Any?] = { dict[-1347021750] = { return Api.ChannelAdminLogEventAction.parse_channelAdminLogEventActionParticipantJoinByRequest($0) } dict[-124291086] = { return Api.ChannelAdminLogEventAction.parse_channelAdminLogEventActionParticipantLeave($0) } dict[-115071790] = { return Api.ChannelAdminLogEventAction.parse_channelAdminLogEventActionParticipantMute($0) } + dict[1684286899] = { return Api.ChannelAdminLogEventAction.parse_channelAdminLogEventActionParticipantSubExtend($0) } dict[-714643696] = { return Api.ChannelAdminLogEventAction.parse_channelAdminLogEventActionParticipantToggleAdmin($0) } dict[-422036098] = { return Api.ChannelAdminLogEventAction.parse_channelAdminLogEventActionParticipantToggleBan($0) } dict[-431740480] = { return Api.ChannelAdminLogEventAction.parse_channelAdminLogEventActionParticipantUnmute($0) } @@ -387,7 +388,7 @@ fileprivate let parsers: [Int32 : (BufferReader) -> Any?] = { dict[-1759532989] = { return Api.InputMedia.parse_inputMediaGeoLive($0) } dict[-104578748] = { return Api.InputMedia.parse_inputMediaGeoPoint($0) } dict[1080028941] = { return Api.InputMedia.parse_inputMediaInvoice($0) } - dict[-1436147773] = { return Api.InputMedia.parse_inputMediaPaidMedia($0) } + dict[-1005571194] = { return Api.InputMedia.parse_inputMediaPaidMedia($0) } dict[-1279654347] = { return Api.InputMedia.parse_inputMediaPhoto($0) } dict[-440664550] = { return Api.InputMedia.parse_inputMediaPhotoExternal($0) } dict[261416433] = { return Api.InputMedia.parse_inputMediaPoll($0) } @@ -469,6 +470,7 @@ fileprivate let parsers: [Int32 : (BufferReader) -> Any?] = { dict[369444042] = { return Api.InputStorePaymentPurpose.parse_inputStorePaymentPremiumGiveaway($0) } dict[-1502273946] = { return Api.InputStorePaymentPurpose.parse_inputStorePaymentPremiumSubscription($0) } dict[494149367] = { return Api.InputStorePaymentPurpose.parse_inputStorePaymentStarsGift($0) } + dict[1964968186] = { return Api.InputStorePaymentPurpose.parse_inputStorePaymentStarsGiveaway($0) } dict[-572715178] = { return Api.InputStorePaymentPurpose.parse_inputStorePaymentStarsTopup($0) } dict[1012306921] = { return Api.InputTheme.parse_inputTheme($0) } dict[-175567375] = { return Api.InputTheme.parse_inputThemeSlug($0) } @@ -550,8 +552,8 @@ fileprivate let parsers: [Int32 : (BufferReader) -> Any?] = { dict[1737240073] = { return Api.MessageAction.parse_messageActionGiftCode($0) } dict[-935499028] = { return Api.MessageAction.parse_messageActionGiftPremium($0) } dict[1171632161] = { return Api.MessageAction.parse_messageActionGiftStars($0) } - dict[858499565] = { return Api.MessageAction.parse_messageActionGiveawayLaunch($0) } - dict[715107781] = { return Api.MessageAction.parse_messageActionGiveawayResults($0) } + dict[-1475391004] = { return Api.MessageAction.parse_messageActionGiveawayLaunch($0) } + dict[-2015170219] = { return Api.MessageAction.parse_messageActionGiveawayResults($0) } dict[2047704898] = { return Api.MessageAction.parse_messageActionGroupCall($0) } dict[-1281329567] = { return Api.MessageAction.parse_messageActionGroupCallScheduled($0) } dict[-1615153660] = { return Api.MessageAction.parse_messageActionHistoryClear($0) } @@ -561,6 +563,7 @@ fileprivate let parsers: [Int32 : (BufferReader) -> Any?] = { dict[-1892568281] = { return Api.MessageAction.parse_messageActionPaymentSentMe($0) } dict[-2132731265] = { return Api.MessageAction.parse_messageActionPhoneCall($0) } dict[-1799538451] = { return Api.MessageAction.parse_messageActionPinMessage($0) } + dict[-1341372510] = { return Api.MessageAction.parse_messageActionPrizeStars($0) } dict[827428507] = { return Api.MessageAction.parse_messageActionRequestedPeer($0) } dict[-1816979384] = { return Api.MessageAction.parse_messageActionRequestedPeerSentMe($0) } dict[1200788123] = { return Api.MessageAction.parse_messageActionScreenshotTaken($0) } @@ -605,8 +608,8 @@ fileprivate let parsers: [Int32 : (BufferReader) -> Any?] = { dict[-38694904] = { return Api.MessageMedia.parse_messageMediaGame($0) } dict[1457575028] = { return Api.MessageMedia.parse_messageMediaGeo($0) } dict[-1186937242] = { return Api.MessageMedia.parse_messageMediaGeoLive($0) } - dict[-626162256] = { return Api.MessageMedia.parse_messageMediaGiveaway($0) } - dict[-963047320] = { return Api.MessageMedia.parse_messageMediaGiveawayResults($0) } + dict[-1442366485] = { return Api.MessageMedia.parse_messageMediaGiveaway($0) } + dict[-827703647] = { return Api.MessageMedia.parse_messageMediaGiveawayResults($0) } dict[-156940077] = { return Api.MessageMedia.parse_messageMediaInvoice($0) } dict[-1467669359] = { return Api.MessageMedia.parse_messageMediaPaidMedia($0) } dict[1766936791] = { return Api.MessageMedia.parse_messageMediaPhoto($0) } @@ -743,6 +746,7 @@ fileprivate let parsers: [Int32 : (BufferReader) -> Any?] = { dict[1958953753] = { return Api.PremiumGiftOption.parse_premiumGiftOption($0) } dict[1596792306] = { return Api.PremiumSubscriptionOption.parse_premiumSubscriptionOption($0) } dict[-1303143084] = { return Api.PrepaidGiveaway.parse_prepaidGiveaway($0) } + dict[-1700956192] = { return Api.PrepaidGiveaway.parse_prepaidStarsGiveaway($0) } dict[-1534675103] = { return Api.PrivacyKey.parse_privacyKeyAbout($0) } dict[1124062251] = { return Api.PrivacyKey.parse_privacyKeyAddedByPhone($0) } dict[536913176] = { return Api.PrivacyKey.parse_privacyKeyBirthday($0) } @@ -884,11 +888,13 @@ fileprivate let parsers: [Int32 : (BufferReader) -> Any?] = { dict[1301522832] = { return Api.SponsoredMessage.parse_sponsoredMessage($0) } dict[1124938064] = { return Api.SponsoredMessageReportOption.parse_sponsoredMessageReportOption($0) } dict[1577421297] = { return Api.StarsGiftOption.parse_starsGiftOption($0) } + dict[-1798404822] = { return Api.StarsGiveawayOption.parse_starsGiveawayOption($0) } + dict[1411605001] = { return Api.StarsGiveawayWinnersOption.parse_starsGiveawayWinnersOption($0) } dict[2033461574] = { return Api.StarsRevenueStatus.parse_starsRevenueStatus($0) } dict[1401868056] = { return Api.StarsSubscription.parse_starsSubscription($0) } dict[88173912] = { return Api.StarsSubscriptionPricing.parse_starsSubscriptionPricing($0) } dict[198776256] = { return Api.StarsTopupOption.parse_starsTopupOption($0) } - dict[1127934763] = { return Api.StarsTransaction.parse_starsTransaction($0) } + dict[-294313259] = { return Api.StarsTransaction.parse_starsTransaction($0) } dict[-670195363] = { return Api.StarsTransactionPeer.parse_starsTransactionPeer($0) } dict[1617438738] = { return Api.StarsTransactionPeer.parse_starsTransactionPeerAds($0) } dict[-1269320843] = { return Api.StarsTransactionPeer.parse_starsTransactionPeerAppStore($0) } @@ -956,6 +962,7 @@ fileprivate let parsers: [Int32 : (BufferReader) -> Any?] = { dict[164329305] = { return Api.Update.parse_updateBotMessageReactions($0) } dict[-1646578564] = { return Api.Update.parse_updateBotNewBusinessMessage($0) } dict[-1934976362] = { return Api.Update.parse_updateBotPrecheckoutQuery($0) } + dict[675009298] = { return Api.Update.parse_updateBotPurchasedPaidMedia($0) } dict[-1246823043] = { return Api.Update.parse_updateBotShippingQuery($0) } dict[-997782967] = { return Api.Update.parse_updateBotStopped($0) } dict[-2095595325] = { return Api.Update.parse_updateBotWebhookJSON($0) } @@ -1026,6 +1033,7 @@ fileprivate let parsers: [Int32 : (BufferReader) -> Any?] = { dict[1753886890] = { return Api.Update.parse_updateNewStickerSet($0) } dict[405070859] = { return Api.Update.parse_updateNewStoryReaction($0) } dict[-1094555409] = { return Api.Update.parse_updateNotifySettings($0) } + dict[1372224236] = { return Api.Update.parse_updatePaidReactionPrivacy($0) } dict[-337610926] = { return Api.Update.parse_updatePeerBlocked($0) } dict[-1147422299] = { return Api.Update.parse_updatePeerHistoryTTL($0) } dict[-1263546448] = { return Api.Update.parse_updatePeerLocated($0) } @@ -1322,7 +1330,7 @@ fileprivate let parsers: [Int32 : (BufferReader) -> Any?] = { dict[675942550] = { return Api.payments.CheckedGiftCode.parse_checkedGiftCode($0) } dict[-1362048039] = { return Api.payments.ExportedInvoice.parse_exportedInvoice($0) } dict[1130879648] = { return Api.payments.GiveawayInfo.parse_giveawayInfo($0) } - dict[13456752] = { return Api.payments.GiveawayInfo.parse_giveawayInfoResults($0) } + dict[-512366993] = { return Api.payments.GiveawayInfo.parse_giveawayInfoResults($0) } dict[-1610250415] = { return Api.payments.PaymentForm.parse_paymentForm($0) } dict[2079764828] = { return Api.payments.PaymentForm.parse_paymentFormStars($0) } dict[1891958275] = { return Api.payments.PaymentReceipt.parse_paymentReceipt($0) } @@ -1408,7 +1416,7 @@ public extension Api { return parser(reader) } else { - telegramApiLog("Type constructor \(String(signature, radix: 16, uppercase: false)) not found") + telegramApiLog("Type constructor \(String(UInt32(bitPattern: signature), radix: 16, uppercase: false)) not found") return nil } } @@ -2006,6 +2014,10 @@ public extension Api { _1.serialize(buffer, boxed) case let _1 as Api.StarsGiftOption: _1.serialize(buffer, boxed) + case let _1 as Api.StarsGiveawayOption: + _1.serialize(buffer, boxed) + case let _1 as Api.StarsGiveawayWinnersOption: + _1.serialize(buffer, boxed) case let _1 as Api.StarsRevenueStatus: _1.serialize(buffer, boxed) case let _1 as Api.StarsSubscription: diff --git a/submodules/TelegramApi/Sources/Api1.swift b/submodules/TelegramApi/Sources/Api1.swift index 2a79ef6f9e1..bfb77ee42c7 100644 --- a/submodules/TelegramApi/Sources/Api1.swift +++ b/submodules/TelegramApi/Sources/Api1.swift @@ -962,13 +962,13 @@ public extension Api { } public extension Api { enum Boost: TypeConstructorDescription { - case boost(flags: Int32, id: String, userId: Int64?, giveawayMsgId: Int32?, date: Int32, expires: Int32, usedGiftSlug: String?, multiplier: Int32?) + case boost(flags: Int32, id: String, userId: Int64?, giveawayMsgId: Int32?, date: Int32, expires: Int32, usedGiftSlug: String?, multiplier: Int32?, stars: Int64?) public func serialize(_ buffer: Buffer, _ boxed: Swift.Bool) { switch self { - case .boost(let flags, let id, let userId, let giveawayMsgId, let date, let expires, let usedGiftSlug, let multiplier): + case .boost(let flags, let id, let userId, let giveawayMsgId, let date, let expires, let usedGiftSlug, let multiplier, let stars): if boxed { - buffer.appendInt32(706514033) + buffer.appendInt32(1262359766) } serializeInt32(flags, buffer: buffer, boxed: false) serializeString(id, buffer: buffer, boxed: false) @@ -978,14 +978,15 @@ public extension Api { serializeInt32(expires, buffer: buffer, boxed: false) if Int(flags) & Int(1 << 4) != 0 {serializeString(usedGiftSlug!, buffer: buffer, boxed: false)} if Int(flags) & Int(1 << 5) != 0 {serializeInt32(multiplier!, buffer: buffer, boxed: false)} + if Int(flags) & Int(1 << 6) != 0 {serializeInt64(stars!, buffer: buffer, boxed: false)} break } } public func descriptionFields() -> (String, [(String, Any)]) { switch self { - case .boost(let flags, let id, let userId, let giveawayMsgId, let date, let expires, let usedGiftSlug, let multiplier): - return ("boost", [("flags", flags as Any), ("id", id as Any), ("userId", userId as Any), ("giveawayMsgId", giveawayMsgId as Any), ("date", date as Any), ("expires", expires as Any), ("usedGiftSlug", usedGiftSlug as Any), ("multiplier", multiplier as Any)]) + case .boost(let flags, let id, let userId, let giveawayMsgId, let date, let expires, let usedGiftSlug, let multiplier, let stars): + return ("boost", [("flags", flags as Any), ("id", id as Any), ("userId", userId as Any), ("giveawayMsgId", giveawayMsgId as Any), ("date", date as Any), ("expires", expires as Any), ("usedGiftSlug", usedGiftSlug as Any), ("multiplier", multiplier as Any), ("stars", stars as Any)]) } } @@ -1006,6 +1007,8 @@ public extension Api { if Int(_1!) & Int(1 << 4) != 0 {_7 = parseString(reader) } var _8: Int32? if Int(_1!) & Int(1 << 5) != 0 {_8 = reader.readInt32() } + var _9: Int64? + if Int(_1!) & Int(1 << 6) != 0 {_9 = reader.readInt64() } let _c1 = _1 != nil let _c2 = _2 != nil let _c3 = (Int(_1!) & Int(1 << 0) == 0) || _3 != nil @@ -1014,8 +1017,9 @@ public extension Api { let _c6 = _6 != nil let _c7 = (Int(_1!) & Int(1 << 4) == 0) || _7 != nil let _c8 = (Int(_1!) & Int(1 << 5) == 0) || _8 != nil - if _c1 && _c2 && _c3 && _c4 && _c5 && _c6 && _c7 && _c8 { - return Api.Boost.boost(flags: _1!, id: _2!, userId: _3, giveawayMsgId: _4, date: _5!, expires: _6!, usedGiftSlug: _7, multiplier: _8) + let _c9 = (Int(_1!) & Int(1 << 6) == 0) || _9 != nil + if _c1 && _c2 && _c3 && _c4 && _c5 && _c6 && _c7 && _c8 && _c9 { + return Api.Boost.boost(flags: _1!, id: _2!, userId: _3, giveawayMsgId: _4, date: _5!, expires: _6!, usedGiftSlug: _7, multiplier: _8, stars: _9) } else { return nil diff --git a/submodules/TelegramApi/Sources/Api10.swift b/submodules/TelegramApi/Sources/Api10.swift index 4b7744a8018..ed8285c2637 100644 --- a/submodules/TelegramApi/Sources/Api10.swift +++ b/submodules/TelegramApi/Sources/Api10.swift @@ -141,7 +141,7 @@ public extension Api { case inputMediaGeoLive(flags: Int32, geoPoint: Api.InputGeoPoint, heading: Int32?, period: Int32?, proximityNotificationRadius: Int32?) case inputMediaGeoPoint(geoPoint: Api.InputGeoPoint) case inputMediaInvoice(flags: Int32, title: String, description: String, photo: Api.InputWebDocument?, invoice: Api.Invoice, payload: Buffer, provider: String?, providerData: Api.DataJSON, startParam: String?, extendedMedia: Api.InputMedia?) - case inputMediaPaidMedia(starsAmount: Int64, extendedMedia: [Api.InputMedia]) + case inputMediaPaidMedia(flags: Int32, starsAmount: Int64, extendedMedia: [Api.InputMedia], payload: String?) case inputMediaPhoto(flags: Int32, id: Api.InputPhoto, ttlSeconds: Int32?) case inputMediaPhotoExternal(flags: Int32, url: String, ttlSeconds: Int32?) case inputMediaPoll(flags: Int32, poll: Api.Poll, correctAnswers: [Buffer]?, solution: String?, solutionEntities: [Api.MessageEntity]?) @@ -228,16 +228,18 @@ public extension Api { if Int(flags) & Int(1 << 1) != 0 {serializeString(startParam!, buffer: buffer, boxed: false)} if Int(flags) & Int(1 << 2) != 0 {extendedMedia!.serialize(buffer, true)} break - case .inputMediaPaidMedia(let starsAmount, let extendedMedia): + case .inputMediaPaidMedia(let flags, let starsAmount, let extendedMedia, let payload): if boxed { - buffer.appendInt32(-1436147773) + buffer.appendInt32(-1005571194) } + serializeInt32(flags, buffer: buffer, boxed: false) serializeInt64(starsAmount, buffer: buffer, boxed: false) buffer.appendInt32(481674261) buffer.appendInt32(Int32(extendedMedia.count)) for item in extendedMedia { item.serialize(buffer, true) } + if Int(flags) & Int(1 << 0) != 0 {serializeString(payload!, buffer: buffer, boxed: false)} break case .inputMediaPhoto(let flags, let id, let ttlSeconds): if boxed { @@ -354,8 +356,8 @@ public extension Api { return ("inputMediaGeoPoint", [("geoPoint", geoPoint as Any)]) case .inputMediaInvoice(let flags, let title, let description, let photo, let invoice, let payload, let provider, let providerData, let startParam, let extendedMedia): return ("inputMediaInvoice", [("flags", flags as Any), ("title", title as Any), ("description", description as Any), ("photo", photo as Any), ("invoice", invoice as Any), ("payload", payload as Any), ("provider", provider as Any), ("providerData", providerData as Any), ("startParam", startParam as Any), ("extendedMedia", extendedMedia as Any)]) - case .inputMediaPaidMedia(let starsAmount, let extendedMedia): - return ("inputMediaPaidMedia", [("starsAmount", starsAmount as Any), ("extendedMedia", extendedMedia as Any)]) + case .inputMediaPaidMedia(let flags, let starsAmount, let extendedMedia, let payload): + return ("inputMediaPaidMedia", [("flags", flags as Any), ("starsAmount", starsAmount as Any), ("extendedMedia", extendedMedia as Any), ("payload", payload as Any)]) case .inputMediaPhoto(let flags, let id, let ttlSeconds): return ("inputMediaPhoto", [("flags", flags as Any), ("id", id as Any), ("ttlSeconds", ttlSeconds as Any)]) case .inputMediaPhotoExternal(let flags, let url, let ttlSeconds): @@ -546,16 +548,22 @@ public extension Api { } } public static func parse_inputMediaPaidMedia(_ reader: BufferReader) -> InputMedia? { - var _1: Int64? - _1 = reader.readInt64() - var _2: [Api.InputMedia]? + var _1: Int32? + _1 = reader.readInt32() + var _2: Int64? + _2 = reader.readInt64() + var _3: [Api.InputMedia]? if let _ = reader.readInt32() { - _2 = Api.parseVector(reader, elementSignature: 0, elementType: Api.InputMedia.self) + _3 = Api.parseVector(reader, elementSignature: 0, elementType: Api.InputMedia.self) } + var _4: String? + if Int(_1!) & Int(1 << 0) != 0 {_4 = parseString(reader) } let _c1 = _1 != nil let _c2 = _2 != nil - if _c1 && _c2 { - return Api.InputMedia.inputMediaPaidMedia(starsAmount: _1!, extendedMedia: _2!) + let _c3 = _3 != nil + let _c4 = (Int(_1!) & Int(1 << 0) == 0) || _4 != nil + if _c1 && _c2 && _c3 && _c4 { + return Api.InputMedia.inputMediaPaidMedia(flags: _1!, starsAmount: _2!, extendedMedia: _3!, payload: _4) } else { return nil diff --git a/submodules/TelegramApi/Sources/Api12.swift b/submodules/TelegramApi/Sources/Api12.swift index ac16a872404..d626af8099f 100644 --- a/submodules/TelegramApi/Sources/Api12.swift +++ b/submodules/TelegramApi/Sources/Api12.swift @@ -715,6 +715,7 @@ public extension Api { case inputStorePaymentPremiumGiveaway(flags: Int32, boostPeer: Api.InputPeer, additionalPeers: [Api.InputPeer]?, countriesIso2: [String]?, prizeDescription: String?, randomId: Int64, untilDate: Int32, currency: String, amount: Int64) case inputStorePaymentPremiumSubscription(flags: Int32) case inputStorePaymentStarsGift(userId: Api.InputUser, stars: Int64, currency: String, amount: Int64) + case inputStorePaymentStarsGiveaway(flags: Int32, stars: Int64, boostPeer: Api.InputPeer, additionalPeers: [Api.InputPeer]?, countriesIso2: [String]?, prizeDescription: String?, randomId: Int64, untilDate: Int32, currency: String, amount: Int64, users: Int32) case inputStorePaymentStarsTopup(stars: Int64, currency: String, amount: Int64) public func serialize(_ buffer: Buffer, _ boxed: Swift.Bool) { @@ -778,6 +779,30 @@ public extension Api { serializeString(currency, buffer: buffer, boxed: false) serializeInt64(amount, buffer: buffer, boxed: false) break + case .inputStorePaymentStarsGiveaway(let flags, let stars, let boostPeer, let additionalPeers, let countriesIso2, let prizeDescription, let randomId, let untilDate, let currency, let amount, let users): + if boxed { + buffer.appendInt32(1964968186) + } + serializeInt32(flags, buffer: buffer, boxed: false) + serializeInt64(stars, buffer: buffer, boxed: false) + boostPeer.serialize(buffer, true) + if Int(flags) & Int(1 << 1) != 0 {buffer.appendInt32(481674261) + buffer.appendInt32(Int32(additionalPeers!.count)) + for item in additionalPeers! { + item.serialize(buffer, true) + }} + if Int(flags) & Int(1 << 2) != 0 {buffer.appendInt32(481674261) + buffer.appendInt32(Int32(countriesIso2!.count)) + for item in countriesIso2! { + serializeString(item, buffer: buffer, boxed: false) + }} + if Int(flags) & Int(1 << 4) != 0 {serializeString(prizeDescription!, buffer: buffer, boxed: false)} + serializeInt64(randomId, buffer: buffer, boxed: false) + serializeInt32(untilDate, buffer: buffer, boxed: false) + serializeString(currency, buffer: buffer, boxed: false) + serializeInt64(amount, buffer: buffer, boxed: false) + serializeInt32(users, buffer: buffer, boxed: false) + break case .inputStorePaymentStarsTopup(let stars, let currency, let amount): if boxed { buffer.appendInt32(-572715178) @@ -801,6 +826,8 @@ public extension Api { return ("inputStorePaymentPremiumSubscription", [("flags", flags as Any)]) case .inputStorePaymentStarsGift(let userId, let stars, let currency, let amount): return ("inputStorePaymentStarsGift", [("userId", userId as Any), ("stars", stars as Any), ("currency", currency as Any), ("amount", amount as Any)]) + case .inputStorePaymentStarsGiveaway(let flags, let stars, let boostPeer, let additionalPeers, let countriesIso2, let prizeDescription, let randomId, let untilDate, let currency, let amount, let users): + return ("inputStorePaymentStarsGiveaway", [("flags", flags as Any), ("stars", stars as Any), ("boostPeer", boostPeer as Any), ("additionalPeers", additionalPeers as Any), ("countriesIso2", countriesIso2 as Any), ("prizeDescription", prizeDescription as Any), ("randomId", randomId as Any), ("untilDate", untilDate as Any), ("currency", currency as Any), ("amount", amount as Any), ("users", users as Any)]) case .inputStorePaymentStarsTopup(let stars, let currency, let amount): return ("inputStorePaymentStarsTopup", [("stars", stars as Any), ("currency", currency as Any), ("amount", amount as Any)]) } @@ -926,6 +953,53 @@ public extension Api { return nil } } + public static func parse_inputStorePaymentStarsGiveaway(_ reader: BufferReader) -> InputStorePaymentPurpose? { + var _1: Int32? + _1 = reader.readInt32() + var _2: Int64? + _2 = reader.readInt64() + var _3: Api.InputPeer? + if let signature = reader.readInt32() { + _3 = Api.parse(reader, signature: signature) as? Api.InputPeer + } + var _4: [Api.InputPeer]? + if Int(_1!) & Int(1 << 1) != 0 {if let _ = reader.readInt32() { + _4 = Api.parseVector(reader, elementSignature: 0, elementType: Api.InputPeer.self) + } } + var _5: [String]? + if Int(_1!) & Int(1 << 2) != 0 {if let _ = reader.readInt32() { + _5 = Api.parseVector(reader, elementSignature: -1255641564, elementType: String.self) + } } + var _6: String? + if Int(_1!) & Int(1 << 4) != 0 {_6 = parseString(reader) } + var _7: Int64? + _7 = reader.readInt64() + var _8: Int32? + _8 = reader.readInt32() + var _9: String? + _9 = parseString(reader) + var _10: Int64? + _10 = reader.readInt64() + var _11: Int32? + _11 = reader.readInt32() + let _c1 = _1 != nil + let _c2 = _2 != nil + let _c3 = _3 != nil + let _c4 = (Int(_1!) & Int(1 << 1) == 0) || _4 != nil + let _c5 = (Int(_1!) & Int(1 << 2) == 0) || _5 != nil + let _c6 = (Int(_1!) & Int(1 << 4) == 0) || _6 != nil + let _c7 = _7 != nil + let _c8 = _8 != nil + let _c9 = _9 != nil + let _c10 = _10 != nil + let _c11 = _11 != nil + if _c1 && _c2 && _c3 && _c4 && _c5 && _c6 && _c7 && _c8 && _c9 && _c10 && _c11 { + return Api.InputStorePaymentPurpose.inputStorePaymentStarsGiveaway(flags: _1!, stars: _2!, boostPeer: _3!, additionalPeers: _4, countriesIso2: _5, prizeDescription: _6, randomId: _7!, untilDate: _8!, currency: _9!, amount: _10!, users: _11!) + } + else { + return nil + } + } public static func parse_inputStorePaymentStarsTopup(_ reader: BufferReader) -> InputStorePaymentPurpose? { var _1: Int64? _1 = reader.readInt64() @@ -1006,75 +1080,3 @@ public extension Api { } } -public extension Api { - enum InputThemeSettings: TypeConstructorDescription { - case inputThemeSettings(flags: Int32, baseTheme: Api.BaseTheme, accentColor: Int32, outboxAccentColor: Int32?, messageColors: [Int32]?, wallpaper: Api.InputWallPaper?, wallpaperSettings: Api.WallPaperSettings?) - - public func serialize(_ buffer: Buffer, _ boxed: Swift.Bool) { - switch self { - case .inputThemeSettings(let flags, let baseTheme, let accentColor, let outboxAccentColor, let messageColors, let wallpaper, let wallpaperSettings): - if boxed { - buffer.appendInt32(-1881255857) - } - serializeInt32(flags, buffer: buffer, boxed: false) - baseTheme.serialize(buffer, true) - serializeInt32(accentColor, buffer: buffer, boxed: false) - if Int(flags) & Int(1 << 3) != 0 {serializeInt32(outboxAccentColor!, buffer: buffer, boxed: false)} - if Int(flags) & Int(1 << 0) != 0 {buffer.appendInt32(481674261) - buffer.appendInt32(Int32(messageColors!.count)) - for item in messageColors! { - serializeInt32(item, buffer: buffer, boxed: false) - }} - if Int(flags) & Int(1 << 1) != 0 {wallpaper!.serialize(buffer, true)} - if Int(flags) & Int(1 << 1) != 0 {wallpaperSettings!.serialize(buffer, true)} - break - } - } - - public func descriptionFields() -> (String, [(String, Any)]) { - switch self { - case .inputThemeSettings(let flags, let baseTheme, let accentColor, let outboxAccentColor, let messageColors, let wallpaper, let wallpaperSettings): - return ("inputThemeSettings", [("flags", flags as Any), ("baseTheme", baseTheme as Any), ("accentColor", accentColor as Any), ("outboxAccentColor", outboxAccentColor as Any), ("messageColors", messageColors as Any), ("wallpaper", wallpaper as Any), ("wallpaperSettings", wallpaperSettings as Any)]) - } - } - - public static func parse_inputThemeSettings(_ reader: BufferReader) -> InputThemeSettings? { - var _1: Int32? - _1 = reader.readInt32() - var _2: Api.BaseTheme? - if let signature = reader.readInt32() { - _2 = Api.parse(reader, signature: signature) as? Api.BaseTheme - } - var _3: Int32? - _3 = reader.readInt32() - var _4: Int32? - if Int(_1!) & Int(1 << 3) != 0 {_4 = reader.readInt32() } - var _5: [Int32]? - if Int(_1!) & Int(1 << 0) != 0 {if let _ = reader.readInt32() { - _5 = Api.parseVector(reader, elementSignature: -1471112230, elementType: Int32.self) - } } - var _6: Api.InputWallPaper? - if Int(_1!) & Int(1 << 1) != 0 {if let signature = reader.readInt32() { - _6 = Api.parse(reader, signature: signature) as? Api.InputWallPaper - } } - var _7: Api.WallPaperSettings? - if Int(_1!) & Int(1 << 1) != 0 {if let signature = reader.readInt32() { - _7 = Api.parse(reader, signature: signature) as? Api.WallPaperSettings - } } - let _c1 = _1 != nil - let _c2 = _2 != nil - let _c3 = _3 != nil - let _c4 = (Int(_1!) & Int(1 << 3) == 0) || _4 != nil - let _c5 = (Int(_1!) & Int(1 << 0) == 0) || _5 != nil - let _c6 = (Int(_1!) & Int(1 << 1) == 0) || _6 != nil - let _c7 = (Int(_1!) & Int(1 << 1) == 0) || _7 != nil - if _c1 && _c2 && _c3 && _c4 && _c5 && _c6 && _c7 { - return Api.InputThemeSettings.inputThemeSettings(flags: _1!, baseTheme: _2!, accentColor: _3!, outboxAccentColor: _4, messageColors: _5, wallpaper: _6, wallpaperSettings: _7) - } - else { - return nil - } - } - - } -} diff --git a/submodules/TelegramApi/Sources/Api13.swift b/submodules/TelegramApi/Sources/Api13.swift index c66b7ed17b2..3f76fc2beeb 100644 --- a/submodules/TelegramApi/Sources/Api13.swift +++ b/submodules/TelegramApi/Sources/Api13.swift @@ -1,3 +1,75 @@ +public extension Api { + enum InputThemeSettings: TypeConstructorDescription { + case inputThemeSettings(flags: Int32, baseTheme: Api.BaseTheme, accentColor: Int32, outboxAccentColor: Int32?, messageColors: [Int32]?, wallpaper: Api.InputWallPaper?, wallpaperSettings: Api.WallPaperSettings?) + + public func serialize(_ buffer: Buffer, _ boxed: Swift.Bool) { + switch self { + case .inputThemeSettings(let flags, let baseTheme, let accentColor, let outboxAccentColor, let messageColors, let wallpaper, let wallpaperSettings): + if boxed { + buffer.appendInt32(-1881255857) + } + serializeInt32(flags, buffer: buffer, boxed: false) + baseTheme.serialize(buffer, true) + serializeInt32(accentColor, buffer: buffer, boxed: false) + if Int(flags) & Int(1 << 3) != 0 {serializeInt32(outboxAccentColor!, buffer: buffer, boxed: false)} + if Int(flags) & Int(1 << 0) != 0 {buffer.appendInt32(481674261) + buffer.appendInt32(Int32(messageColors!.count)) + for item in messageColors! { + serializeInt32(item, buffer: buffer, boxed: false) + }} + if Int(flags) & Int(1 << 1) != 0 {wallpaper!.serialize(buffer, true)} + if Int(flags) & Int(1 << 1) != 0 {wallpaperSettings!.serialize(buffer, true)} + break + } + } + + public func descriptionFields() -> (String, [(String, Any)]) { + switch self { + case .inputThemeSettings(let flags, let baseTheme, let accentColor, let outboxAccentColor, let messageColors, let wallpaper, let wallpaperSettings): + return ("inputThemeSettings", [("flags", flags as Any), ("baseTheme", baseTheme as Any), ("accentColor", accentColor as Any), ("outboxAccentColor", outboxAccentColor as Any), ("messageColors", messageColors as Any), ("wallpaper", wallpaper as Any), ("wallpaperSettings", wallpaperSettings as Any)]) + } + } + + public static func parse_inputThemeSettings(_ reader: BufferReader) -> InputThemeSettings? { + var _1: Int32? + _1 = reader.readInt32() + var _2: Api.BaseTheme? + if let signature = reader.readInt32() { + _2 = Api.parse(reader, signature: signature) as? Api.BaseTheme + } + var _3: Int32? + _3 = reader.readInt32() + var _4: Int32? + if Int(_1!) & Int(1 << 3) != 0 {_4 = reader.readInt32() } + var _5: [Int32]? + if Int(_1!) & Int(1 << 0) != 0 {if let _ = reader.readInt32() { + _5 = Api.parseVector(reader, elementSignature: -1471112230, elementType: Int32.self) + } } + var _6: Api.InputWallPaper? + if Int(_1!) & Int(1 << 1) != 0 {if let signature = reader.readInt32() { + _6 = Api.parse(reader, signature: signature) as? Api.InputWallPaper + } } + var _7: Api.WallPaperSettings? + if Int(_1!) & Int(1 << 1) != 0 {if let signature = reader.readInt32() { + _7 = Api.parse(reader, signature: signature) as? Api.WallPaperSettings + } } + let _c1 = _1 != nil + let _c2 = _2 != nil + let _c3 = _3 != nil + let _c4 = (Int(_1!) & Int(1 << 3) == 0) || _4 != nil + let _c5 = (Int(_1!) & Int(1 << 0) == 0) || _5 != nil + let _c6 = (Int(_1!) & Int(1 << 1) == 0) || _6 != nil + let _c7 = (Int(_1!) & Int(1 << 1) == 0) || _7 != nil + if _c1 && _c2 && _c3 && _c4 && _c5 && _c6 && _c7 { + return Api.InputThemeSettings.inputThemeSettings(flags: _1!, baseTheme: _2!, accentColor: _3!, outboxAccentColor: _4, messageColors: _5, wallpaper: _6, wallpaperSettings: _7) + } + else { + return nil + } + } + + } +} public extension Api { indirect enum InputUser: TypeConstructorDescription { case inputUser(userId: Int64, accessHash: Int64) diff --git a/submodules/TelegramApi/Sources/Api14.swift b/submodules/TelegramApi/Sources/Api14.swift index 6bdd0446fe4..b7de916ed3c 100644 --- a/submodules/TelegramApi/Sources/Api14.swift +++ b/submodules/TelegramApi/Sources/Api14.swift @@ -989,8 +989,8 @@ public extension Api { case messageActionGiftCode(flags: Int32, boostPeer: Api.Peer?, months: Int32, slug: String, currency: String?, amount: Int64?, cryptoCurrency: String?, cryptoAmount: Int64?) case messageActionGiftPremium(flags: Int32, currency: String, amount: Int64, months: Int32, cryptoCurrency: String?, cryptoAmount: Int64?) case messageActionGiftStars(flags: Int32, currency: String, amount: Int64, stars: Int64, cryptoCurrency: String?, cryptoAmount: Int64?, transactionId: String?) - case messageActionGiveawayLaunch - case messageActionGiveawayResults(winnersCount: Int32, unclaimedCount: Int32) + case messageActionGiveawayLaunch(flags: Int32, stars: Int64?) + case messageActionGiveawayResults(flags: Int32, winnersCount: Int32, unclaimedCount: Int32) case messageActionGroupCall(flags: Int32, call: Api.InputGroupCall, duration: Int32?) case messageActionGroupCallScheduled(call: Api.InputGroupCall, scheduleDate: Int32) case messageActionHistoryClear @@ -1000,6 +1000,7 @@ public extension Api { case messageActionPaymentSentMe(flags: Int32, currency: String, totalAmount: Int64, payload: Buffer, info: Api.PaymentRequestedInfo?, shippingOptionId: String?, charge: Api.PaymentCharge) case messageActionPhoneCall(flags: Int32, callId: Int64, reason: Api.PhoneCallDiscardReason?, duration: Int32?) case messageActionPinMessage + case messageActionPrizeStars(flags: Int32, stars: Int64, transactionId: String, boostPeer: Api.Peer, giveawayMsgId: Int32) case messageActionRequestedPeer(buttonId: Int32, peers: [Api.Peer]) case messageActionRequestedPeerSentMe(buttonId: Int32, peers: [Api.RequestedPeer]) case messageActionScreenshotTaken @@ -1175,16 +1176,18 @@ public extension Api { if Int(flags) & Int(1 << 0) != 0 {serializeInt64(cryptoAmount!, buffer: buffer, boxed: false)} if Int(flags) & Int(1 << 1) != 0 {serializeString(transactionId!, buffer: buffer, boxed: false)} break - case .messageActionGiveawayLaunch: + case .messageActionGiveawayLaunch(let flags, let stars): if boxed { - buffer.appendInt32(858499565) + buffer.appendInt32(-1475391004) } - + serializeInt32(flags, buffer: buffer, boxed: false) + if Int(flags) & Int(1 << 0) != 0 {serializeInt64(stars!, buffer: buffer, boxed: false)} break - case .messageActionGiveawayResults(let winnersCount, let unclaimedCount): + case .messageActionGiveawayResults(let flags, let winnersCount, let unclaimedCount): if boxed { - buffer.appendInt32(715107781) + buffer.appendInt32(-2015170219) } + serializeInt32(flags, buffer: buffer, boxed: false) serializeInt32(winnersCount, buffer: buffer, boxed: false) serializeInt32(unclaimedCount, buffer: buffer, boxed: false) break @@ -1266,6 +1269,16 @@ public extension Api { buffer.appendInt32(-1799538451) } + break + case .messageActionPrizeStars(let flags, let stars, let transactionId, let boostPeer, let giveawayMsgId): + if boxed { + buffer.appendInt32(-1341372510) + } + serializeInt32(flags, buffer: buffer, boxed: false) + serializeInt64(stars, buffer: buffer, boxed: false) + serializeString(transactionId, buffer: buffer, boxed: false) + boostPeer.serialize(buffer, true) + serializeInt32(giveawayMsgId, buffer: buffer, boxed: false) break case .messageActionRequestedPeer(let buttonId, let peers): if boxed { @@ -1422,10 +1435,10 @@ public extension Api { return ("messageActionGiftPremium", [("flags", flags as Any), ("currency", currency as Any), ("amount", amount as Any), ("months", months as Any), ("cryptoCurrency", cryptoCurrency as Any), ("cryptoAmount", cryptoAmount as Any)]) case .messageActionGiftStars(let flags, let currency, let amount, let stars, let cryptoCurrency, let cryptoAmount, let transactionId): return ("messageActionGiftStars", [("flags", flags as Any), ("currency", currency as Any), ("amount", amount as Any), ("stars", stars as Any), ("cryptoCurrency", cryptoCurrency as Any), ("cryptoAmount", cryptoAmount as Any), ("transactionId", transactionId as Any)]) - case .messageActionGiveawayLaunch: - return ("messageActionGiveawayLaunch", []) - case .messageActionGiveawayResults(let winnersCount, let unclaimedCount): - return ("messageActionGiveawayResults", [("winnersCount", winnersCount as Any), ("unclaimedCount", unclaimedCount as Any)]) + case .messageActionGiveawayLaunch(let flags, let stars): + return ("messageActionGiveawayLaunch", [("flags", flags as Any), ("stars", stars as Any)]) + case .messageActionGiveawayResults(let flags, let winnersCount, let unclaimedCount): + return ("messageActionGiveawayResults", [("flags", flags as Any), ("winnersCount", winnersCount as Any), ("unclaimedCount", unclaimedCount as Any)]) case .messageActionGroupCall(let flags, let call, let duration): return ("messageActionGroupCall", [("flags", flags as Any), ("call", call as Any), ("duration", duration as Any)]) case .messageActionGroupCallScheduled(let call, let scheduleDate): @@ -1444,6 +1457,8 @@ public extension Api { return ("messageActionPhoneCall", [("flags", flags as Any), ("callId", callId as Any), ("reason", reason as Any), ("duration", duration as Any)]) case .messageActionPinMessage: return ("messageActionPinMessage", []) + case .messageActionPrizeStars(let flags, let stars, let transactionId, let boostPeer, let giveawayMsgId): + return ("messageActionPrizeStars", [("flags", flags as Any), ("stars", stars as Any), ("transactionId", transactionId as Any), ("boostPeer", boostPeer as Any), ("giveawayMsgId", giveawayMsgId as Any)]) case .messageActionRequestedPeer(let buttonId, let peers): return ("messageActionRequestedPeer", [("buttonId", buttonId as Any), ("peers", peers as Any)]) case .messageActionRequestedPeerSentMe(let buttonId, let peers): @@ -1762,17 +1777,31 @@ public extension Api { } } public static func parse_messageActionGiveawayLaunch(_ reader: BufferReader) -> MessageAction? { - return Api.MessageAction.messageActionGiveawayLaunch + var _1: Int32? + _1 = reader.readInt32() + var _2: Int64? + if Int(_1!) & Int(1 << 0) != 0 {_2 = reader.readInt64() } + let _c1 = _1 != nil + let _c2 = (Int(_1!) & Int(1 << 0) == 0) || _2 != nil + if _c1 && _c2 { + return Api.MessageAction.messageActionGiveawayLaunch(flags: _1!, stars: _2) + } + else { + return nil + } } public static func parse_messageActionGiveawayResults(_ reader: BufferReader) -> MessageAction? { var _1: Int32? _1 = reader.readInt32() var _2: Int32? _2 = reader.readInt32() + var _3: Int32? + _3 = reader.readInt32() let _c1 = _1 != nil let _c2 = _2 != nil - if _c1 && _c2 { - return Api.MessageAction.messageActionGiveawayResults(winnersCount: _1!, unclaimedCount: _2!) + let _c3 = _3 != nil + if _c1 && _c2 && _c3 { + return Api.MessageAction.messageActionGiveawayResults(flags: _1!, winnersCount: _2!, unclaimedCount: _3!) } else { return nil @@ -1942,6 +1971,31 @@ public extension Api { public static func parse_messageActionPinMessage(_ reader: BufferReader) -> MessageAction? { return Api.MessageAction.messageActionPinMessage } + public static func parse_messageActionPrizeStars(_ reader: BufferReader) -> MessageAction? { + var _1: Int32? + _1 = reader.readInt32() + var _2: Int64? + _2 = reader.readInt64() + var _3: String? + _3 = parseString(reader) + var _4: Api.Peer? + if let signature = reader.readInt32() { + _4 = Api.parse(reader, signature: signature) as? Api.Peer + } + var _5: Int32? + _5 = reader.readInt32() + let _c1 = _1 != nil + let _c2 = _2 != nil + let _c3 = _3 != nil + let _c4 = _4 != nil + let _c5 = _5 != nil + if _c1 && _c2 && _c3 && _c4 && _c5 { + return Api.MessageAction.messageActionPrizeStars(flags: _1!, stars: _2!, transactionId: _3!, boostPeer: _4!, giveawayMsgId: _5!) + } + else { + return nil + } + } public static func parse_messageActionRequestedPeer(_ reader: BufferReader) -> MessageAction? { var _1: Int32? _1 = reader.readInt32() diff --git a/submodules/TelegramApi/Sources/Api15.swift b/submodules/TelegramApi/Sources/Api15.swift index 334a7490250..eedd1393551 100644 --- a/submodules/TelegramApi/Sources/Api15.swift +++ b/submodules/TelegramApi/Sources/Api15.swift @@ -715,8 +715,8 @@ public extension Api { case messageMediaGame(game: Api.Game) case messageMediaGeo(geo: Api.GeoPoint) case messageMediaGeoLive(flags: Int32, geo: Api.GeoPoint, heading: Int32?, period: Int32, proximityNotificationRadius: Int32?) - case messageMediaGiveaway(flags: Int32, channels: [Int64], countriesIso2: [String]?, prizeDescription: String?, quantity: Int32, months: Int32, untilDate: Int32) - case messageMediaGiveawayResults(flags: Int32, channelId: Int64, additionalPeersCount: Int32?, launchMsgId: Int32, winnersCount: Int32, unclaimedCount: Int32, winners: [Int64], months: Int32, prizeDescription: String?, untilDate: Int32) + case messageMediaGiveaway(flags: Int32, channels: [Int64], countriesIso2: [String]?, prizeDescription: String?, quantity: Int32, months: Int32?, stars: Int64?, untilDate: Int32) + case messageMediaGiveawayResults(flags: Int32, channelId: Int64, additionalPeersCount: Int32?, launchMsgId: Int32, winnersCount: Int32, unclaimedCount: Int32, winners: [Int64], months: Int32?, stars: Int64?, prizeDescription: String?, untilDate: Int32) case messageMediaInvoice(flags: Int32, title: String, description: String, photo: Api.WebDocument?, receiptMsgId: Int32?, currency: String, totalAmount: Int64, startParam: String, extendedMedia: Api.MessageExtendedMedia?) case messageMediaPaidMedia(starsAmount: Int64, extendedMedia: [Api.MessageExtendedMedia]) case messageMediaPhoto(flags: Int32, photo: Api.Photo?, ttlSeconds: Int32?) @@ -782,9 +782,9 @@ public extension Api { serializeInt32(period, buffer: buffer, boxed: false) if Int(flags) & Int(1 << 1) != 0 {serializeInt32(proximityNotificationRadius!, buffer: buffer, boxed: false)} break - case .messageMediaGiveaway(let flags, let channels, let countriesIso2, let prizeDescription, let quantity, let months, let untilDate): + case .messageMediaGiveaway(let flags, let channels, let countriesIso2, let prizeDescription, let quantity, let months, let stars, let untilDate): if boxed { - buffer.appendInt32(-626162256) + buffer.appendInt32(-1442366485) } serializeInt32(flags, buffer: buffer, boxed: false) buffer.appendInt32(481674261) @@ -799,12 +799,13 @@ public extension Api { }} if Int(flags) & Int(1 << 3) != 0 {serializeString(prizeDescription!, buffer: buffer, boxed: false)} serializeInt32(quantity, buffer: buffer, boxed: false) - serializeInt32(months, buffer: buffer, boxed: false) + if Int(flags) & Int(1 << 4) != 0 {serializeInt32(months!, buffer: buffer, boxed: false)} + if Int(flags) & Int(1 << 5) != 0 {serializeInt64(stars!, buffer: buffer, boxed: false)} serializeInt32(untilDate, buffer: buffer, boxed: false) break - case .messageMediaGiveawayResults(let flags, let channelId, let additionalPeersCount, let launchMsgId, let winnersCount, let unclaimedCount, let winners, let months, let prizeDescription, let untilDate): + case .messageMediaGiveawayResults(let flags, let channelId, let additionalPeersCount, let launchMsgId, let winnersCount, let unclaimedCount, let winners, let months, let stars, let prizeDescription, let untilDate): if boxed { - buffer.appendInt32(-963047320) + buffer.appendInt32(-827703647) } serializeInt32(flags, buffer: buffer, boxed: false) serializeInt64(channelId, buffer: buffer, boxed: false) @@ -817,7 +818,8 @@ public extension Api { for item in winners { serializeInt64(item, buffer: buffer, boxed: false) } - serializeInt32(months, buffer: buffer, boxed: false) + if Int(flags) & Int(1 << 4) != 0 {serializeInt32(months!, buffer: buffer, boxed: false)} + if Int(flags) & Int(1 << 5) != 0 {serializeInt64(stars!, buffer: buffer, boxed: false)} if Int(flags) & Int(1 << 1) != 0 {serializeString(prizeDescription!, buffer: buffer, boxed: false)} serializeInt32(untilDate, buffer: buffer, boxed: false) break @@ -913,10 +915,10 @@ public extension Api { return ("messageMediaGeo", [("geo", geo as Any)]) case .messageMediaGeoLive(let flags, let geo, let heading, let period, let proximityNotificationRadius): return ("messageMediaGeoLive", [("flags", flags as Any), ("geo", geo as Any), ("heading", heading as Any), ("period", period as Any), ("proximityNotificationRadius", proximityNotificationRadius as Any)]) - case .messageMediaGiveaway(let flags, let channels, let countriesIso2, let prizeDescription, let quantity, let months, let untilDate): - return ("messageMediaGiveaway", [("flags", flags as Any), ("channels", channels as Any), ("countriesIso2", countriesIso2 as Any), ("prizeDescription", prizeDescription as Any), ("quantity", quantity as Any), ("months", months as Any), ("untilDate", untilDate as Any)]) - case .messageMediaGiveawayResults(let flags, let channelId, let additionalPeersCount, let launchMsgId, let winnersCount, let unclaimedCount, let winners, let months, let prizeDescription, let untilDate): - return ("messageMediaGiveawayResults", [("flags", flags as Any), ("channelId", channelId as Any), ("additionalPeersCount", additionalPeersCount as Any), ("launchMsgId", launchMsgId as Any), ("winnersCount", winnersCount as Any), ("unclaimedCount", unclaimedCount as Any), ("winners", winners as Any), ("months", months as Any), ("prizeDescription", prizeDescription as Any), ("untilDate", untilDate as Any)]) + case .messageMediaGiveaway(let flags, let channels, let countriesIso2, let prizeDescription, let quantity, let months, let stars, let untilDate): + return ("messageMediaGiveaway", [("flags", flags as Any), ("channels", channels as Any), ("countriesIso2", countriesIso2 as Any), ("prizeDescription", prizeDescription as Any), ("quantity", quantity as Any), ("months", months as Any), ("stars", stars as Any), ("untilDate", untilDate as Any)]) + case .messageMediaGiveawayResults(let flags, let channelId, let additionalPeersCount, let launchMsgId, let winnersCount, let unclaimedCount, let winners, let months, let stars, let prizeDescription, let untilDate): + return ("messageMediaGiveawayResults", [("flags", flags as Any), ("channelId", channelId as Any), ("additionalPeersCount", additionalPeersCount as Any), ("launchMsgId", launchMsgId as Any), ("winnersCount", winnersCount as Any), ("unclaimedCount", unclaimedCount as Any), ("winners", winners as Any), ("months", months as Any), ("stars", stars as Any), ("prizeDescription", prizeDescription as Any), ("untilDate", untilDate as Any)]) case .messageMediaInvoice(let flags, let title, let description, let photo, let receiptMsgId, let currency, let totalAmount, let startParam, let extendedMedia): return ("messageMediaInvoice", [("flags", flags as Any), ("title", title as Any), ("description", description as Any), ("photo", photo as Any), ("receiptMsgId", receiptMsgId as Any), ("currency", currency as Any), ("totalAmount", totalAmount as Any), ("startParam", startParam as Any), ("extendedMedia", extendedMedia as Any)]) case .messageMediaPaidMedia(let starsAmount, let extendedMedia): @@ -1067,18 +1069,21 @@ public extension Api { var _5: Int32? _5 = reader.readInt32() var _6: Int32? - _6 = reader.readInt32() - var _7: Int32? - _7 = reader.readInt32() + if Int(_1!) & Int(1 << 4) != 0 {_6 = reader.readInt32() } + var _7: Int64? + if Int(_1!) & Int(1 << 5) != 0 {_7 = reader.readInt64() } + var _8: Int32? + _8 = reader.readInt32() let _c1 = _1 != nil let _c2 = _2 != nil let _c3 = (Int(_1!) & Int(1 << 1) == 0) || _3 != nil let _c4 = (Int(_1!) & Int(1 << 3) == 0) || _4 != nil let _c5 = _5 != nil - let _c6 = _6 != nil - let _c7 = _7 != nil - if _c1 && _c2 && _c3 && _c4 && _c5 && _c6 && _c7 { - return Api.MessageMedia.messageMediaGiveaway(flags: _1!, channels: _2!, countriesIso2: _3, prizeDescription: _4, quantity: _5!, months: _6!, untilDate: _7!) + let _c6 = (Int(_1!) & Int(1 << 4) == 0) || _6 != nil + let _c7 = (Int(_1!) & Int(1 << 5) == 0) || _7 != nil + let _c8 = _8 != nil + if _c1 && _c2 && _c3 && _c4 && _c5 && _c6 && _c7 && _c8 { + return Api.MessageMedia.messageMediaGiveaway(flags: _1!, channels: _2!, countriesIso2: _3, prizeDescription: _4, quantity: _5!, months: _6, stars: _7, untilDate: _8!) } else { return nil @@ -1102,11 +1107,13 @@ public extension Api { _7 = Api.parseVector(reader, elementSignature: 570911930, elementType: Int64.self) } var _8: Int32? - _8 = reader.readInt32() - var _9: String? - if Int(_1!) & Int(1 << 1) != 0 {_9 = parseString(reader) } - var _10: Int32? - _10 = reader.readInt32() + if Int(_1!) & Int(1 << 4) != 0 {_8 = reader.readInt32() } + var _9: Int64? + if Int(_1!) & Int(1 << 5) != 0 {_9 = reader.readInt64() } + var _10: String? + if Int(_1!) & Int(1 << 1) != 0 {_10 = parseString(reader) } + var _11: Int32? + _11 = reader.readInt32() let _c1 = _1 != nil let _c2 = _2 != nil let _c3 = (Int(_1!) & Int(1 << 3) == 0) || _3 != nil @@ -1114,11 +1121,12 @@ public extension Api { let _c5 = _5 != nil let _c6 = _6 != nil let _c7 = _7 != nil - let _c8 = _8 != nil - let _c9 = (Int(_1!) & Int(1 << 1) == 0) || _9 != nil - let _c10 = _10 != nil - if _c1 && _c2 && _c3 && _c4 && _c5 && _c6 && _c7 && _c8 && _c9 && _c10 { - return Api.MessageMedia.messageMediaGiveawayResults(flags: _1!, channelId: _2!, additionalPeersCount: _3, launchMsgId: _4!, winnersCount: _5!, unclaimedCount: _6!, winners: _7!, months: _8!, prizeDescription: _9, untilDate: _10!) + let _c8 = (Int(_1!) & Int(1 << 4) == 0) || _8 != nil + let _c9 = (Int(_1!) & Int(1 << 5) == 0) || _9 != nil + let _c10 = (Int(_1!) & Int(1 << 1) == 0) || _10 != nil + let _c11 = _11 != nil + if _c1 && _c2 && _c3 && _c4 && _c5 && _c6 && _c7 && _c8 && _c9 && _c10 && _c11 { + return Api.MessageMedia.messageMediaGiveawayResults(flags: _1!, channelId: _2!, additionalPeersCount: _3, launchMsgId: _4!, winnersCount: _5!, unclaimedCount: _6!, winners: _7!, months: _8, stars: _9, prizeDescription: _10, untilDate: _11!) } else { return nil diff --git a/submodules/TelegramApi/Sources/Api19.swift b/submodules/TelegramApi/Sources/Api19.swift index 86d042f173f..7935cc9ca1e 100644 --- a/submodules/TelegramApi/Sources/Api19.swift +++ b/submodules/TelegramApi/Sources/Api19.swift @@ -1013,6 +1013,7 @@ public extension Api { public extension Api { enum PrepaidGiveaway: TypeConstructorDescription { case prepaidGiveaway(id: Int64, months: Int32, quantity: Int32, date: Int32) + case prepaidStarsGiveaway(id: Int64, stars: Int64, quantity: Int32, boosts: Int32, date: Int32) public func serialize(_ buffer: Buffer, _ boxed: Swift.Bool) { switch self { @@ -1025,6 +1026,16 @@ public extension Api { serializeInt32(quantity, buffer: buffer, boxed: false) serializeInt32(date, buffer: buffer, boxed: false) break + case .prepaidStarsGiveaway(let id, let stars, let quantity, let boosts, let date): + if boxed { + buffer.appendInt32(-1700956192) + } + serializeInt64(id, buffer: buffer, boxed: false) + serializeInt64(stars, buffer: buffer, boxed: false) + serializeInt32(quantity, buffer: buffer, boxed: false) + serializeInt32(boosts, buffer: buffer, boxed: false) + serializeInt32(date, buffer: buffer, boxed: false) + break } } @@ -1032,6 +1043,8 @@ public extension Api { switch self { case .prepaidGiveaway(let id, let months, let quantity, let date): return ("prepaidGiveaway", [("id", id as Any), ("months", months as Any), ("quantity", quantity as Any), ("date", date as Any)]) + case .prepaidStarsGiveaway(let id, let stars, let quantity, let boosts, let date): + return ("prepaidStarsGiveaway", [("id", id as Any), ("stars", stars as Any), ("quantity", quantity as Any), ("boosts", boosts as Any), ("date", date as Any)]) } } @@ -1055,6 +1068,29 @@ public extension Api { return nil } } + public static func parse_prepaidStarsGiveaway(_ reader: BufferReader) -> PrepaidGiveaway? { + var _1: Int64? + _1 = reader.readInt64() + var _2: Int64? + _2 = reader.readInt64() + var _3: Int32? + _3 = reader.readInt32() + var _4: Int32? + _4 = reader.readInt32() + var _5: Int32? + _5 = reader.readInt32() + let _c1 = _1 != nil + let _c2 = _2 != nil + let _c3 = _3 != nil + let _c4 = _4 != nil + let _c5 = _5 != nil + if _c1 && _c2 && _c3 && _c4 && _c5 { + return Api.PrepaidGiveaway.prepaidStarsGiveaway(id: _1!, stars: _2!, quantity: _3!, boosts: _4!, date: _5!) + } + else { + return nil + } + } } } diff --git a/submodules/TelegramApi/Sources/Api2.swift b/submodules/TelegramApi/Sources/Api2.swift index f7891e99446..5407c5f76ce 100644 --- a/submodules/TelegramApi/Sources/Api2.swift +++ b/submodules/TelegramApi/Sources/Api2.swift @@ -772,14 +772,15 @@ public extension Api { } public extension Api { enum BroadcastRevenueBalances: TypeConstructorDescription { - case broadcastRevenueBalances(currentBalance: Int64, availableBalance: Int64, overallRevenue: Int64) + case broadcastRevenueBalances(flags: Int32, currentBalance: Int64, availableBalance: Int64, overallRevenue: Int64) public func serialize(_ buffer: Buffer, _ boxed: Swift.Bool) { switch self { - case .broadcastRevenueBalances(let currentBalance, let availableBalance, let overallRevenue): + case .broadcastRevenueBalances(let flags, let currentBalance, let availableBalance, let overallRevenue): if boxed { - buffer.appendInt32(-2076642874) + buffer.appendInt32(-1006669337) } + serializeInt32(flags, buffer: buffer, boxed: false) serializeInt64(currentBalance, buffer: buffer, boxed: false) serializeInt64(availableBalance, buffer: buffer, boxed: false) serializeInt64(overallRevenue, buffer: buffer, boxed: false) @@ -789,23 +790,26 @@ public extension Api { public func descriptionFields() -> (String, [(String, Any)]) { switch self { - case .broadcastRevenueBalances(let currentBalance, let availableBalance, let overallRevenue): - return ("broadcastRevenueBalances", [("currentBalance", currentBalance as Any), ("availableBalance", availableBalance as Any), ("overallRevenue", overallRevenue as Any)]) + case .broadcastRevenueBalances(let flags, let currentBalance, let availableBalance, let overallRevenue): + return ("broadcastRevenueBalances", [("flags", flags as Any), ("currentBalance", currentBalance as Any), ("availableBalance", availableBalance as Any), ("overallRevenue", overallRevenue as Any)]) } } public static func parse_broadcastRevenueBalances(_ reader: BufferReader) -> BroadcastRevenueBalances? { - var _1: Int64? - _1 = reader.readInt64() + var _1: Int32? + _1 = reader.readInt32() var _2: Int64? _2 = reader.readInt64() var _3: Int64? _3 = reader.readInt64() + var _4: Int64? + _4 = reader.readInt64() let _c1 = _1 != nil let _c2 = _2 != nil let _c3 = _3 != nil - if _c1 && _c2 && _c3 { - return Api.BroadcastRevenueBalances.broadcastRevenueBalances(currentBalance: _1!, availableBalance: _2!, overallRevenue: _3!) + let _c4 = _4 != nil + if _c1 && _c2 && _c3 && _c4 { + return Api.BroadcastRevenueBalances.broadcastRevenueBalances(flags: _1!, currentBalance: _2!, availableBalance: _3!, overallRevenue: _4!) } else { return nil diff --git a/submodules/TelegramApi/Sources/Api23.swift b/submodules/TelegramApi/Sources/Api23.swift index 83c393bc912..6372cf2fed0 100644 --- a/submodules/TelegramApi/Sources/Api23.swift +++ b/submodules/TelegramApi/Sources/Api23.swift @@ -624,6 +624,116 @@ public extension Api { } } +public extension Api { + enum StarsGiveawayOption: TypeConstructorDescription { + case starsGiveawayOption(flags: Int32, stars: Int64, yearlyBoosts: Int32, storeProduct: String?, currency: String, amount: Int64, winners: [Api.StarsGiveawayWinnersOption]) + + public func serialize(_ buffer: Buffer, _ boxed: Swift.Bool) { + switch self { + case .starsGiveawayOption(let flags, let stars, let yearlyBoosts, let storeProduct, let currency, let amount, let winners): + if boxed { + buffer.appendInt32(-1798404822) + } + serializeInt32(flags, buffer: buffer, boxed: false) + serializeInt64(stars, buffer: buffer, boxed: false) + serializeInt32(yearlyBoosts, buffer: buffer, boxed: false) + if Int(flags) & Int(1 << 2) != 0 {serializeString(storeProduct!, buffer: buffer, boxed: false)} + serializeString(currency, buffer: buffer, boxed: false) + serializeInt64(amount, buffer: buffer, boxed: false) + buffer.appendInt32(481674261) + buffer.appendInt32(Int32(winners.count)) + for item in winners { + item.serialize(buffer, true) + } + break + } + } + + public func descriptionFields() -> (String, [(String, Any)]) { + switch self { + case .starsGiveawayOption(let flags, let stars, let yearlyBoosts, let storeProduct, let currency, let amount, let winners): + return ("starsGiveawayOption", [("flags", flags as Any), ("stars", stars as Any), ("yearlyBoosts", yearlyBoosts as Any), ("storeProduct", storeProduct as Any), ("currency", currency as Any), ("amount", amount as Any), ("winners", winners as Any)]) + } + } + + public static func parse_starsGiveawayOption(_ reader: BufferReader) -> StarsGiveawayOption? { + var _1: Int32? + _1 = reader.readInt32() + var _2: Int64? + _2 = reader.readInt64() + var _3: Int32? + _3 = reader.readInt32() + var _4: String? + if Int(_1!) & Int(1 << 2) != 0 {_4 = parseString(reader) } + var _5: String? + _5 = parseString(reader) + var _6: Int64? + _6 = reader.readInt64() + var _7: [Api.StarsGiveawayWinnersOption]? + if let _ = reader.readInt32() { + _7 = Api.parseVector(reader, elementSignature: 0, elementType: Api.StarsGiveawayWinnersOption.self) + } + let _c1 = _1 != nil + let _c2 = _2 != nil + let _c3 = _3 != nil + let _c4 = (Int(_1!) & Int(1 << 2) == 0) || _4 != nil + let _c5 = _5 != nil + let _c6 = _6 != nil + let _c7 = _7 != nil + if _c1 && _c2 && _c3 && _c4 && _c5 && _c6 && _c7 { + return Api.StarsGiveawayOption.starsGiveawayOption(flags: _1!, stars: _2!, yearlyBoosts: _3!, storeProduct: _4, currency: _5!, amount: _6!, winners: _7!) + } + else { + return nil + } + } + + } +} +public extension Api { + enum StarsGiveawayWinnersOption: TypeConstructorDescription { + case starsGiveawayWinnersOption(flags: Int32, users: Int32, perUserStars: Int64) + + public func serialize(_ buffer: Buffer, _ boxed: Swift.Bool) { + switch self { + case .starsGiveawayWinnersOption(let flags, let users, let perUserStars): + if boxed { + buffer.appendInt32(1411605001) + } + serializeInt32(flags, buffer: buffer, boxed: false) + serializeInt32(users, buffer: buffer, boxed: false) + serializeInt64(perUserStars, buffer: buffer, boxed: false) + break + } + } + + public func descriptionFields() -> (String, [(String, Any)]) { + switch self { + case .starsGiveawayWinnersOption(let flags, let users, let perUserStars): + return ("starsGiveawayWinnersOption", [("flags", flags as Any), ("users", users as Any), ("perUserStars", perUserStars as Any)]) + } + } + + public static func parse_starsGiveawayWinnersOption(_ reader: BufferReader) -> StarsGiveawayWinnersOption? { + var _1: Int32? + _1 = reader.readInt32() + var _2: Int32? + _2 = reader.readInt32() + var _3: Int64? + _3 = reader.readInt64() + let _c1 = _1 != nil + let _c2 = _2 != nil + let _c3 = _3 != nil + if _c1 && _c2 && _c3 { + return Api.StarsGiveawayWinnersOption.starsGiveawayWinnersOption(flags: _1!, users: _2!, perUserStars: _3!) + } + else { + return nil + } + } + + } +} public extension Api { enum StarsRevenueStatus: TypeConstructorDescription { case starsRevenueStatus(flags: Int32, currentBalance: Int64, availableBalance: Int64, overallRevenue: Int64, nextWithdrawalAt: Int32?) @@ -830,13 +940,13 @@ public extension Api { } public extension Api { enum StarsTransaction: TypeConstructorDescription { - case starsTransaction(flags: Int32, id: String, stars: Int64, date: Int32, peer: Api.StarsTransactionPeer, title: String?, description: String?, photo: Api.WebDocument?, transactionDate: Int32?, transactionUrl: String?, botPayload: Buffer?, msgId: Int32?, extendedMedia: [Api.MessageMedia]?, subscriptionPeriod: Int32?) + case starsTransaction(flags: Int32, id: String, stars: Int64, date: Int32, peer: Api.StarsTransactionPeer, title: String?, description: String?, photo: Api.WebDocument?, transactionDate: Int32?, transactionUrl: String?, botPayload: Buffer?, msgId: Int32?, extendedMedia: [Api.MessageMedia]?, subscriptionPeriod: Int32?, giveawayPostId: Int32?) public func serialize(_ buffer: Buffer, _ boxed: Swift.Bool) { switch self { - case .starsTransaction(let flags, let id, let stars, let date, let peer, let title, let description, let photo, let transactionDate, let transactionUrl, let botPayload, let msgId, let extendedMedia, let subscriptionPeriod): + case .starsTransaction(let flags, let id, let stars, let date, let peer, let title, let description, let photo, let transactionDate, let transactionUrl, let botPayload, let msgId, let extendedMedia, let subscriptionPeriod, let giveawayPostId): if boxed { - buffer.appendInt32(1127934763) + buffer.appendInt32(-294313259) } serializeInt32(flags, buffer: buffer, boxed: false) serializeString(id, buffer: buffer, boxed: false) @@ -856,14 +966,15 @@ public extension Api { item.serialize(buffer, true) }} if Int(flags) & Int(1 << 12) != 0 {serializeInt32(subscriptionPeriod!, buffer: buffer, boxed: false)} + if Int(flags) & Int(1 << 13) != 0 {serializeInt32(giveawayPostId!, buffer: buffer, boxed: false)} break } } public func descriptionFields() -> (String, [(String, Any)]) { switch self { - case .starsTransaction(let flags, let id, let stars, let date, let peer, let title, let description, let photo, let transactionDate, let transactionUrl, let botPayload, let msgId, let extendedMedia, let subscriptionPeriod): - return ("starsTransaction", [("flags", flags as Any), ("id", id as Any), ("stars", stars as Any), ("date", date as Any), ("peer", peer as Any), ("title", title as Any), ("description", description as Any), ("photo", photo as Any), ("transactionDate", transactionDate as Any), ("transactionUrl", transactionUrl as Any), ("botPayload", botPayload as Any), ("msgId", msgId as Any), ("extendedMedia", extendedMedia as Any), ("subscriptionPeriod", subscriptionPeriod as Any)]) + case .starsTransaction(let flags, let id, let stars, let date, let peer, let title, let description, let photo, let transactionDate, let transactionUrl, let botPayload, let msgId, let extendedMedia, let subscriptionPeriod, let giveawayPostId): + return ("starsTransaction", [("flags", flags as Any), ("id", id as Any), ("stars", stars as Any), ("date", date as Any), ("peer", peer as Any), ("title", title as Any), ("description", description as Any), ("photo", photo as Any), ("transactionDate", transactionDate as Any), ("transactionUrl", transactionUrl as Any), ("botPayload", botPayload as Any), ("msgId", msgId as Any), ("extendedMedia", extendedMedia as Any), ("subscriptionPeriod", subscriptionPeriod as Any), ("giveawayPostId", giveawayPostId as Any)]) } } @@ -902,6 +1013,8 @@ public extension Api { } } var _14: Int32? if Int(_1!) & Int(1 << 12) != 0 {_14 = reader.readInt32() } + var _15: Int32? + if Int(_1!) & Int(1 << 13) != 0 {_15 = reader.readInt32() } let _c1 = _1 != nil let _c2 = _2 != nil let _c3 = _3 != nil @@ -916,8 +1029,9 @@ public extension Api { let _c12 = (Int(_1!) & Int(1 << 8) == 0) || _12 != nil let _c13 = (Int(_1!) & Int(1 << 9) == 0) || _13 != nil let _c14 = (Int(_1!) & Int(1 << 12) == 0) || _14 != nil - if _c1 && _c2 && _c3 && _c4 && _c5 && _c6 && _c7 && _c8 && _c9 && _c10 && _c11 && _c12 && _c13 && _c14 { - return Api.StarsTransaction.starsTransaction(flags: _1!, id: _2!, stars: _3!, date: _4!, peer: _5!, title: _6, description: _7, photo: _8, transactionDate: _9, transactionUrl: _10, botPayload: _11, msgId: _12, extendedMedia: _13, subscriptionPeriod: _14) + let _c15 = (Int(_1!) & Int(1 << 13) == 0) || _15 != nil + if _c1 && _c2 && _c3 && _c4 && _c5 && _c6 && _c7 && _c8 && _c9 && _c10 && _c11 && _c12 && _c13 && _c14 && _c15 { + return Api.StarsTransaction.starsTransaction(flags: _1!, id: _2!, stars: _3!, date: _4!, peer: _5!, title: _6, description: _7, photo: _8, transactionDate: _9, transactionUrl: _10, botPayload: _11, msgId: _12, extendedMedia: _13, subscriptionPeriod: _14, giveawayPostId: _15) } else { return nil diff --git a/submodules/TelegramApi/Sources/Api25.swift b/submodules/TelegramApi/Sources/Api25.swift index ec78a2243ac..9e553153137 100644 --- a/submodules/TelegramApi/Sources/Api25.swift +++ b/submodules/TelegramApi/Sources/Api25.swift @@ -278,6 +278,7 @@ public extension Api { case updateBotMessageReactions(peer: Api.Peer, msgId: Int32, date: Int32, reactions: [Api.ReactionCount], qts: Int32) case updateBotNewBusinessMessage(flags: Int32, connectionId: String, message: Api.Message, replyToMessage: Api.Message?, qts: Int32) case updateBotPrecheckoutQuery(flags: Int32, queryId: Int64, userId: Int64, payload: Buffer, info: Api.PaymentRequestedInfo?, shippingOptionId: String?, currency: String, totalAmount: Int64) + case updateBotPurchasedPaidMedia(userId: Int64, payload: String, qts: Int32) case updateBotShippingQuery(queryId: Int64, userId: Int64, payload: Buffer, shippingAddress: Api.PostAddress) case updateBotStopped(userId: Int64, date: Int32, stopped: Api.Bool, qts: Int32) case updateBotWebhookJSON(data: Api.DataJSON) @@ -348,6 +349,7 @@ public extension Api { case updateNewStickerSet(stickerset: Api.messages.StickerSet) case updateNewStoryReaction(storyId: Int32, peer: Api.Peer, reaction: Api.Reaction) case updateNotifySettings(peer: Api.NotifyPeer, notifySettings: Api.PeerNotifySettings) + case updatePaidReactionPrivacy(private: Api.Bool) case updatePeerBlocked(flags: Int32, peerId: Api.Peer) case updatePeerHistoryTTL(flags: Int32, peer: Api.Peer, ttlPeriod: Int32?) case updatePeerLocated(peers: [Api.PeerLocated]) @@ -577,6 +579,14 @@ public extension Api { serializeString(currency, buffer: buffer, boxed: false) serializeInt64(totalAmount, buffer: buffer, boxed: false) break + case .updateBotPurchasedPaidMedia(let userId, let payload, let qts): + if boxed { + buffer.appendInt32(675009298) + } + serializeInt64(userId, buffer: buffer, boxed: false) + serializeString(payload, buffer: buffer, boxed: false) + serializeInt32(qts, buffer: buffer, boxed: false) + break case .updateBotShippingQuery(let queryId, let userId, let payload, let shippingAddress): if boxed { buffer.appendInt32(-1246823043) @@ -1173,6 +1183,12 @@ public extension Api { peer.serialize(buffer, true) notifySettings.serialize(buffer, true) break + case .updatePaidReactionPrivacy(let `private`): + if boxed { + buffer.appendInt32(1372224236) + } + `private`.serialize(buffer, true) + break case .updatePeerBlocked(let flags, let peerId): if boxed { buffer.appendInt32(-337610926) @@ -1649,6 +1665,8 @@ public extension Api { return ("updateBotNewBusinessMessage", [("flags", flags as Any), ("connectionId", connectionId as Any), ("message", message as Any), ("replyToMessage", replyToMessage as Any), ("qts", qts as Any)]) case .updateBotPrecheckoutQuery(let flags, let queryId, let userId, let payload, let info, let shippingOptionId, let currency, let totalAmount): return ("updateBotPrecheckoutQuery", [("flags", flags as Any), ("queryId", queryId as Any), ("userId", userId as Any), ("payload", payload as Any), ("info", info as Any), ("shippingOptionId", shippingOptionId as Any), ("currency", currency as Any), ("totalAmount", totalAmount as Any)]) + case .updateBotPurchasedPaidMedia(let userId, let payload, let qts): + return ("updateBotPurchasedPaidMedia", [("userId", userId as Any), ("payload", payload as Any), ("qts", qts as Any)]) case .updateBotShippingQuery(let queryId, let userId, let payload, let shippingAddress): return ("updateBotShippingQuery", [("queryId", queryId as Any), ("userId", userId as Any), ("payload", payload as Any), ("shippingAddress", shippingAddress as Any)]) case .updateBotStopped(let userId, let date, let stopped, let qts): @@ -1789,6 +1807,8 @@ public extension Api { return ("updateNewStoryReaction", [("storyId", storyId as Any), ("peer", peer as Any), ("reaction", reaction as Any)]) case .updateNotifySettings(let peer, let notifySettings): return ("updateNotifySettings", [("peer", peer as Any), ("notifySettings", notifySettings as Any)]) + case .updatePaidReactionPrivacy(let `private`): + return ("updatePaidReactionPrivacy", [("`private`", `private` as Any)]) case .updatePeerBlocked(let flags, let peerId): return ("updatePeerBlocked", [("flags", flags as Any), ("peerId", peerId as Any)]) case .updatePeerHistoryTTL(let flags, let peer, let ttlPeriod): @@ -2281,6 +2301,23 @@ public extension Api { return nil } } + public static func parse_updateBotPurchasedPaidMedia(_ reader: BufferReader) -> Update? { + var _1: Int64? + _1 = reader.readInt64() + var _2: String? + _2 = parseString(reader) + var _3: Int32? + _3 = reader.readInt32() + let _c1 = _1 != nil + let _c2 = _2 != nil + let _c3 = _3 != nil + if _c1 && _c2 && _c3 { + return Api.Update.updateBotPurchasedPaidMedia(userId: _1!, payload: _2!, qts: _3!) + } + else { + return nil + } + } public static func parse_updateBotShippingQuery(_ reader: BufferReader) -> Update? { var _1: Int64? _1 = reader.readInt64() @@ -3529,6 +3566,19 @@ public extension Api { return nil } } + public static func parse_updatePaidReactionPrivacy(_ reader: BufferReader) -> Update? { + var _1: Api.Bool? + if let signature = reader.readInt32() { + _1 = Api.parse(reader, signature: signature) as? Api.Bool + } + let _c1 = _1 != nil + if _c1 { + return Api.Update.updatePaidReactionPrivacy(private: _1!) + } + else { + return nil + } + } public static func parse_updatePeerBlocked(_ reader: BufferReader) -> Update? { var _1: Int32? _1 = reader.readInt32() diff --git a/submodules/TelegramApi/Sources/Api3.swift b/submodules/TelegramApi/Sources/Api3.swift index 4ee341396a9..2a1c1fb2122 100644 --- a/submodules/TelegramApi/Sources/Api3.swift +++ b/submodules/TelegramApi/Sources/Api3.swift @@ -395,6 +395,7 @@ public extension Api { case channelAdminLogEventActionParticipantJoinByRequest(invite: Api.ExportedChatInvite, approvedBy: Int64) case channelAdminLogEventActionParticipantLeave case channelAdminLogEventActionParticipantMute(participant: Api.GroupCallParticipant) + case channelAdminLogEventActionParticipantSubExtend(prevParticipant: Api.ChannelParticipant, newParticipant: Api.ChannelParticipant) case channelAdminLogEventActionParticipantToggleAdmin(prevParticipant: Api.ChannelParticipant, newParticipant: Api.ChannelParticipant) case channelAdminLogEventActionParticipantToggleBan(prevParticipant: Api.ChannelParticipant, newParticipant: Api.ChannelParticipant) case channelAdminLogEventActionParticipantUnmute(participant: Api.GroupCallParticipant) @@ -631,6 +632,13 @@ public extension Api { } participant.serialize(buffer, true) break + case .channelAdminLogEventActionParticipantSubExtend(let prevParticipant, let newParticipant): + if boxed { + buffer.appendInt32(1684286899) + } + prevParticipant.serialize(buffer, true) + newParticipant.serialize(buffer, true) + break case .channelAdminLogEventActionParticipantToggleAdmin(let prevParticipant, let newParticipant): if boxed { buffer.appendInt32(-714643696) @@ -811,6 +819,8 @@ public extension Api { return ("channelAdminLogEventActionParticipantLeave", []) case .channelAdminLogEventActionParticipantMute(let participant): return ("channelAdminLogEventActionParticipantMute", [("participant", participant as Any)]) + case .channelAdminLogEventActionParticipantSubExtend(let prevParticipant, let newParticipant): + return ("channelAdminLogEventActionParticipantSubExtend", [("prevParticipant", prevParticipant as Any), ("newParticipant", newParticipant as Any)]) case .channelAdminLogEventActionParticipantToggleAdmin(let prevParticipant, let newParticipant): return ("channelAdminLogEventActionParticipantToggleAdmin", [("prevParticipant", prevParticipant as Any), ("newParticipant", newParticipant as Any)]) case .channelAdminLogEventActionParticipantToggleBan(let prevParticipant, let newParticipant): @@ -1314,6 +1324,24 @@ public extension Api { return nil } } + public static func parse_channelAdminLogEventActionParticipantSubExtend(_ reader: BufferReader) -> ChannelAdminLogEventAction? { + var _1: Api.ChannelParticipant? + if let signature = reader.readInt32() { + _1 = Api.parse(reader, signature: signature) as? Api.ChannelParticipant + } + var _2: Api.ChannelParticipant? + if let signature = reader.readInt32() { + _2 = Api.parse(reader, signature: signature) as? Api.ChannelParticipant + } + let _c1 = _1 != nil + let _c2 = _2 != nil + if _c1 && _c2 { + return Api.ChannelAdminLogEventAction.channelAdminLogEventActionParticipantSubExtend(prevParticipant: _1!, newParticipant: _2!) + } + else { + return nil + } + } public static func parse_channelAdminLogEventActionParticipantToggleAdmin(_ reader: BufferReader) -> ChannelAdminLogEventAction? { var _1: Api.ChannelParticipant? if let signature = reader.readInt32() { diff --git a/submodules/TelegramApi/Sources/Api33.swift b/submodules/TelegramApi/Sources/Api33.swift index e368c5f707b..c1f54eeea8f 100644 --- a/submodules/TelegramApi/Sources/Api33.swift +++ b/submodules/TelegramApi/Sources/Api33.swift @@ -583,7 +583,7 @@ public extension Api.payments { public extension Api.payments { enum GiveawayInfo: TypeConstructorDescription { 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) + case giveawayInfoResults(flags: Int32, startDate: Int32, giftCodeSlug: String?, starsPrize: Int64?, finishDate: Int32, winnersCount: Int32, activatedCount: Int32?) public func serialize(_ buffer: Buffer, _ boxed: Swift.Bool) { switch self { @@ -597,16 +597,17 @@ public extension Api.payments { 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 startDate, let giftCodeSlug, let finishDate, let winnersCount, let activatedCount): + case .giveawayInfoResults(let flags, let startDate, let giftCodeSlug, let starsPrize, let finishDate, let winnersCount, let activatedCount): if boxed { - buffer.appendInt32(13456752) + buffer.appendInt32(-512366993) } 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)} + if Int(flags) & Int(1 << 3) != 0 {serializeString(giftCodeSlug!, buffer: buffer, boxed: false)} + if Int(flags) & Int(1 << 4) != 0 {serializeInt64(starsPrize!, buffer: buffer, boxed: false)} serializeInt32(finishDate, buffer: buffer, boxed: false) serializeInt32(winnersCount, buffer: buffer, boxed: false) - serializeInt32(activatedCount, buffer: buffer, boxed: false) + if Int(flags) & Int(1 << 2) != 0 {serializeInt32(activatedCount!, buffer: buffer, boxed: false)} break } } @@ -615,8 +616,8 @@ public extension Api.payments { switch self { 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)]) + case .giveawayInfoResults(let flags, let startDate, let giftCodeSlug, let starsPrize, let finishDate, let winnersCount, let activatedCount): + return ("giveawayInfoResults", [("flags", flags as Any), ("startDate", startDate as Any), ("giftCodeSlug", giftCodeSlug as Any), ("starsPrize", starsPrize as Any), ("finishDate", finishDate as Any), ("winnersCount", winnersCount as Any), ("activatedCount", activatedCount as Any)]) } } @@ -649,21 +650,24 @@ public extension Api.payments { 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() + if Int(_1!) & Int(1 << 3) != 0 {_3 = parseString(reader) } + var _4: Int64? + if Int(_1!) & Int(1 << 4) != 0 {_4 = reader.readInt64() } var _5: Int32? _5 = reader.readInt32() var _6: Int32? _6 = reader.readInt32() + var _7: Int32? + if Int(_1!) & Int(1 << 2) != 0 {_7 = reader.readInt32() } let _c1 = _1 != nil let _c2 = _2 != nil - let _c3 = (Int(_1!) & Int(1 << 0) == 0) || _3 != nil - let _c4 = _4 != nil + let _c3 = (Int(_1!) & Int(1 << 3) == 0) || _3 != nil + let _c4 = (Int(_1!) & Int(1 << 4) == 0) || _4 != nil let _c5 = _5 != nil 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!) + let _c7 = (Int(_1!) & Int(1 << 2) == 0) || _7 != nil + if _c1 && _c2 && _c3 && _c4 && _c5 && _c6 && _c7 { + return Api.payments.GiveawayInfo.giveawayInfoResults(flags: _1!, startDate: _2!, giftCodeSlug: _3, starsPrize: _4, finishDate: _5!, winnersCount: _6!, activatedCount: _7) } else { return nil diff --git a/submodules/TelegramApi/Sources/Api36.swift b/submodules/TelegramApi/Sources/Api36.swift index dc8ee6eebb0..8d34e43419d 100644 --- a/submodules/TelegramApi/Sources/Api36.swift +++ b/submodules/TelegramApi/Sources/Api36.swift @@ -6425,6 +6425,21 @@ public extension Api.functions.messages { }) } } +public extension Api.functions.messages { + static func getPaidReactionPrivacy() -> (FunctionDescription, Buffer, DeserializeFunctionResponse) { + let buffer = Buffer() + buffer.appendInt32(1193563562) + + return (FunctionDescription(name: "messages.getPaidReactionPrivacy", parameters: []), buffer, DeserializeFunctionResponse { (buffer: Buffer) -> Api.Updates? in + let reader = BufferReader(buffer) + var result: Api.Updates? + if let signature = reader.readInt32() { + result = Api.parse(reader, signature: signature) as? Api.Updates + } + return result + }) + } +} public extension Api.functions.messages { static func getPeerDialogs(peers: [Api.InputDialogPeer]) -> (FunctionDescription, Buffer, DeserializeFunctionResponse) { let buffer = Buffer() @@ -7935,15 +7950,16 @@ public extension Api.functions.messages { } } public extension Api.functions.messages { - static func sendPaidReaction(flags: Int32, peer: Api.InputPeer, msgId: Int32, count: Int32, randomId: Int64) -> (FunctionDescription, Buffer, DeserializeFunctionResponse) { + static func sendPaidReaction(flags: Int32, peer: Api.InputPeer, msgId: Int32, count: Int32, randomId: Int64, `private`: Api.Bool?) -> (FunctionDescription, Buffer, DeserializeFunctionResponse) { let buffer = Buffer() - buffer.appendInt32(633929278) + buffer.appendInt32(-1646877061) serializeInt32(flags, buffer: buffer, boxed: false) peer.serialize(buffer, true) serializeInt32(msgId, buffer: buffer, boxed: false) serializeInt32(count, buffer: buffer, boxed: false) serializeInt64(randomId, buffer: buffer, boxed: false) - return (FunctionDescription(name: "messages.sendPaidReaction", parameters: [("flags", String(describing: flags)), ("peer", String(describing: peer)), ("msgId", String(describing: msgId)), ("count", String(describing: count)), ("randomId", String(describing: randomId))]), buffer, DeserializeFunctionResponse { (buffer: Buffer) -> Api.Updates? in + if Int(flags) & Int(1 << 0) != 0 {`private`!.serialize(buffer, true)} + return (FunctionDescription(name: "messages.sendPaidReaction", parameters: [("flags", String(describing: flags)), ("peer", String(describing: peer)), ("msgId", String(describing: msgId)), ("count", String(describing: count)), ("randomId", String(describing: randomId)), ("`private`", String(describing: `private`))]), buffer, DeserializeFunctionResponse { (buffer: Buffer) -> Api.Updates? in let reader = BufferReader(buffer) var result: Api.Updates? if let signature = reader.readInt32() { @@ -8963,6 +8979,21 @@ public extension Api.functions.payments { }) } } +public extension Api.functions.payments { + static func getStarsGiveawayOptions() -> (FunctionDescription, Buffer, DeserializeFunctionResponse<[Api.StarsGiveawayOption]>) { + let buffer = Buffer() + buffer.appendInt32(-1122042562) + + return (FunctionDescription(name: "payments.getStarsGiveawayOptions", parameters: []), buffer, DeserializeFunctionResponse { (buffer: Buffer) -> [Api.StarsGiveawayOption]? in + let reader = BufferReader(buffer) + var result: [Api.StarsGiveawayOption]? + if let _ = reader.readInt32() { + result = Api.parseVector(reader, elementSignature: 0, elementType: Api.StarsGiveawayOption.self) + } + return result + }) + } +} public extension Api.functions.payments { static func getStarsRevenueAdsAccountUrl(peer: Api.InputPeer) -> (FunctionDescription, Buffer, DeserializeFunctionResponse) { let buffer = Buffer() diff --git a/submodules/TelegramCallsUI/BUILD b/submodules/TelegramCallsUI/BUILD index 2c3825434ee..a3ffac3d2eb 100644 --- a/submodules/TelegramCallsUI/BUILD +++ b/submodules/TelegramCallsUI/BUILD @@ -111,6 +111,12 @@ swift_library( "//submodules/ImageBlur", "//submodules/MetalEngine", "//submodules/TelegramUI/Components/Calls/VoiceChatActionButton", + "//submodules/TelegramUI/Components/PlainButtonComponent", + "//submodules/TelegramUI/Components/LottieComponent", + "//submodules/TelegramUI/Components/Stories/PeerListItemComponent", + "//submodules/TelegramUI/Components/BackButtonComponent", + "//submodules/DirectMediaImageCache", + "//submodules/FastBlur", ], visibility = [ "//visibility:public", diff --git a/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift b/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift index e14bdfa6a86..fd9e3acc4da 100644 --- a/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift @@ -335,7 +335,7 @@ final class MediaStreamVideoComponent: Component { stallTimer = _stallTimer self.clipsToBounds = component.isFullscreen // or just true - if let videoView = self.videoRenderingContext.makeView(input: input, forceSampleBufferDisplayLayer: true) { + if let videoView = self.videoRenderingContext.makeView(input: input, blur: false, forceSampleBufferDisplayLayer: true) { self.videoView = videoView self.addSubview(videoView) videoView.alpha = 0 diff --git a/submodules/TelegramCallsUI/Sources/PresentationCall.swift b/submodules/TelegramCallsUI/Sources/PresentationCall.swift index 8b40e8d4f11..bd564a7b95e 100644 --- a/submodules/TelegramCallsUI/Sources/PresentationCall.swift +++ b/submodules/TelegramCallsUI/Sources/PresentationCall.swift @@ -295,7 +295,7 @@ public final class PresentationCallImpl: PresentationCall { if let data = context.currentAppConfiguration.with({ $0 }).data, let _ = data["ios_killswitch_disable_call_device"] { self.sharedAudioDevice = nil } else { - self.sharedAudioDevice = OngoingCallContext.AudioDevice.create() + self.sharedAudioDevice = OngoingCallContext.AudioDevice.create(enableSystemMute: context.sharedContext.immediateExperimentalUISettings.experimentalCallMute) } self.audioSessionActiveDisposable = (self.audioSessionActive.get() diff --git a/submodules/TelegramCallsUI/Sources/PresentationGroupCall.swift b/submodules/TelegramCallsUI/Sources/PresentationGroupCall.swift index 811d2553ef8..f270f3ab290 100644 --- a/submodules/TelegramCallsUI/Sources/PresentationGroupCall.swift +++ b/submodules/TelegramCallsUI/Sources/PresentationGroupCall.swift @@ -408,7 +408,7 @@ private extension CurrentImpl { } } - func video(endpointId: String) -> Signal? { + func video(endpointId: String) -> Signal { switch self { case let .call(callContext): return callContext.video(endpointId: endpointId) @@ -663,6 +663,10 @@ public final class PresentationGroupCallImpl: PresentationGroupCall { } private var ssrcMapping: [UInt32: SsrcMapping] = [:] + private var requestedVideoChannels: [OngoingGroupCallContext.VideoChannel] = [] + private var suspendVideoChannelRequests: Bool = false + private var pendingVideoSubscribers = Bag<(String, MetaDisposable, (OngoingGroupCallContext.VideoFrameData) -> Void)>() + private var summaryInfoState = Promise(nil) private var summaryParticipantsState = Promise(nil) @@ -1683,7 +1687,7 @@ public final class PresentationGroupCallImpl: PresentationGroupCall { strongSelf.requestCall(movingFromBroadcastToRtc: false) } } - }, outgoingAudioBitrateKbit: outgoingAudioBitrateKbit, videoContentType: self.isVideoEnabled ? .generic : .none, enableNoiseSuppression: false, disableAudioInput: self.isStream, preferX264: self.accountContext.sharedContext.immediateExperimentalUISettings.preferredVideoCodec == "H264", logPath: allocateCallLogPath(account: self.account), onMutedSpeechActivityDetected: { [weak self] value in + }, outgoingAudioBitrateKbit: outgoingAudioBitrateKbit, videoContentType: self.isVideoEnabled ? .generic : .none, enableNoiseSuppression: false, disableAudioInput: self.isStream, enableSystemMute: self.accountContext.sharedContext.immediateExperimentalUISettings.experimentalCallMute, preferX264: self.accountContext.sharedContext.immediateExperimentalUISettings.preferredVideoCodec == "H264", logPath: allocateCallLogPath(account: self.account), onMutedSpeechActivityDetected: { [weak self] value in Queue.mainQueue().async { guard let strongSelf = self else { return @@ -1695,6 +1699,9 @@ public final class PresentationGroupCallImpl: PresentationGroupCall { self.genericCallContext = genericCallContext self.stateVersionValue += 1 + + genericCallContext.setRequestedVideoChannels(self.suspendVideoChannelRequests ? [] : self.requestedVideoChannels) + self.connectPendingVideoSubscribers() } self.joinDisposable.set((genericCallContext.joinPayload @@ -2990,7 +2997,7 @@ public final class PresentationGroupCallImpl: PresentationGroupCall { self.hasScreencast = true - let screencastCallContext = OngoingGroupCallContext(audioSessionActive: .single(true), video: self.screencastCapturer, requestMediaChannelDescriptions: { _, _ in EmptyDisposable }, rejoinNeeded: { }, outgoingAudioBitrateKbit: nil, videoContentType: .screencast, enableNoiseSuppression: false, disableAudioInput: true, preferX264: false, logPath: "", onMutedSpeechActivityDetected: { _ in }) + let screencastCallContext = OngoingGroupCallContext(audioSessionActive: .single(true), video: self.screencastCapturer, requestMediaChannelDescriptions: { _, _ in EmptyDisposable }, rejoinNeeded: { }, outgoingAudioBitrateKbit: nil, videoContentType: .screencast, enableNoiseSuppression: false, disableAudioInput: true, enableSystemMute: false, preferX264: false, logPath: "", onMutedSpeechActivityDetected: { _ in }) self.screencastCallContext = screencastCallContext self.screencastJoinDisposable.set((screencastCallContext.joinPayload @@ -3055,7 +3062,7 @@ public final class PresentationGroupCallImpl: PresentationGroupCall { } public func setRequestedVideoList(items: [PresentationGroupCallRequestedVideo]) { - self.genericCallContext?.setRequestedVideoChannels(items.compactMap { item -> OngoingGroupCallContext.VideoChannel in + self.requestedVideoChannels = items.compactMap { item -> OngoingGroupCallContext.VideoChannel in let mappedMinQuality: OngoingGroupCallContext.VideoChannel.Quality let mappedMaxQuality: OngoingGroupCallContext.VideoChannel.Quality switch item.minQuality { @@ -3083,7 +3090,20 @@ public final class PresentationGroupCallImpl: PresentationGroupCall { minQuality: mappedMinQuality, maxQuality: mappedMaxQuality ) - }) + } + if let genericCallContext = self.genericCallContext, !self.suspendVideoChannelRequests { + genericCallContext.setRequestedVideoChannels(self.requestedVideoChannels) + } + } + + public func setSuspendVideoChannelRequests(_ value: Bool) { + if self.suspendVideoChannelRequests != value { + self.suspendVideoChannelRequests = value + + if let genericCallContext = self.genericCallContext { + genericCallContext.setRequestedVideoChannels(self.suspendVideoChannelRequests ? [] : self.requestedVideoChannels) + } + } } public func setCurrentAudioOutput(_ output: AudioSessionOutput) { @@ -3538,7 +3558,49 @@ public final class PresentationGroupCallImpl: PresentationGroupCall { } func video(endpointId: String) -> Signal? { - return self.genericCallContext?.video(endpointId: endpointId) + return Signal { [weak self] subscriber in + guard let self else { + return EmptyDisposable + } + + if let genericCallContext = self.genericCallContext { + return genericCallContext.video(endpointId: endpointId).start(next: { value in + subscriber.putNext(value) + }) + } else { + let disposable = MetaDisposable() + let index = self.pendingVideoSubscribers.add((endpointId, disposable, { value in + subscriber.putNext(value) + })) + + return ActionDisposable { [weak self] in + disposable.dispose() + + Queue.mainQueue().async { + guard let self else { + return + } + self.pendingVideoSubscribers.remove(index) + } + } + } + } + |> runOn(.mainQueue()) + } + + private func connectPendingVideoSubscribers() { + guard let genericCallContext = self.genericCallContext else { + return + } + + let items = self.pendingVideoSubscribers.copyItems() + self.pendingVideoSubscribers.removeAll() + + for (endpointId, disposable, f) in items { + disposable.set(genericCallContext.video(endpointId: endpointId).start(next: { value in + f(value) + })) + } } public func loadMoreMembers(token: String) { diff --git a/submodules/TelegramCallsUI/Sources/VideoChatActionButtonComponent.swift b/submodules/TelegramCallsUI/Sources/VideoChatActionButtonComponent.swift new file mode 100644 index 00000000000..13c69b2cf27 --- /dev/null +++ b/submodules/TelegramCallsUI/Sources/VideoChatActionButtonComponent.swift @@ -0,0 +1,291 @@ +import Foundation +import UIKit +import Display +import ComponentFlow +import MultilineTextComponent +import TelegramPresentationData +import AppBundle +import TelegramAudio + +final class VideoChatActionButtonComponent: Component { + enum Content: Equatable { + enum BluetoothType: Equatable { + case generic + case airpods + case airpodsPro + case airpodsMax + } + + enum Audio: Equatable { + case none + case builtin + case speaker + case headphones + case bluetooth(BluetoothType) + } + + fileprivate enum IconType: Equatable { + enum Audio: Equatable { + case speaker + case headphones + case bluetooth(BluetoothType) + } + + case audio(audio: Audio) + case video + case leave + } + + case audio(audio: Audio) + case video(isActive: Bool) + case leave + + fileprivate var iconType: IconType { + switch self { + case let .audio(audio): + let mappedAudio: IconType.Audio + switch audio { + case .none, .builtin, .speaker: + mappedAudio = .speaker + case .headphones: + mappedAudio = .headphones + case let .bluetooth(type): + mappedAudio = .bluetooth(type) + } + return .audio(audio: mappedAudio) + case .video: + return .video + case .leave: + return .leave + } + } + } + + enum MicrophoneState { + case connecting + case muted + case unmuted + case raiseHand + } + + let strings: PresentationStrings + let content: Content + let microphoneState: MicrophoneState + let isCollapsed: Bool + + init( + strings: PresentationStrings, + content: Content, + microphoneState: MicrophoneState, + isCollapsed: Bool + ) { + self.strings = strings + self.content = content + self.microphoneState = microphoneState + self.isCollapsed = isCollapsed + } + + static func ==(lhs: VideoChatActionButtonComponent, rhs: VideoChatActionButtonComponent) -> Bool { + if lhs.strings !== rhs.strings { + return false + } + if lhs.content != rhs.content { + return false + } + if lhs.microphoneState != rhs.microphoneState { + return false + } + if lhs.isCollapsed != rhs.isCollapsed { + return false + } + return true + } + + final class View: HighlightTrackingButton { + private let icon = ComponentView() + private let background: UIImageView + private let title = ComponentView() + + private var component: VideoChatActionButtonComponent? + private var isUpdating: Bool = false + + private var contentImage: UIImage? + + override init(frame: CGRect) { + self.background = UIImageView() + + super.init(frame: frame) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func update(component: VideoChatActionButtonComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + self.isUpdating = true + defer { + self.isUpdating = false + } + + let previousComponent = self.component + self.component = component + + let alphaTransition: ComponentTransition = transition.animation.isImmediate ? .immediate : .easeInOut(duration: 0.2) + + let titleText: String + let backgroundColor: UIColor + let iconDiameter: CGFloat + switch component.content { + case let .audio(audio): + var isActive = false + switch audio { + case .none, .builtin: + titleText = component.strings.Call_Speaker + case .speaker: + isActive = true + titleText = component.strings.Call_Speaker + case .headphones: + titleText = component.strings.Call_Audio + case .bluetooth: + titleText = component.strings.Call_Audio + } + switch component.microphoneState { + case .connecting: + backgroundColor = UIColor(white: 0.1, alpha: 1.0) + case .muted: + backgroundColor = !isActive ? UIColor(rgb: 0x002E5D) : UIColor(rgb: 0x027FFF) + case .unmuted: + backgroundColor = !isActive ? UIColor(rgb: 0x124B21) : UIColor(rgb: 0x34C659) + case .raiseHand: + backgroundColor = UIColor(rgb: 0x3252EF) + } + iconDiameter = 60.0 + case let .video(isActive): + titleText = "video" + switch component.microphoneState { + case .connecting: + backgroundColor = UIColor(white: 0.1, alpha: 1.0) + case .muted: + backgroundColor = !isActive ? UIColor(rgb: 0x002E5D) : UIColor(rgb: 0x027FFF) + case .unmuted: + backgroundColor = !isActive ? UIColor(rgb: 0x124B21) : UIColor(rgb: 0x34C659) + case .raiseHand: + backgroundColor = UIColor(rgb: 0x3252EF) + } + iconDiameter = 60.0 + case .leave: + titleText = "leave" + backgroundColor = UIColor(rgb: 0x47191E) + iconDiameter = 22.0 + } + + if self.contentImage == nil || previousComponent?.content.iconType != component.content.iconType { + switch component.content.iconType { + case let .audio(audio): + let iconName: String + switch audio { + case .speaker: + iconName = "Call/CallSpeakerButton" + case .headphones: + iconName = "Call/CallHeadphonesButton" + case let .bluetooth(type): + switch type { + case .generic: + iconName = "Call/CallBluetoothButton" + case .airpods: + iconName = "Call/CallAirpodsButton" + case .airpodsPro: + iconName = "Call/CallAirpodsProButton" + case .airpodsMax: + iconName = "Call/CallAirpodsMaxButton" + } + } + self.contentImage = UIImage(bundleImageName: iconName)?.precomposed().withRenderingMode(.alwaysTemplate) + case .video: + self.contentImage = UIImage(bundleImageName: "Call/CallCameraButton")?.precomposed().withRenderingMode(.alwaysTemplate) + case .leave: + self.contentImage = generateImage(CGSize(width: 28.0, height: 28.0), opaque: false, rotatedContext: { size, context in + let bounds = CGRect(origin: CGPoint(), size: size) + context.clear(bounds) + + context.setLineWidth(4.0 - UIScreenPixel) + context.setLineCap(.round) + context.setStrokeColor(UIColor.white.cgColor) + + context.move(to: CGPoint(x: 2.0 + UIScreenPixel, y: 2.0 + UIScreenPixel)) + context.addLine(to: CGPoint(x: 26.0 - UIScreenPixel, y: 26.0 - UIScreenPixel)) + context.strokePath() + + context.move(to: CGPoint(x: 26.0 - UIScreenPixel, y: 2.0 + UIScreenPixel)) + context.addLine(to: CGPoint(x: 2.0 + UIScreenPixel, y: 26.0 - UIScreenPixel)) + context.strokePath() + }) + } + } + + let titleSize = self.title.update( + transition: .immediate, + component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString(string: titleText, font: Font.regular(13.0), textColor: .white)) + )), + environment: {}, + containerSize: CGSize(width: 90.0, height: 100.0) + ) + + let size = CGSize(width: availableSize.width, height: availableSize.height) + + if self.background.superview == nil { + self.addSubview(self.background) + self.background.image = generateStretchableFilledCircleImage(diameter: 56.0, color: .white)?.withRenderingMode(.alwaysTemplate) + self.background.tintColor = backgroundColor + } + transition.setFrame(view: self.background, frame: CGRect(origin: CGPoint(), size: size)) + + let tintTransition: ComponentTransition + if !transition.animation.isImmediate { + tintTransition = .easeInOut(duration: 0.2) + } else { + tintTransition = .immediate + } + tintTransition.setTintColor(layer: self.background.layer, color: backgroundColor) + + let titleFrame = CGRect(origin: CGPoint(x: floor((size.width - titleSize.width) * 0.5), y: size.height + 8.0), size: titleSize) + if let titleView = self.title.view { + if titleView.superview == nil { + self.addSubview(titleView) + } + transition.setPosition(view: titleView, position: titleFrame.center) + titleView.bounds = CGRect(origin: CGPoint(), size: titleFrame.size) + alphaTransition.setAlpha(view: titleView, alpha: component.isCollapsed ? 0.0 : 1.0) + } + + let iconSize = self.icon.update( + transition: .immediate, + component: AnyComponent(Image( + image: self.contentImage, + tintColor: .white, + size: CGSize(width: iconDiameter, height: iconDiameter) + )), + environment: {}, + containerSize: CGSize(width: 100.0, height: 100.0) + ) + let iconFrame = CGRect(origin: CGPoint(x: floor((size.width - iconSize.width) * 0.5), y: floor((size.height - iconSize.height) * 0.5)), size: iconSize) + if let iconView = self.icon.view { + if iconView.superview == nil { + self.addSubview(iconView) + } + transition.setFrame(view: iconView, frame: iconFrame) + } + + return size + } + } + + func makeView() -> View { + return View() + } + + func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } +} diff --git a/submodules/TelegramCallsUI/Sources/VideoChatExpandedControlsComponent.swift b/submodules/TelegramCallsUI/Sources/VideoChatExpandedControlsComponent.swift new file mode 100644 index 00000000000..40fde2f5028 --- /dev/null +++ b/submodules/TelegramCallsUI/Sources/VideoChatExpandedControlsComponent.swift @@ -0,0 +1,137 @@ +import Foundation +import UIKit +import Display +import ComponentFlow +import MultilineTextComponent +import TelegramPresentationData +import AppBundle +import BackButtonComponent + +final class VideoChatExpandedControlsComponent: Component { + let theme: PresentationTheme + let strings: PresentationStrings + let isPinned: Bool + let backAction: () -> Void + let pinAction: () -> Void + + init( + theme: PresentationTheme, + strings: PresentationStrings, + isPinned: Bool, + backAction: @escaping () -> Void, + pinAction: @escaping () -> Void + ) { + self.theme = theme + self.strings = strings + self.isPinned = isPinned + self.backAction = backAction + self.pinAction = pinAction + } + + static func ==(lhs: VideoChatExpandedControlsComponent, rhs: VideoChatExpandedControlsComponent) -> Bool { + if lhs.theme !== rhs.theme { + return false + } + if lhs.strings !== rhs.strings { + return false + } + if lhs.isPinned != rhs.isPinned { + return false + } + return true + } + + final class View: UIView { + private let backButton = ComponentView() + private let pinStatus = ComponentView() + + private var component: VideoChatExpandedControlsComponent? + private var isUpdating: Bool = false + + private var ignoreScrolling: Bool = false + + override init(frame: CGRect) { + super.init(frame: frame) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + if let backButtonView = self.backButton.view, let result = backButtonView.hitTest(self.convert(point, to: backButtonView), with: event) { + return result + } + if let pinStatusView = self.pinStatus.view, let result = pinStatusView.hitTest(self.convert(point, to: pinStatusView), with: event) { + return result + } + return nil + } + + func update(component: VideoChatExpandedControlsComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + self.isUpdating = true + defer { + self.isUpdating = false + } + + self.component = component + + let backButtonSize = self.backButton.update( + transition: transition, + component: AnyComponent(BackButtonComponent( + title: component.strings.Common_Back, + color: .white, + action: { [weak self] in + guard let self, let component = self.component else { + return + } + component.backAction() + } + )), + environment: {}, + containerSize: CGSize(width: availableSize.width * 0.5, height: 100.0) + ) + let backButtonFrame = CGRect(origin: CGPoint(x: 12.0, y: 12.0), size: backButtonSize) + if let backButtonView = self.backButton.view { + if backButtonView.superview == nil { + self.addSubview(backButtonView) + } + transition.setFrame(view: backButtonView, frame: backButtonFrame) + } + + let pinStatusSize = self.pinStatus.update( + transition: transition, + component: AnyComponent(VideoChatPinStatusComponent( + theme: component.theme, + strings: component.strings, + isPinned: component.isPinned, + action: { [weak self] in + guard let self, let component = self.component else { + return + } + component.pinAction() + } + )), + environment: {}, + containerSize: CGSize(width: availableSize.width, height: 100.0) + ) + let pinStatusFrame = CGRect(origin: CGPoint(x: availableSize.width - 0.0 - pinStatusSize.width, y: 0.0), size: pinStatusSize) + if let pinStatusView = self.pinStatus.view { + if pinStatusView.superview == nil { + self.addSubview(pinStatusView) + } + transition.setFrame(view: pinStatusView, frame: pinStatusFrame) + } + + return availableSize + } + } + + func makeView() -> View { + return View() + } + + func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } +} diff --git a/submodules/TelegramCallsUI/Sources/VideoChatExpandedParticipantThumbnailsComponent.swift b/submodules/TelegramCallsUI/Sources/VideoChatExpandedParticipantThumbnailsComponent.swift new file mode 100644 index 00000000000..716bfd8076b --- /dev/null +++ b/submodules/TelegramCallsUI/Sources/VideoChatExpandedParticipantThumbnailsComponent.swift @@ -0,0 +1,686 @@ +import Foundation +import UIKit +import Display +import ComponentFlow +import MultilineTextComponent +import TelegramPresentationData +import AppBundle +import TelegramCore +import AccountContext +import SwiftSignalKit +import MetalEngine +import CallScreen +import AvatarNode + +final class VideoChatParticipantThumbnailComponent: Component { + let call: PresentationGroupCall + let theme: PresentationTheme + let participant: GroupCallParticipantsContext.Participant + let isPresentation: Bool + let isSelected: Bool + let isSpeaking: Bool + let action: (() -> Void)? + + init( + call: PresentationGroupCall, + theme: PresentationTheme, + participant: GroupCallParticipantsContext.Participant, + isPresentation: Bool, + isSelected: Bool, + isSpeaking: Bool, + action: (() -> Void)? + ) { + self.call = call + self.theme = theme + self.participant = participant + self.isPresentation = isPresentation + self.isSelected = isSelected + self.isSpeaking = isSpeaking + self.action = action + } + + static func ==(lhs: VideoChatParticipantThumbnailComponent, rhs: VideoChatParticipantThumbnailComponent) -> Bool { + if lhs.call !== rhs.call { + return false + } + if lhs.theme !== rhs.theme { + return false + } + if lhs.participant != rhs.participant { + return false + } + if lhs.isPresentation != rhs.isPresentation { + return false + } + if lhs.isSelected != rhs.isSelected { + return false + } + if lhs.isSpeaking != rhs.isSpeaking { + return false + } + return true + } + + private struct VideoSpec: Equatable { + var resolution: CGSize + var rotationAngle: Float + + init(resolution: CGSize, rotationAngle: Float) { + self.resolution = resolution + self.rotationAngle = rotationAngle + } + } + + final class View: HighlightTrackingButton { + private static let selectedBorderImage: UIImage? = { + return generateStretchableFilledCircleImage(diameter: 20.0, color: nil, strokeColor: UIColor.white, strokeWidth: 2.0)?.withRenderingMode(.alwaysTemplate) + }() + + private var component: VideoChatParticipantThumbnailComponent? + private weak var componentState: EmptyComponentState? + private var isUpdating: Bool = false + + private var avatarNode: AvatarNode? + private let title = ComponentView() + private let muteStatus = ComponentView() + + private var selectedBorderView: UIImageView? + + private var videoSource: AdaptedCallVideoSource? + private var videoDisposable: Disposable? + private var videoBackgroundLayer: SimpleLayer? + private var videoLayer: PrivateCallVideoLayer? + private var videoSpec: VideoSpec? + + override init(frame: CGRect) { + super.init(frame: frame) + + //TODO:release optimize + self.clipsToBounds = true + self.layer.cornerRadius = 10.0 + + self.addTarget(self, action: #selector(self.pressed), for: .touchUpInside) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + self.videoDisposable?.dispose() + } + + @objc private func pressed() { + guard let component = self.component, let action = component.action else { + return + } + action() + } + + func update(component: VideoChatParticipantThumbnailComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + self.isUpdating = true + defer { + self.isUpdating = false + } + + if self.component == nil { + self.backgroundColor = UIColor(rgb: 0x1C1C1E) + } + + let previousComponent = self.component + + let wasSpeaking = previousComponent?.isSpeaking ?? false + let speakingAlphaTransition: ComponentTransition + if transition.animation.isImmediate { + speakingAlphaTransition = .immediate + } else { + if let previousComponent, previousComponent.isSelected == component.isSelected { + if !wasSpeaking { + speakingAlphaTransition = .easeInOut(duration: 0.1) + } else { + speakingAlphaTransition = .easeInOut(duration: 0.25) + } + } else { + speakingAlphaTransition = .immediate + } + } + + self.component = component + self.componentState = state + + let avatarNode: AvatarNode + if let current = self.avatarNode { + avatarNode = current + } else { + avatarNode = AvatarNode(font: avatarPlaceholderFont(size: 17.0)) + avatarNode.isUserInteractionEnabled = false + self.avatarNode = avatarNode + self.addSubview(avatarNode.view) + } + + let avatarSize = CGSize(width: 50.0, height: 50.0) + let avatarFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((availableSize.width - avatarSize.width) * 0.5), y: 7.0), size: avatarSize) + transition.setFrame(view: avatarNode.view, frame: avatarFrame) + avatarNode.updateSize(size: avatarSize) + if component.participant.peer.smallProfileImage != nil { + avatarNode.setPeerV2(context: component.call.accountContext, theme: component.theme, peer: EnginePeer(component.participant.peer), displayDimensions: avatarSize) + } else { + avatarNode.setPeer(context: component.call.accountContext, theme: component.theme, peer: EnginePeer(component.participant.peer), displayDimensions: avatarSize) + } + + let muteStatusSize = self.muteStatus.update( + transition: transition, + component: AnyComponent(VideoChatMuteIconComponent( + color: .white, + content: component.isPresentation ? .screenshare : .mute(isFilled: true, isMuted: component.participant.muteState != nil && !component.isSpeaking) + )), + environment: {}, + containerSize: CGSize(width: 36.0, height: 36.0) + ) + let muteStatusFrame = CGRect(origin: CGPoint(x: availableSize.width + 5.0 - muteStatusSize.width, y: availableSize.height + 5.0 - muteStatusSize.height), size: muteStatusSize) + if let muteStatusView = self.muteStatus.view as? VideoChatMuteIconComponent.View { + if muteStatusView.superview == nil { + self.addSubview(muteStatusView) + } + transition.setPosition(view: muteStatusView, position: muteStatusFrame.center) + transition.setBounds(view: muteStatusView, bounds: CGRect(origin: CGPoint(), size: muteStatusFrame.size)) + transition.setScale(view: muteStatusView, scale: 0.65) + } + + let titleSize = self.title.update( + transition: .immediate, + component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString(string: EnginePeer(component.participant.peer).compactDisplayTitle, font: Font.semibold(13.0), textColor: .white)) + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - 6.0 * 2.0 - 8.0, height: 100.0) + ) + let titleFrame = CGRect(origin: CGPoint(x: 6.0, y: availableSize.height - 6.0 - titleSize.height), size: titleSize) + if let titleView = self.title.view { + if titleView.superview == nil { + titleView.layer.anchorPoint = CGPoint() + titleView.isUserInteractionEnabled = false + self.addSubview(titleView) + } + transition.setPosition(view: titleView, position: titleFrame.origin) + titleView.bounds = CGRect(origin: CGPoint(), size: titleFrame.size) + } + + if let videoDescription = component.isPresentation ? component.participant.presentationDescription : component.participant.videoDescription { + let videoBackgroundLayer: SimpleLayer + if let current = self.videoBackgroundLayer { + videoBackgroundLayer = current + } else { + videoBackgroundLayer = SimpleLayer() + videoBackgroundLayer.backgroundColor = UIColor(white: 0.1, alpha: 1.0).cgColor + self.videoBackgroundLayer = videoBackgroundLayer + self.layer.insertSublayer(videoBackgroundLayer, above: avatarNode.layer) + videoBackgroundLayer.isHidden = true + } + + let videoLayer: PrivateCallVideoLayer + if let current = self.videoLayer { + videoLayer = current + } else { + videoLayer = PrivateCallVideoLayer() + self.videoLayer = videoLayer + self.layer.insertSublayer(videoLayer.blurredLayer, above: videoBackgroundLayer) + self.layer.insertSublayer(videoLayer, above: videoLayer.blurredLayer) + + videoLayer.blurredLayer.opacity = 0.25 + + if let input = (component.call as! PresentationGroupCallImpl).video(endpointId: videoDescription.endpointId) { + let videoSource = AdaptedCallVideoSource(videoStreamSignal: input) + self.videoSource = videoSource + + self.videoDisposable?.dispose() + self.videoDisposable = videoSource.addOnUpdated { [weak self] in + guard let self, let videoSource = self.videoSource, let videoLayer = self.videoLayer else { + return + } + + let videoOutput = videoSource.currentOutput + videoLayer.video = videoOutput + + if let videoOutput { + let videoSpec = VideoSpec(resolution: videoOutput.resolution, rotationAngle: videoOutput.rotationAngle) + if self.videoSpec != videoSpec { + self.videoSpec = videoSpec + if !self.isUpdating { + self.componentState?.updated(transition: .immediate, isLocal: true) + } + } + } else { + if self.videoSpec != nil { + self.videoSpec = nil + if !self.isUpdating { + self.componentState?.updated(transition: .immediate, isLocal: true) + } + } + } + } + } + } + + transition.setFrame(layer: videoBackgroundLayer, frame: CGRect(origin: CGPoint(), size: availableSize)) + + if let videoSpec = self.videoSpec { + videoBackgroundLayer.isHidden = component.isSelected + videoLayer.blurredLayer.isHidden = component.isSelected + videoLayer.isHidden = component.isSelected + + var rotatedResolution = videoSpec.resolution + var videoIsRotated = false + if abs(videoSpec.rotationAngle - Float.pi * 0.5) < .ulpOfOne || abs(videoSpec.rotationAngle - Float.pi * 3.0 / 2.0) < .ulpOfOne { + videoIsRotated = true + } + if videoIsRotated { + rotatedResolution = CGSize(width: rotatedResolution.height, height: rotatedResolution.width) + } + + let videoSize = rotatedResolution.aspectFilled(availableSize) + let videoFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((availableSize.width - videoSize.width) * 0.5), y: floorToScreenPixels((availableSize.height - videoSize.height) * 0.5)), size: videoSize) + let blurredVideoSize = rotatedResolution.aspectFilled(availableSize) + let blurredVideoFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((availableSize.width - blurredVideoSize.width) * 0.5), y: floorToScreenPixels((availableSize.height - blurredVideoSize.height) * 0.5)), size: blurredVideoSize) + + let videoResolution = rotatedResolution.aspectFitted(CGSize(width: availableSize.width * 3.0, height: availableSize.height * 3.0)) + + var rotatedVideoResolution = videoResolution + var rotatedVideoFrame = videoFrame + var rotatedBlurredVideoFrame = blurredVideoFrame + var rotatedVideoBoundsSize = videoFrame.size + var rotatedBlurredVideoBoundsSize = blurredVideoFrame.size + + if videoIsRotated { + rotatedVideoBoundsSize = CGSize(width: rotatedVideoBoundsSize.height, height: rotatedVideoBoundsSize.width) + rotatedVideoFrame = rotatedVideoFrame.size.centered(around: rotatedVideoFrame.center) + + rotatedBlurredVideoBoundsSize = CGSize(width: rotatedBlurredVideoBoundsSize.height, height: rotatedBlurredVideoBoundsSize.width) + rotatedBlurredVideoFrame = rotatedBlurredVideoFrame.size.centered(around: rotatedBlurredVideoFrame.center) + } + + rotatedVideoResolution = rotatedVideoResolution.aspectFittedOrSmaller(CGSize(width: rotatedVideoFrame.width * UIScreenScale, height: rotatedVideoFrame.height * UIScreenScale)) + + transition.setPosition(layer: videoLayer, position: rotatedVideoFrame.center) + transition.setBounds(layer: videoLayer, bounds: CGRect(origin: CGPoint(), size: rotatedVideoBoundsSize)) + transition.setTransform(layer: videoLayer, transform: CATransform3DMakeRotation(CGFloat(videoSpec.rotationAngle), 0.0, 0.0, 1.0)) + videoLayer.renderSpec = RenderLayerSpec(size: RenderSize(width: Int(rotatedVideoResolution.width), height: Int(rotatedVideoResolution.height)), edgeInset: 2) + + transition.setPosition(layer: videoLayer.blurredLayer, position: rotatedBlurredVideoFrame.center) + transition.setBounds(layer: videoLayer.blurredLayer, bounds: CGRect(origin: CGPoint(), size: rotatedBlurredVideoBoundsSize)) + transition.setTransform(layer: videoLayer.blurredLayer, transform: CATransform3DMakeRotation(CGFloat(videoSpec.rotationAngle), 0.0, 0.0, 1.0)) + } + } else { + if let videoBackgroundLayer = self.videoBackgroundLayer { + self.videoBackgroundLayer = nil + videoBackgroundLayer.removeFromSuperlayer() + } + if let videoLayer = self.videoLayer { + self.videoLayer = nil + videoLayer.blurredLayer.removeFromSuperlayer() + videoLayer.removeFromSuperlayer() + } + self.videoDisposable?.dispose() + self.videoDisposable = nil + self.videoSource = nil + self.videoSpec = nil + } + + if component.isSelected || component.isSpeaking { + let selectedBorderView: UIImageView + if let current = self.selectedBorderView { + selectedBorderView = current + + speakingAlphaTransition.setTintColor(layer: selectedBorderView.layer, color: component.isSpeaking ? UIColor(rgb: 0x33C758) : component.theme.list.itemAccentColor) + } else { + selectedBorderView = UIImageView() + self.selectedBorderView = selectedBorderView + selectedBorderView.alpha = 0.0 + self.addSubview(selectedBorderView) + selectedBorderView.image = View.selectedBorderImage + + selectedBorderView.frame = CGRect(origin: CGPoint(), size: availableSize) + + speakingAlphaTransition.setAlpha(view: selectedBorderView, alpha: 1.0) + ComponentTransition.immediate.setTintColor(layer: selectedBorderView.layer, color: component.isSpeaking ? UIColor(rgb: 0x33C758) : component.theme.list.itemAccentColor) + } + } else if let selectedBorderView = self.selectedBorderView { + if !speakingAlphaTransition.animation.isImmediate { + if selectedBorderView.alpha != 0.0 { + speakingAlphaTransition.setAlpha(view: selectedBorderView, alpha: 0.0, completion: { [weak self, weak selectedBorderView] completed in + guard let self, let component = self.component, let selectedBorderView, self.selectedBorderView === selectedBorderView, completed else { + return + } + if !component.isSelected && !component.isSpeaking { + selectedBorderView.removeFromSuperview() + self.selectedBorderView = nil + } + }) + } + } else { + self.selectedBorderView = nil + selectedBorderView.removeFromSuperview() + } + } + + if let selectedBorderView = self.selectedBorderView { + transition.setFrame(view: selectedBorderView, frame: CGRect(origin: CGPoint(), size: availableSize)) + } + + return availableSize + } + } + + func makeView() -> View { + return View(frame: CGRect()) + } + + func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } +} + +final class VideoChatExpandedParticipantThumbnailsComponent: Component { + final class Participant: Equatable { + struct Key: Hashable { + var id: EnginePeer.Id + var isPresentation: Bool + + init(id: EnginePeer.Id, isPresentation: Bool) { + self.id = id + self.isPresentation = isPresentation + } + } + + let participant: GroupCallParticipantsContext.Participant + let isPresentation: Bool + + var key: Key { + return Key(id: self.participant.peer.id, isPresentation: self.isPresentation) + } + + init( + participant: GroupCallParticipantsContext.Participant, + isPresentation: Bool + ) { + self.participant = participant + self.isPresentation = isPresentation + } + + static func ==(lhs: Participant, rhs: Participant) -> Bool { + if lhs === rhs { + return true + } + if lhs.participant != rhs.participant { + return false + } + if lhs.isPresentation != rhs.isPresentation { + return false + } + return true + } + } + + let call: PresentationGroupCall + let theme: PresentationTheme + let participants: [Participant] + let selectedParticipant: Participant.Key? + let speakingParticipants: Set + let updateSelectedParticipant: (Participant.Key) -> Void + + init( + call: PresentationGroupCall, + theme: PresentationTheme, + participants: [Participant], + selectedParticipant: Participant.Key?, + speakingParticipants: Set, + updateSelectedParticipant: @escaping (Participant.Key) -> Void + ) { + self.call = call + self.theme = theme + self.participants = participants + self.selectedParticipant = selectedParticipant + self.speakingParticipants = speakingParticipants + self.updateSelectedParticipant = updateSelectedParticipant + } + + static func ==(lhs: VideoChatExpandedParticipantThumbnailsComponent, rhs: VideoChatExpandedParticipantThumbnailsComponent) -> Bool { + if lhs.call !== rhs.call { + return false + } + if lhs.theme !== rhs.theme { + return false + } + if lhs.participants != rhs.participants { + return false + } + if lhs.selectedParticipant != rhs.selectedParticipant { + return false + } + if lhs.speakingParticipants != rhs.speakingParticipants { + return false + } + return true + } + + private final class ScrollView: UIScrollView { + override func touchesShouldCancel(in view: UIView) -> Bool { + return true + } + } + + private struct ItemLayout { + let containerSize: CGSize + let containerInsets: UIEdgeInsets + let itemCount: Int + let itemSize: CGSize + let itemSpacing: CGFloat + + let contentSize: CGSize + + init(containerSize: CGSize, containerInsets: UIEdgeInsets, itemCount: Int) { + self.containerSize = containerSize + self.containerInsets = containerInsets + self.itemCount = itemCount + self.itemSize = CGSize(width: 84.0, height: 84.0) + self.itemSpacing = 6.0 + + let itemsWidth: CGFloat = CGFloat(itemCount) * self.itemSize.width + CGFloat(max(itemCount - 1, 0)) * self.itemSpacing + self.contentSize = CGSize(width: self.containerInsets.left + self.containerInsets.right + itemsWidth, height: self.containerInsets.top + self.containerInsets.bottom + self.itemSize.height) + } + + func frame(at index: Int) -> CGRect { + let frame = CGRect(origin: CGPoint(x: self.containerInsets.left + CGFloat(index) * (self.itemSize.width + self.itemSpacing), y: self.containerInsets.top), size: self.itemSize) + return frame + } + + func visibleItemRange(for rect: CGRect) -> (minIndex: Int, maxIndex: Int) { + if self.itemCount == 0 { + return (0, -1) + } + let offsetRect = rect.offsetBy(dx: -self.containerInsets.left, dy: 0.0) + var minVisibleRow = Int(floor((offsetRect.minX) / (self.itemSize.width + self.itemSpacing))) + minVisibleRow = max(0, minVisibleRow) + let maxVisibleRow = Int(ceil((offsetRect.maxX) / (self.itemSize.width + self.itemSpacing))) + + let minVisibleIndex = minVisibleRow + let maxVisibleIndex = min(self.itemCount - 1, (maxVisibleRow + 1) - 1) + + return (minVisibleIndex, maxVisibleIndex) + } + } + + private final class VisibleItem { + let view = ComponentView() + + init() { + } + } + + final class View: UIView, UIScrollViewDelegate { + private let scrollView: ScrollView + + private var component: VideoChatExpandedParticipantThumbnailsComponent? + private var isUpdating: Bool = false + + private var ignoreScrolling: Bool = false + + private var itemLayout: ItemLayout? + private var visibleItems: [Participant.Key: VisibleItem] = [:] + + override init(frame: CGRect) { + self.scrollView = ScrollView() + + super.init(frame: frame) + + self.scrollView.delaysContentTouches = false + self.scrollView.canCancelContentTouches = true + self.scrollView.clipsToBounds = false + 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 = false + self.scrollView.scrollsToTop = false + self.scrollView.delegate = self + self.scrollView.clipsToBounds = true + + self.addSubview(self.scrollView) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + if !self.ignoreScrolling { + self.updateScrolling(transition: .immediate) + } + } + + private func updateScrolling(transition: ComponentTransition) { + guard let component = self.component, let itemLayout = self.itemLayout else { + return + } + + var validListItemIds: [Participant.Key] = [] + let visibleListItemRange = itemLayout.visibleItemRange(for: self.scrollView.bounds) + if visibleListItemRange.maxIndex >= visibleListItemRange.minIndex { + for i in visibleListItemRange.minIndex ... visibleListItemRange.maxIndex { + let participant = component.participants[i] + validListItemIds.append(participant.key) + + var itemTransition = transition + let itemView: VisibleItem + if let current = self.visibleItems[participant.key] { + itemView = current + } else { + itemTransition = itemTransition.withAnimation(.none) + itemView = VisibleItem() + self.visibleItems[participant.key] = itemView + } + + let itemFrame = itemLayout.frame(at: i) + + let participantKey = participant.key + let _ = itemView.view.update( + transition: itemTransition, + component: AnyComponent(VideoChatParticipantThumbnailComponent( + call: component.call, + theme: component.theme, + participant: participant.participant, + isPresentation: participant.isPresentation, + isSelected: component.selectedParticipant == participant.key, + isSpeaking: component.speakingParticipants.contains(participant.participant.peer.id), + action: { [weak self] in + guard let self, let component = self.component else { + return + } + component.updateSelectedParticipant(participantKey) + } + )), + environment: {}, + containerSize: itemFrame.size + ) + if let itemComponentView = itemView.view.view { + if itemComponentView.superview == nil { + itemComponentView.clipsToBounds = true + + self.scrollView.addSubview(itemComponentView) + + if !transition.animation.isImmediate { + itemComponentView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15) + transition.animateScale(view: itemComponentView, from: 0.001, to: 1.0) + } + } + transition.setFrame(view: itemComponentView, frame: itemFrame) + } + } + } + + var removedListItemIds: [Participant.Key] = [] + for (itemId, itemView) in self.visibleItems { + if !validListItemIds.contains(itemId) { + removedListItemIds.append(itemId) + + if let itemComponentView = itemView.view.view { + if !transition.animation.isImmediate { + transition.setScale(view: itemComponentView, scale: 0.001) + itemComponentView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak itemComponentView] _ in + itemComponentView?.removeFromSuperview() + }) + } else { + itemComponentView.removeFromSuperview() + } + } + } + } + for itemId in removedListItemIds { + self.visibleItems.removeValue(forKey: itemId) + } + } + + func update(component: VideoChatExpandedParticipantThumbnailsComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + self.isUpdating = true + defer { + self.isUpdating = false + } + + self.component = component + + let itemLayout = ItemLayout( + containerSize: availableSize, + containerInsets: UIEdgeInsets(top: 10.0, left: 10.0, bottom: 10.0, right: 10.0), + itemCount: component.participants.count + ) + self.itemLayout = itemLayout + + let size = CGSize(width: availableSize.width, height: itemLayout.contentSize.height) + + self.ignoreScrolling = true + if self.scrollView.bounds.size != size { + transition.setFrame(view: self.scrollView, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: size)) + } + let contentSize = CGSize(width: itemLayout.contentSize.width, height: size.height) + if self.scrollView.contentSize != contentSize { + self.scrollView.contentSize = contentSize + } + self.ignoreScrolling = false + + self.updateScrolling(transition: transition) + + return size + } + } + + func makeView() -> View { + return View() + } + + func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } +} diff --git a/submodules/TelegramCallsUI/Sources/VideoChatListInviteComponent.swift b/submodules/TelegramCallsUI/Sources/VideoChatListInviteComponent.swift new file mode 100644 index 00000000000..5e25476df2e --- /dev/null +++ b/submodules/TelegramCallsUI/Sources/VideoChatListInviteComponent.swift @@ -0,0 +1,150 @@ +import Foundation +import UIKit +import Display +import ComponentFlow +import MultilineTextComponent +import TelegramPresentationData +import BundleIconComponent + +final class VideoChatListInviteComponent: Component { + let title: String + let theme: PresentationTheme + let action: () -> Void + + init( + title: String, + theme: PresentationTheme, + action: @escaping () -> Void + ) { + self.title = title + self.theme = theme + self.action = action + } + + static func ==(lhs: VideoChatListInviteComponent, rhs: VideoChatListInviteComponent) -> Bool { + if lhs.title != rhs.title { + return false + } + if lhs.theme !== rhs.theme { + return false + } + return true + } + + final class View: HighlightTrackingButton { + private let icon = ComponentView() + private let title = ComponentView() + + private var component: VideoChatListInviteComponent? + private var isUpdating: Bool = false + + private var highlightBackgroundLayer: SimpleLayer? + private var highlightBackgroundFrame: CGRect? + + override init(frame: CGRect) { + super.init(frame: frame) + + self.highligthedChanged = { [weak self] isHighlighted in + guard let self, let component = self.component, let highlightBackgroundFrame = self.highlightBackgroundFrame else { + return + } + + if isHighlighted { + self.superview?.bringSubviewToFront(self) + + let highlightBackgroundLayer: SimpleLayer + if let current = self.highlightBackgroundLayer { + highlightBackgroundLayer = current + } else { + highlightBackgroundLayer = SimpleLayer() + self.highlightBackgroundLayer = highlightBackgroundLayer + self.layer.insertSublayer(highlightBackgroundLayer, at: 0) + highlightBackgroundLayer.backgroundColor = component.theme.list.itemHighlightedBackgroundColor.cgColor + } + highlightBackgroundLayer.frame = highlightBackgroundFrame + highlightBackgroundLayer.opacity = 1.0 + } else { + if let highlightBackgroundLayer = self.highlightBackgroundLayer { + self.highlightBackgroundLayer = nil + highlightBackgroundLayer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, removeOnCompletion: false, completion: { [weak highlightBackgroundLayer] _ in + highlightBackgroundLayer?.removeFromSuperlayer() + }) + } + } + } + self.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: VideoChatListInviteComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + self.isUpdating = true + defer { + self.isUpdating = false + } + + self.component = component + + 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.itemAccentColor)) + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - 62.0 - 8.0, height: 100.0) + ) + + let size = CGSize(width: availableSize.width, height: 46.0) + + let titleFrame = CGRect(origin: CGPoint(x: 62.0, y: floor((size.height - titleSize.height) * 0.5)), size: titleSize) + if let titleView = self.title.view { + if titleView.superview == nil { + titleView.isUserInteractionEnabled = false + titleView.layer.anchorPoint = CGPoint() + self.addSubview(titleView) + } + transition.setPosition(view: titleView, position: titleFrame.origin) + titleView.bounds = CGRect(origin: CGPoint(), size: titleFrame.size) + } + + let iconSize = self.icon.update( + transition: .immediate, + component: AnyComponent(BundleIconComponent( + name: "Chat/Context Menu/AddUser", + tintColor: component.theme.list.itemAccentColor + )), + environment: {}, + containerSize: CGSize(width: 100.0, height: 100.0) + ) + let iconFrame = CGRect(origin: CGPoint(x: floor((62.0 - iconSize.width) * 0.5), y: floor((size.height - iconSize.height) * 0.5)), size: iconSize) + if let iconView = self.icon.view { + if iconView.superview == nil { + iconView.isUserInteractionEnabled = false + self.addSubview(iconView) + } + transition.setFrame(view: iconView, frame: iconFrame) + } + + //self.highlightBackgroundFrame = CGRect(origin: CGPoint(), size: size) + + return size + } + } + + func makeView() -> View { + return View() + } + + func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } +} diff --git a/submodules/TelegramCallsUI/Sources/VideoChatMicButtonComponent.swift b/submodules/TelegramCallsUI/Sources/VideoChatMicButtonComponent.swift new file mode 100644 index 00000000000..9e7c87b5bba --- /dev/null +++ b/submodules/TelegramCallsUI/Sources/VideoChatMicButtonComponent.swift @@ -0,0 +1,601 @@ +import Foundation +import UIKit +import Display +import ComponentFlow +import MultilineTextComponent +import TelegramPresentationData +import LottieComponent +import VoiceChatActionButton +import CallScreen +import MetalEngine +import SwiftSignalKit +import AccountContext +import RadialStatusNode + +private final class BlobView: UIView { + let blobsLayer: CallBlobsLayer + + private let maxLevel: CGFloat + + private var displayLinkAnimator: ConstantDisplayLinkAnimator? + + private var audioLevel: CGFloat = 0.0 + var presentationAudioLevel: CGFloat = 0.0 + + var scaleUpdated: ((CGFloat) -> Void)? { + didSet { + } + } + + private(set) var isAnimating = false + + private let hierarchyTrackingNode: HierarchyTrackingNode + private var isCurrentlyInHierarchy = true + + init( + frame: CGRect, + maxLevel: CGFloat + ) { + var updateInHierarchy: ((Bool) -> Void)? + self.hierarchyTrackingNode = HierarchyTrackingNode({ value in + updateInHierarchy?(value) + }) + + self.maxLevel = maxLevel + + self.blobsLayer = CallBlobsLayer() + + super.init(frame: frame) + + self.addSubnode(self.hierarchyTrackingNode) + + self.layer.addSublayer(self.blobsLayer) + + self.displayLinkAnimator = ConstantDisplayLinkAnimator() { [weak self] in + guard let self else { + return + } + + if !self.isCurrentlyInHierarchy { + return + } + + self.presentationAudioLevel = self.presentationAudioLevel * 0.9 + self.audioLevel * 0.1 + self.updateAudioLevel() + } + + updateInHierarchy = { [weak self] value in + guard let self else { + return + } + self.isCurrentlyInHierarchy = value + if value { + self.startAnimating() + } else { + self.stopAnimating() + } + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public func setColor(_ color: UIColor) { + } + + public func updateLevel(_ level: CGFloat, immediately: Bool) { + let normalizedLevel = min(1, max(level / maxLevel, 0)) + + self.audioLevel = normalizedLevel + if immediately { + self.presentationAudioLevel = normalizedLevel + } + } + + private func updateAudioLevel() { + let additionalAvatarScale = CGFloat(max(0.0, min(self.presentationAudioLevel * 18.0, 5.0)) * 0.05) + let blobAmplificationFactor: CGFloat = 2.0 + let blobScale = 1.0 + additionalAvatarScale * blobAmplificationFactor + self.blobsLayer.transform = CATransform3DMakeScale(blobScale, blobScale, 1.0) + + self.scaleUpdated?(blobScale) + } + + public func startAnimating() { + guard !self.isAnimating else { return } + self.isAnimating = true + + self.updateBlobsState() + + self.displayLinkAnimator?.isPaused = false + } + + public func stopAnimating() { + self.stopAnimating(duration: 0.15) + } + + public func stopAnimating(duration: Double) { + guard isAnimating else { return } + self.isAnimating = false + + self.updateBlobsState() + + self.displayLinkAnimator?.isPaused = true + } + + private func updateBlobsState() { + /*if self.isAnimating { + if self.mediumBlob.frame.size != .zero { + self.mediumBlob.startAnimating() + self.bigBlob.startAnimating() + } + } else { + self.mediumBlob.stopAnimating() + self.bigBlob.stopAnimating() + }*/ + } + + override public func layoutSubviews() { + super.layoutSubviews() + + //self.mediumBlob.frame = bounds + //self.bigBlob.frame = bounds + + let blobsFrame = bounds.insetBy(dx: floor(bounds.width * 0.12), dy: floor(bounds.height * 0.12)) + self.blobsLayer.position = blobsFrame.center + self.blobsLayer.bounds = CGRect(origin: CGPoint(), size: blobsFrame.size) + + self.updateBlobsState() + } +} + +private final class GlowView: UIView { + let maskGradientLayer: SimpleGradientLayer + + override init(frame: CGRect) { + self.maskGradientLayer = SimpleGradientLayer() + self.maskGradientLayer.type = .radial + self.maskGradientLayer.startPoint = CGPoint(x: 0.5, y: 0.5) + self.maskGradientLayer.endPoint = CGPoint(x: 1.0, y: 1.0) + + super.init(frame: frame) + + self.layer.addSublayer(self.maskGradientLayer) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func update(size: CGSize, color: UIColor, transition: ComponentTransition, colorTransition: ComponentTransition) { + transition.setFrame(layer: self.maskGradientLayer, frame: CGRect(origin: CGPoint(), size: size)) + colorTransition.setGradientColors(layer: self.maskGradientLayer, colors: [color.withMultipliedAlpha(1.0), color.withMultipliedAlpha(0.0)]) + } +} + +final class VideoChatMicButtonComponent: Component { + enum Content: Equatable { + case connecting + case muted + case unmuted(pushToTalk: Bool) + case raiseHand + } + + let call: PresentationGroupCall + let content: Content + let isCollapsed: Bool + let updateUnmutedStateIsPushToTalk: (Bool?) -> Void + let raiseHand: () -> Void + + init( + call: PresentationGroupCall, + content: Content, + isCollapsed: Bool, + updateUnmutedStateIsPushToTalk: @escaping (Bool?) -> Void, + raiseHand: @escaping () -> Void + ) { + self.call = call + self.content = content + self.isCollapsed = isCollapsed + self.updateUnmutedStateIsPushToTalk = updateUnmutedStateIsPushToTalk + self.raiseHand = raiseHand + } + + static func ==(lhs: VideoChatMicButtonComponent, rhs: VideoChatMicButtonComponent) -> Bool { + if lhs.content != rhs.content { + return false + } + if lhs.isCollapsed != rhs.isCollapsed { + return false + } + return true + } + + final class View: HighlightTrackingButton { + private let background: UIImageView + private var disappearingBackgrounds: [UIImageView] = [] + private var progressIndicator: RadialStatusNode? + private let title = ComponentView() + private let icon: VoiceChatActionButtonIconNode + + private var glowView: GlowView? + private var blobView: BlobView? + + private var component: VideoChatMicButtonComponent? + private var isUpdating: Bool = false + + private var beginTrackingTimestamp: Double = 0.0 + private var beginTrackingWasPushToTalk: Bool = false + + private var audioLevelDisposable: Disposable? + + override init(frame: CGRect) { + self.background = UIImageView() + self.icon = VoiceChatActionButtonIconNode(isColored: false) + + super.init(frame: frame) + } + + deinit { + self.audioLevelDisposable?.dispose() + } + + override func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool { + self.beginTrackingTimestamp = CFAbsoluteTimeGetCurrent() + if let component = self.component { + switch component.content { + case .connecting, .unmuted, .raiseHand: + self.beginTrackingWasPushToTalk = false + case .muted: + self.beginTrackingWasPushToTalk = true + component.updateUnmutedStateIsPushToTalk(true) + } + } + + return super.beginTracking(touch, with: event) + } + + override func endTracking(_ touch: UITouch?, with event: UIEvent?) { + performEndOrCancelTracking() + + return super.endTracking(touch, with: event) + } + + override func cancelTracking(with event: UIEvent?) { + performEndOrCancelTracking() + + return super.cancelTracking(with: event) + } + + private func performEndOrCancelTracking() { + if let component = self.component { + let timestamp = CFAbsoluteTimeGetCurrent() + + switch component.content { + case .connecting: + break + case .muted: + component.updateUnmutedStateIsPushToTalk(false) + case .unmuted: + if self.beginTrackingWasPushToTalk { + if timestamp < self.beginTrackingTimestamp + 0.15 { + component.updateUnmutedStateIsPushToTalk(false) + } else { + component.updateUnmutedStateIsPushToTalk(nil) + } + } else { + component.updateUnmutedStateIsPushToTalk(nil) + } + case .raiseHand: + self.icon.playRandomAnimation() + + component.raiseHand() + } + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func update(component: VideoChatMicButtonComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + self.isUpdating = true + defer { + self.isUpdating = false + } + + let previousComponent = self.component + self.component = component + + let alphaTransition: ComponentTransition = transition.animation.isImmediate ? .immediate : .easeInOut(duration: 0.2) + + let titleText: String + var isEnabled = true + switch component.content { + case .connecting: + titleText = "Connecting..." + isEnabled = false + case .muted: + titleText = "Unmute" + case let .unmuted(isPushToTalk): + titleText = isPushToTalk ? "You are Live" : "Tap to Mute" + case .raiseHand: + titleText = "Raise Hand" + } + self.isEnabled = isEnabled + + let titleSize = self.title.update( + transition: .immediate, + component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString(string: titleText, font: Font.regular(15.0), textColor: .white)) + )), + environment: {}, + containerSize: CGSize(width: 120.0, height: 100.0) + ) + + let size = CGSize(width: availableSize.width, height: availableSize.height) + + if self.background.superview == nil { + self.background.isUserInteractionEnabled = false + self.addSubview(self.background) + self.background.frame = CGRect(origin: CGPoint(), size: CGSize(width: 116.0, height: 116.0)) + } + + if case .connecting = component.content { + let progressIndicator: RadialStatusNode + if let current = self.progressIndicator { + progressIndicator = current + } else { + progressIndicator = RadialStatusNode(backgroundNodeColor: .clear) + self.progressIndicator = progressIndicator + } + progressIndicator.transitionToState(.progress(color: UIColor(rgb: 0x0080FF), lineWidth: 3.0, value: nil, cancelEnabled: false, animateRotation: true)) + + let progressIndicatorView = progressIndicator.view + if progressIndicatorView.superview == nil { + self.addSubview(progressIndicatorView) + progressIndicatorView.center = CGRect(origin: CGPoint(), size: size).center + progressIndicatorView.bounds = CGRect(origin: CGPoint(), size: CGSize(width: 116.0, height: 116.0)) + progressIndicatorView.layer.transform = CATransform3DMakeScale(size.width / 116.0, size.width / 116.0, 1.0) + } else { + transition.setPosition(view: progressIndicatorView, position: CGRect(origin: CGPoint(), size: size).center) + transition.setScale(view: progressIndicatorView, scale: size.width / 116.0) + } + } else if let progressIndicator = self.progressIndicator { + self.progressIndicator = nil + if !transition.animation.isImmediate { + let progressIndicatorView = progressIndicator.view + progressIndicatorView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.1, removeOnCompletion: false, completion: { [weak progressIndicatorView] _ in + progressIndicatorView?.removeFromSuperview() + }) + } else { + progressIndicator.view.removeFromSuperview() + } + } + + if previousComponent?.content != component.content { + let backgroundContentsTransition: ComponentTransition + if !transition.animation.isImmediate { + backgroundContentsTransition = .easeInOut(duration: 0.2) + } else { + backgroundContentsTransition = .immediate + } + let backgroundImage = generateImage(CGSize(width: 200.0, height: 200.0), rotatedContext: { size, context in + context.clear(CGRect(origin: CGPoint(), size: size)) + context.addEllipse(in: CGRect(origin: CGPoint(), size: size)) + context.clip() + + switch component.content { + case .connecting: + context.setFillColor(UIColor(white: 0.1, alpha: 1.0).cgColor) + context.fill(CGRect(origin: CGPoint(), size: size)) + case .muted, .unmuted, .raiseHand: + let colors: [UIColor] + if case .muted = component.content { + colors = [UIColor(rgb: 0x0080FF), UIColor(rgb: 0x00A1FE)] + } else if case .raiseHand = component.content { + colors = [UIColor(rgb: 0x3252EF), UIColor(rgb: 0xC64688)] + } else { + colors = [UIColor(rgb: 0x33C659), UIColor(rgb: 0x0BA8A5)] + } + let gradientColors = colors.map { $0.cgColor } as CFArray + let colorSpace = DeviceGraphicsContextSettings.shared.colorSpace + + var locations: [CGFloat] = [0.0, 1.0] + let gradient = CGGradient(colorsSpace: colorSpace, colors: gradientColors, locations: &locations)! + + context.drawLinearGradient(gradient, start: CGPoint(x: 0.0, y: size.height), end: CGPoint(x: size.width, y: 0.0), options: CGGradientDrawingOptions()) + } + })! + if let previousImage = self.background.image { + let previousBackground = UIImageView() + previousBackground.center = self.background.center + previousBackground.bounds = self.background.bounds + previousBackground.layer.transform = self.background.layer.transform + previousBackground.image = previousImage + self.insertSubview(previousBackground, aboveSubview: self.background) + self.disappearingBackgrounds.append(previousBackground) + + self.background.image = backgroundImage + backgroundContentsTransition.setAlpha(view: previousBackground, alpha: 0.0, completion: { [weak self, weak previousBackground] _ in + guard let self, let previousBackground else { + return + } + previousBackground.removeFromSuperview() + self.disappearingBackgrounds.removeAll(where: { $0 === previousBackground }) + }) + } else { + self.background.image = backgroundImage + } + + if !transition.animation.isImmediate, let previousComponent, case .connecting = previousComponent.content { + self.layer.animateSublayerScale(from: 1.0, to: 1.07, duration: 0.12, removeOnCompletion: false, completion: { [weak self] completed in + if let self, completed { + self.layer.removeAnimation(forKey: "sublayerTransform.scale") + self.layer.animateSublayerScale(from: 1.07, to: 1.0, duration: 0.12, removeOnCompletion: true) + } + }) + } + } + + transition.setPosition(view: self.background, position: CGRect(origin: CGPoint(), size: size).center) + transition.setScale(view: self.background, scale: size.width / 116.0) + for disappearingBackground in self.disappearingBackgrounds { + transition.setPosition(view: disappearingBackground, position: CGRect(origin: CGPoint(), size: size).center) + transition.setScale(view: disappearingBackground, scale: size.width / 116.0) + } + + let titleFrame = CGRect(origin: CGPoint(x: floor((size.width - titleSize.width) * 0.5), y: size.height + 16.0), size: titleSize) + if let titleView = self.title.view { + if titleView.superview == nil { + titleView.isUserInteractionEnabled = false + self.addSubview(titleView) + } + transition.setPosition(view: titleView, position: titleFrame.center) + titleView.bounds = CGRect(origin: CGPoint(), size: titleFrame.size) + alphaTransition.setAlpha(view: titleView, alpha: component.isCollapsed ? 0.0 : 1.0) + } + + if self.icon.view.superview == nil { + self.icon.view.isUserInteractionEnabled = false + self.addSubview(self.icon.view) + } + let iconSize = CGSize(width: 100.0, height: 100.0) + let iconFrame = CGRect(origin: CGPoint(x: floor((size.width - iconSize.width) * 0.5), y: floor((size.height - iconSize.height) * 0.5)), size: iconSize) + + transition.setPosition(view: self.icon.view, position: iconFrame.center) + transition.setBounds(view: self.icon.view, bounds: CGRect(origin: CGPoint(), size: iconFrame.size)) + transition.setScale(view: self.icon.view, scale: component.isCollapsed ? ((iconSize.width - 24.0) / iconSize.width) : 1.0) + + switch component.content { + case .connecting: + self.icon.enqueueState(.mute) + case .muted: + self.icon.enqueueState(.mute) + case .unmuted: + self.icon.enqueueState(.unmute) + case .raiseHand: + self.icon.enqueueState(.hand) + } + + switch component.content { + case .muted, .unmuted, .raiseHand: + let blobSize = CGRect(origin: CGPoint(), size: CGSize(width: 116.0, height: 116.0)).insetBy(dx: -40.0, dy: -40.0).size + + let blobTintTransition: ComponentTransition + + let blobView: BlobView + if let current = self.blobView { + blobView = current + blobTintTransition = .easeInOut(duration: 0.2) + } else { + blobTintTransition = .immediate + blobView = BlobView(frame: CGRect(), maxLevel: 1.5) + blobView.isUserInteractionEnabled = false + self.blobView = blobView + self.insertSubview(blobView, at: 0) + blobView.center = CGPoint(x: availableSize.width * 0.5, y: availableSize.height * 0.5) + blobView.bounds = CGRect(origin: CGPoint(), size: blobSize) + + ComponentTransition.immediate.setScale(view: blobView, scale: 0.001) + if !transition.animation.isImmediate { + blobView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15) + } + } + + transition.setPosition(view: blobView, position: CGPoint(x: availableSize.width * 0.5, y: availableSize.height * 0.5)) + transition.setScale(view: blobView, scale: availableSize.width / 116.0) + + let blobsColor: UIColor + if case .muted = component.content { + blobsColor = UIColor(rgb: 0x0086FF) + } else if case .raiseHand = component.content { + blobsColor = UIColor(rgb: 0x914BAD) + } else { + blobsColor = UIColor(rgb: 0x33C758) + } + blobTintTransition.setTintColor(layer: blobView.blobsLayer, color: blobsColor) + + switch component.content { + case .unmuted: + if self.audioLevelDisposable == nil { + self.audioLevelDisposable = (component.call.myAudioLevel + |> deliverOnMainQueue).startStrict(next: { [weak self] value in + guard let self, let blobView = self.blobView else { + return + } + blobView.updateLevel(CGFloat(value), immediately: false) + }) + } + case .connecting, .muted, .raiseHand: + if let audioLevelDisposable = self.audioLevelDisposable { + self.audioLevelDisposable = nil + audioLevelDisposable.dispose() + blobView.updateLevel(0.0, immediately: false) + } + } + + var glowFrame = CGRect(origin: CGPoint(), size: availableSize) + if component.isCollapsed { + glowFrame = glowFrame.insetBy(dx: -20.0, dy: -20.0) + } else { + glowFrame = glowFrame.insetBy(dx: -60.0, dy: -60.0) + } + + let glowView: GlowView + if let current = self.glowView { + glowView = current + } else { + glowView = GlowView(frame: CGRect()) + glowView.isUserInteractionEnabled = false + self.glowView = glowView + self.insertSubview(glowView, aboveSubview: blobView) + + transition.animateScale(view: glowView, from: 0.001, to: 1.0) + glowView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) + } + + let glowColor: UIColor + if case .muted = component.content { + glowColor = UIColor(rgb: 0x0086FF) + } else if case .raiseHand = component.content { + glowColor = UIColor(rgb: 0x3252EF) + } else { + glowColor = UIColor(rgb: 0x33C758) + } + glowView.update(size: glowFrame.size, color: glowColor.withMultipliedAlpha(component.isCollapsed ? 0.5 : 0.7), transition: transition, colorTransition: blobTintTransition) + transition.setFrame(view: glowView, frame: glowFrame) + default: + if let blobView = self.blobView { + self.blobView = nil + transition.setScale(view: blobView, scale: 0.001, completion: { [weak blobView] _ in + blobView?.removeFromSuperview() + }) + } + + if let glowView = self.glowView { + self.glowView = nil + transition.setScale(view: glowView, scale: 0.001, completion: { [weak glowView] _ in + glowView?.removeFromSuperview() + }) + } + + if let audioLevelDisposable = self.audioLevelDisposable { + self.audioLevelDisposable = nil + audioLevelDisposable.dispose() + } + } + + return size + } + } + + func makeView() -> View { + return View() + } + + func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } +} diff --git a/submodules/TelegramCallsUI/Sources/VideoChatMuteIconComponent.swift b/submodules/TelegramCallsUI/Sources/VideoChatMuteIconComponent.swift new file mode 100644 index 00000000000..3bc92c4bcf2 --- /dev/null +++ b/submodules/TelegramCallsUI/Sources/VideoChatMuteIconComponent.swift @@ -0,0 +1,132 @@ +import Foundation +import UIKit +import Display +import ComponentFlow +import MultilineTextComponent +import TelegramPresentationData +import AppBundle +import LottieComponent +import BundleIconComponent + +final class VideoChatMuteIconComponent: Component { + enum Content: Equatable { + case mute(isFilled: Bool, isMuted: Bool) + case screenshare + } + + let color: UIColor + let content: Content + + init( + color: UIColor, + content: Content + ) { + self.color = color + self.content = content + } + + static func ==(lhs: VideoChatMuteIconComponent, rhs: VideoChatMuteIconComponent) -> Bool { + if lhs.color != rhs.color { + return false + } + if lhs.content != rhs.content { + return false + } + return true + } + + final class View: HighlightTrackingButton { + private var icon: VoiceChatMicrophoneNode? + private var scheenshareIcon: ComponentView? + + private var component: VideoChatMuteIconComponent? + private var isUpdating: Bool = false + + private var contentImage: UIImage? + + var iconView: UIView? { + return self.icon?.view + } + + override init(frame: CGRect) { + super.init(frame: frame) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func update(component: VideoChatMuteIconComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + self.isUpdating = true + defer { + self.isUpdating = false + } + + self.component = component + + if case let .mute(isFilled, isMuted) = component.content { + let icon: VoiceChatMicrophoneNode + if let current = self.icon { + icon = current + } else { + icon = VoiceChatMicrophoneNode() + self.icon = icon + self.addSubview(icon.view) + } + + let animationSize = availableSize + let animationFrame = animationSize.centered(in: CGRect(origin: CGPoint(), size: availableSize)) + transition.setFrame(view: icon.view, frame: animationFrame) + icon.update(state: VoiceChatMicrophoneNode.State(muted: isMuted, filled: isFilled, color: component.color), animated: !transition.animation.isImmediate) + } else { + if let icon = self.icon { + self.icon = nil + icon.view.removeFromSuperview() + } + } + + if case .screenshare = component.content { + let scheenshareIcon: ComponentView + if let current = self.scheenshareIcon { + scheenshareIcon = current + } else { + scheenshareIcon = ComponentView() + self.scheenshareIcon = scheenshareIcon + } + let scheenshareIconSize = scheenshareIcon.update( + transition: transition, + component: AnyComponent(BundleIconComponent( + name: "Call/StatusScreen", + tintColor: component.color + )), + environment: {}, + containerSize: availableSize + ) + let scheenshareIconFrame = scheenshareIconSize.centered(in: CGRect(origin: CGPoint(), size: availableSize)) + if let scheenshareIconView = scheenshareIcon.view { + if scheenshareIconView.superview == nil { + self.addSubview(scheenshareIconView) + } + transition.setPosition(view: scheenshareIconView, position: scheenshareIconFrame.center) + transition.setBounds(view: scheenshareIconView, bounds: CGRect(origin: CGPoint(), size: scheenshareIconFrame.size)) + transition.setScale(view: scheenshareIconView, scale: 1.5) + } + } else { + if let scheenshareIcon = self.scheenshareIcon { + self.scheenshareIcon = nil + scheenshareIcon.view?.removeFromSuperview() + } + } + + return availableSize + } + } + + func makeView() -> View { + return View() + } + + func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } +} diff --git a/submodules/TelegramCallsUI/Sources/VideoChatParticipantAvatarComponent.swift b/submodules/TelegramCallsUI/Sources/VideoChatParticipantAvatarComponent.swift new file mode 100644 index 00000000000..bc631b0f845 --- /dev/null +++ b/submodules/TelegramCallsUI/Sources/VideoChatParticipantAvatarComponent.swift @@ -0,0 +1,364 @@ +import Foundation +import UIKit +import Display +import ComponentFlow +import TelegramPresentationData +import TelegramCore +import AccountContext +import AvatarNode +import VoiceChatActionButton +import CallScreen +import MetalEngine +import SwiftSignalKit + +private final class BlobView: UIView { + let blobsLayer: CallBlobsLayer + + private let maxLevel: CGFloat + + private var displayLinkAnimator: ConstantDisplayLinkAnimator? + + private var audioLevel: CGFloat = 0.0 + var presentationAudioLevel: CGFloat = 0.0 + + var scaleUpdated: ((CGFloat) -> Void)? { + didSet { + } + } + + private(set) var isAnimating = false + + public typealias BlobRange = (min: CGFloat, max: CGFloat) + + private let hierarchyTrackingNode: HierarchyTrackingNode + private var isCurrentlyInHierarchy = true + + init( + frame: CGRect, + maxLevel: CGFloat, + mediumBlobRange: BlobRange, + bigBlobRange: BlobRange + ) { + var updateInHierarchy: ((Bool) -> Void)? + self.hierarchyTrackingNode = HierarchyTrackingNode({ value in + updateInHierarchy?(value) + }) + + self.maxLevel = maxLevel + + self.blobsLayer = CallBlobsLayer() + + super.init(frame: frame) + + self.addSubnode(self.hierarchyTrackingNode) + + self.layer.addSublayer(self.blobsLayer) + + self.displayLinkAnimator = ConstantDisplayLinkAnimator() { [weak self] in + guard let self else { + return + } + + if !self.isCurrentlyInHierarchy { + return + } + + self.presentationAudioLevel = self.presentationAudioLevel * 0.9 + self.audioLevel * 0.1 + self.updateAudioLevel() + } + + updateInHierarchy = { [weak self] value in + guard let self else { + return + } + self.isCurrentlyInHierarchy = value + if value { + self.startAnimating() + } else { + self.stopAnimating() + } + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public func setColor(_ color: UIColor) { + } + + public func updateLevel(_ level: CGFloat, immediately: Bool) { + let normalizedLevel = min(1, max(level / maxLevel, 0)) + + self.audioLevel = normalizedLevel + if immediately { + self.presentationAudioLevel = normalizedLevel + } + } + + private func updateAudioLevel() { + let additionalAvatarScale = CGFloat(max(0.0, min(self.presentationAudioLevel * 18.0, 5.0)) * 0.05) + let blobAmplificationFactor: CGFloat = 2.0 + let blobScale = 1.0 + additionalAvatarScale * blobAmplificationFactor + self.blobsLayer.transform = CATransform3DMakeScale(blobScale, blobScale, 1.0) + + self.scaleUpdated?(blobScale) + } + + public func startAnimating() { + guard !self.isAnimating else { return } + self.isAnimating = true + + self.updateBlobsState() + + self.displayLinkAnimator?.isPaused = false + } + + public func stopAnimating() { + self.stopAnimating(duration: 0.15) + } + + public func stopAnimating(duration: Double) { + guard isAnimating else { return } + self.isAnimating = false + + self.updateBlobsState() + + self.displayLinkAnimator?.isPaused = true + } + + private func updateBlobsState() { + /*if self.isAnimating { + if self.mediumBlob.frame.size != .zero { + self.mediumBlob.startAnimating() + self.bigBlob.startAnimating() + } + } else { + self.mediumBlob.stopAnimating() + self.bigBlob.stopAnimating() + }*/ + } + + override public func layoutSubviews() { + super.layoutSubviews() + + //self.mediumBlob.frame = bounds + //self.bigBlob.frame = bounds + + let blobsFrame = bounds.insetBy(dx: floor(bounds.width * 0.12), dy: floor(bounds.height * 0.12)) + self.blobsLayer.position = blobsFrame.center + self.blobsLayer.bounds = CGRect(origin: CGPoint(), size: blobsFrame.size) + + self.updateBlobsState() + } +} + +final class VideoChatParticipantAvatarComponent: Component { + let call: PresentationGroupCall + let peer: EnginePeer + let isSpeaking: Bool + let theme: PresentationTheme + + init( + call: PresentationGroupCall, + peer: EnginePeer, + isSpeaking: Bool, + theme: PresentationTheme + ) { + self.call = call + self.peer = peer + self.isSpeaking = isSpeaking + self.theme = theme + } + + static func ==(lhs: VideoChatParticipantAvatarComponent, rhs: VideoChatParticipantAvatarComponent) -> Bool { + if lhs.call !== rhs.call { + return false + } + if lhs.peer != rhs.peer { + return false + } + if lhs.isSpeaking != rhs.isSpeaking { + return false + } + if lhs.theme !== rhs.theme { + return false + } + return true + } + + final class View: UIView { + private var component: VideoChatParticipantAvatarComponent? + private var isUpdating: Bool = false + + private var avatarNode: AvatarNode? + private var blobView: BlobView? + private var audioLevelDisposable: Disposable? + + private var wasSpeaking: Bool? + private var noAudioTimer: Foundation.Timer? + + override init(frame: CGRect) { + super.init(frame: frame) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + self.audioLevelDisposable?.dispose() + self.noAudioTimer?.invalidate() + } + + func update(component: VideoChatParticipantAvatarComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + self.isUpdating = true + defer { + self.isUpdating = false + } + + let previousComponent = self.component + self.component = component + + let avatarNode: AvatarNode + if let current = self.avatarNode { + avatarNode = current + } else { + avatarNode = AvatarNode(font: avatarPlaceholderFont(size: 15.0)) + self.avatarNode = avatarNode + self.addSubview(avatarNode.view) + } + + let avatarSize = availableSize + + let clipStyle: AvatarNodeClipStyle + if case let .channel(channel) = component.peer, channel.flags.contains(.isForum) { + clipStyle = .roundedRect + } else { + clipStyle = .round + } + + if let blobView = self.blobView { + let tintTransition: ComponentTransition + if let previousComponent, previousComponent.isSpeaking != component.isSpeaking { + if component.isSpeaking { + tintTransition = .easeInOut(duration: 0.15) + } else { + tintTransition = .easeInOut(duration: 0.25) + } + } else { + tintTransition = .immediate + } + tintTransition.setTintColor(layer: blobView.blobsLayer, color: component.isSpeaking ? UIColor(rgb: 0x33C758) : component.theme.list.itemAccentColor) + } + + if component.peer.smallProfileImage != nil { + avatarNode.setPeerV2( + context: component.call.accountContext, + theme: component.theme, + peer: component.peer, + authorOfMessage: nil, + overrideImage: nil, + emptyColor: nil, + clipStyle: .round, + synchronousLoad: false, + displayDimensions: avatarSize + ) + } else { + avatarNode.setPeer(context: component.call.accountContext, theme: component.theme, peer: component.peer, clipStyle: clipStyle, synchronousLoad: false, displayDimensions: avatarSize) + } + + transition.setFrame(view: avatarNode.view, frame: CGRect(origin: CGPoint(), size: avatarSize)) + avatarNode.updateSize(size: avatarSize) + + if self.audioLevelDisposable == nil { + let peerId = component.peer.id + struct Level { + var value: Float + var isSpeaking: Bool + } + self.audioLevelDisposable = (component.call.audioLevels + |> map { levels -> Level? in + for level in levels { + if level.0 == peerId { + return Level(value: level.2, isSpeaking: level.3) + } + } + return nil + } + |> distinctUntilChanged(isEqual: { lhs, rhs in + if (lhs == nil) != (rhs == nil) { + return false + } + if lhs != nil { + return true + } else { + return false + } + }) + |> deliverOnMainQueue).startStrict(next: { [weak self] level in + guard let self, let component = self.component, let avatarNode = self.avatarNode else { + return + } + if let level { + let blobView: BlobView + if let current = self.blobView { + blobView = current + } else { + self.wasSpeaking = nil + + blobView = BlobView( + frame: avatarNode.frame, + maxLevel: 1.5, + mediumBlobRange: (0.69, 0.87), + bigBlobRange: (0.71, 1.0) + ) + self.blobView = blobView + blobView.frame = avatarNode.frame + self.insertSubview(blobView, belowSubview: avatarNode.view) + + blobView.layer.animateScale(from: 0.001, to: 1.0, duration: 0.2) + + ComponentTransition.immediate.setTintColor(layer: blobView.blobsLayer, color: component.isSpeaking ? UIColor(rgb: 0x33C758) : component.theme.list.itemAccentColor) + } + + blobView.updateLevel(CGFloat(level.value), immediately: false) + + if let noAudioTimer = self.noAudioTimer { + self.noAudioTimer = nil + noAudioTimer.invalidate() + } + } else { + if self.noAudioTimer == nil { + self.noAudioTimer = Foundation.Timer.scheduledTimer(withTimeInterval: 0.4, repeats: false, block: { [weak self] _ in + guard let self else { + return + } + self.noAudioTimer?.invalidate() + self.noAudioTimer = nil + + if let blobView = self.blobView { + self.blobView = nil + blobView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, removeOnCompletion: false, completion: { [weak blobView] _ in + blobView?.removeFromSuperview() + }) + blobView.layer.animateScale(from: 1.0, to: 0.001, duration: 0.3, removeOnCompletion: false) + } + }) + } + } + }) + } + + return availableSize + } + } + + func makeView() -> View { + return View() + } + + func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } +} diff --git a/submodules/TelegramCallsUI/Sources/VideoChatParticipantStatusComponent.swift b/submodules/TelegramCallsUI/Sources/VideoChatParticipantStatusComponent.swift new file mode 100644 index 00000000000..5b480bac460 --- /dev/null +++ b/submodules/TelegramCallsUI/Sources/VideoChatParticipantStatusComponent.swift @@ -0,0 +1,224 @@ +import Foundation +import UIKit +import Display +import ComponentFlow +import TelegramPresentationData +import TelegramCore +import LottieComponent + +final class VideoChatParticipantStatusComponent: Component { + let muteState: GroupCallParticipantsContext.Participant.MuteState? + let hasRaiseHand: Bool + let isSpeaking: Bool + let theme: PresentationTheme + + init( + muteState: GroupCallParticipantsContext.Participant.MuteState?, + hasRaiseHand: Bool, + isSpeaking: Bool, + theme: PresentationTheme + ) { + self.muteState = muteState + self.hasRaiseHand = hasRaiseHand + self.isSpeaking = isSpeaking + self.theme = theme + } + + static func ==(lhs: VideoChatParticipantStatusComponent, rhs: VideoChatParticipantStatusComponent) -> Bool { + if lhs.muteState != rhs.muteState { + return false + } + if lhs.hasRaiseHand != rhs.hasRaiseHand { + return false + } + if lhs.isSpeaking != rhs.isSpeaking { + return false + } + if lhs.theme !== rhs.theme { + return false + } + return true + } + + final class View: UIView { + private var muteStatus: ComponentView? + private var raiseHandStatus: ComponentView? + + private var component: VideoChatParticipantStatusComponent? + private var isUpdating: Bool = false + + override init(frame: CGRect) { + super.init(frame: frame) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + } + + func update(component: VideoChatParticipantStatusComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + self.isUpdating = true + defer { + self.isUpdating = false + } + + let alphaTransition: ComponentTransition + if !transition.animation.isImmediate { + alphaTransition = .easeInOut(duration: 0.2) + } else { + alphaTransition = .immediate + } + + let size = CGSize(width: 44.0, height: 44.0) + + let isRaiseHand: Bool + if let muteState = component.muteState { + if muteState.canUnmute { + isRaiseHand = false + } else { + isRaiseHand = component.hasRaiseHand + } + } else { + isRaiseHand = false + } + + if !isRaiseHand { + let muteStatus: ComponentView + var muteStatusTransition = transition + if let current = self.muteStatus { + muteStatus = current + } else { + muteStatusTransition = muteStatusTransition.withAnimation(.none) + muteStatus = ComponentView() + self.muteStatus = muteStatus + } + + let muteStatusSize = muteStatus.update( + transition: muteStatusTransition, + component: AnyComponent(VideoChatMuteIconComponent( + color: .white, + content: .mute(isFilled: false, isMuted: component.muteState != nil && !component.isSpeaking) + )), + environment: {}, + containerSize: CGSize(width: 36.0, height: 36.0) + ) + let muteStatusFrame = CGRect(origin: CGPoint(x: floor((size.width - muteStatusSize.width) * 0.5), y: floor((size.height - muteStatusSize.height) * 0.5)), size: muteStatusSize) + if let muteStatusView = muteStatus.view as? VideoChatMuteIconComponent.View { + var animateIn = false + if muteStatusView.superview == nil { + animateIn = true + self.addSubview(muteStatusView) + } + muteStatusTransition.setFrame(view: muteStatusView, frame: muteStatusFrame) + + let tintTransition: ComponentTransition + if !muteStatusTransition.animation.isImmediate { + tintTransition = .easeInOut(duration: 0.2) + } else { + tintTransition = .immediate + } + if let iconView = muteStatusView.iconView { + let iconTintColor: UIColor + if component.isSpeaking { + iconTintColor = UIColor(rgb: 0x33C758) + } else { + if let muteState = component.muteState { + if muteState.canUnmute { + iconTintColor = UIColor(white: 1.0, alpha: 0.4) + } else { + iconTintColor = UIColor(rgb: 0xFF3B30) + } + } else { + iconTintColor = UIColor(white: 1.0, alpha: 0.4) + } + } + + tintTransition.setTintColor(layer: iconView.layer, color: iconTintColor) + } + + if animateIn, !transition.animation.isImmediate { + transition.animateScale(view: muteStatusView, from: 0.001, to: 1.0) + alphaTransition.animateAlpha(view: muteStatusView, from: 0.0, to: 1.0) + } + } + } else if let muteStatus = self.muteStatus { + self.muteStatus = nil + + if let muteStatusView = muteStatus.view { + if !transition.animation.isImmediate { + transition.setScale(view: muteStatusView, scale: 0.001) + alphaTransition.setAlpha(view: muteStatusView, alpha: 0.0, completion: { [weak muteStatusView] _ in + muteStatusView?.removeFromSuperview() + }) + } else { + muteStatusView.removeFromSuperview() + } + } + } + + if isRaiseHand { + let raiseHandStatus: ComponentView + var raiseHandStatusTransition = transition + if let current = self.raiseHandStatus { + raiseHandStatus = current + } else { + raiseHandStatusTransition = raiseHandStatusTransition.withAnimation(.none) + raiseHandStatus = ComponentView() + self.raiseHandStatus = raiseHandStatus + } + + let raiseHandStatusSize = raiseHandStatus.update( + transition: raiseHandStatusTransition, + component: AnyComponent(LottieComponent( + content: LottieComponent.AppBundleContent( + name: "anim_hand1" + ), + color: component.theme.list.itemAccentColor, + size: CGSize(width: 48.0, height: 48.0) + )), + environment: {}, + containerSize: CGSize(width: 48.0, height: 48.0) + ) + let raiseHandStatusFrame = CGRect(origin: CGPoint(x: floor((size.width - raiseHandStatusSize.width) * 0.5) - 2.0, y: floor((size.height - raiseHandStatusSize.height) * 0.5)), size: raiseHandStatusSize) + if let raiseHandStatusView = raiseHandStatus.view { + var animateIn = false + if raiseHandStatusView.superview == nil { + animateIn = true + self.addSubview(raiseHandStatusView) + } + raiseHandStatusTransition.setFrame(view: raiseHandStatusView, frame: raiseHandStatusFrame) + + if animateIn, !transition.animation.isImmediate { + transition.animateScale(view: raiseHandStatusView, from: 0.001, to: 1.0) + alphaTransition.animateAlpha(view: raiseHandStatusView, from: 0.0, to: 1.0) + } + } + } else if let raiseHandStatus = self.raiseHandStatus { + self.raiseHandStatus = nil + + if let raiseHandStatusView = raiseHandStatus.view { + if !transition.animation.isImmediate { + transition.setScale(view: raiseHandStatusView, scale: 0.001) + alphaTransition.setAlpha(view: raiseHandStatusView, alpha: 0.0, completion: { [weak raiseHandStatusView] _ in + raiseHandStatusView?.removeFromSuperview() + }) + } else { + raiseHandStatusView.removeFromSuperview() + } + } + } + + return size + } + } + + func makeView() -> View { + return View() + } + + func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } +} diff --git a/submodules/TelegramCallsUI/Sources/VideoChatParticipantVideoComponent.swift b/submodules/TelegramCallsUI/Sources/VideoChatParticipantVideoComponent.swift new file mode 100644 index 00000000000..cde0ff9ad99 --- /dev/null +++ b/submodules/TelegramCallsUI/Sources/VideoChatParticipantVideoComponent.swift @@ -0,0 +1,479 @@ +import Foundation +import UIKit +import Display +import ComponentFlow +import MultilineTextComponent +import TelegramPresentationData +import BundleIconComponent +import MetalEngine +import CallScreen +import TelegramCore +import AccountContext +import SwiftSignalKit +import DirectMediaImageCache +import FastBlur + +private func blurredAvatarImage(_ dataImage: UIImage) -> UIImage? { + let imageContextSize = CGSize(width: 64.0, height: 64.0) + if let imageContext = DrawingContext(size: imageContextSize, scale: 1.0, clear: true) { + imageContext.withFlippedContext { c in + if let cgImage = dataImage.cgImage { + c.draw(cgImage, in: CGRect(origin: CGPoint(), size: imageContextSize)) + } + } + + telegramFastBlurMore(Int32(imageContext.size.width * imageContext.scale), Int32(imageContext.size.height * imageContext.scale), Int32(imageContext.bytesPerRow), imageContext.bytes) + + return imageContext.generateImage() + } else { + return nil + } +} + +private let activityBorderImage: UIImage = { + return generateStretchableFilledCircleImage(diameter: 20.0, color: nil, strokeColor: .white, strokeWidth: 2.0)!.withRenderingMode(.alwaysTemplate) +}() + +final class VideoChatParticipantVideoComponent: Component { + let call: PresentationGroupCall + let participant: GroupCallParticipantsContext.Participant + let isPresentation: Bool + let isSpeaking: Bool + let isExpanded: Bool + let isUIHidden: Bool + let contentInsets: UIEdgeInsets + let controlInsets: UIEdgeInsets + let interfaceOrientation: UIInterfaceOrientation + weak var rootVideoLoadingEffectView: VideoChatVideoLoadingEffectView? + let action: (() -> Void)? + + init( + call: PresentationGroupCall, + participant: GroupCallParticipantsContext.Participant, + isPresentation: Bool, + isSpeaking: Bool, + isExpanded: Bool, + isUIHidden: Bool, + contentInsets: UIEdgeInsets, + controlInsets: UIEdgeInsets, + interfaceOrientation: UIInterfaceOrientation, + rootVideoLoadingEffectView: VideoChatVideoLoadingEffectView?, + action: (() -> Void)? + ) { + self.call = call + self.participant = participant + self.isPresentation = isPresentation + self.isSpeaking = isSpeaking + self.isExpanded = isExpanded + self.isUIHidden = isUIHidden + self.contentInsets = contentInsets + self.controlInsets = controlInsets + self.interfaceOrientation = interfaceOrientation + self.rootVideoLoadingEffectView = rootVideoLoadingEffectView + self.action = action + } + + static func ==(lhs: VideoChatParticipantVideoComponent, rhs: VideoChatParticipantVideoComponent) -> Bool { + if lhs.participant != rhs.participant { + return false + } + if lhs.isPresentation != rhs.isPresentation { + return false + } + if lhs.isSpeaking != rhs.isSpeaking { + return false + } + if lhs.isExpanded != rhs.isExpanded { + return false + } + if lhs.isUIHidden != rhs.isUIHidden { + return false + } + if lhs.contentInsets != rhs.contentInsets { + return false + } + if lhs.controlInsets != rhs.controlInsets { + return false + } + if lhs.interfaceOrientation != rhs.interfaceOrientation { + return false + } + if (lhs.action == nil) != (rhs.action == nil) { + return false + } + return true + } + + private struct VideoSpec: Equatable { + var resolution: CGSize + var rotationAngle: Float + var followsDeviceOrientation: Bool + + init(resolution: CGSize, rotationAngle: Float, followsDeviceOrientation: Bool) { + self.resolution = resolution + self.rotationAngle = rotationAngle + self.followsDeviceOrientation = followsDeviceOrientation + } + } + + final class View: HighlightTrackingButton { + private var component: VideoChatParticipantVideoComponent? + private weak var componentState: EmptyComponentState? + private var isUpdating: Bool = false + private var previousSize: CGSize? + + private let muteStatus = ComponentView() + private let title = ComponentView() + + private var blurredAvatarDisposable: Disposable? + private var blurredAvatarView: UIImageView? + + private var videoSource: AdaptedCallVideoSource? + private var videoDisposable: Disposable? + private var videoBackgroundLayer: SimpleLayer? + private var videoLayer: PrivateCallVideoLayer? + private var videoSpec: VideoSpec? + + private var activityBorderView: UIImageView? + + private var loadingEffectView: PortalView? + + override init(frame: CGRect) { + super.init(frame: frame) + + //TODO:release optimize + self.clipsToBounds = true + self.layer.cornerRadius = 10.0 + + self.addTarget(self, action: #selector(self.pressed), for: .touchUpInside) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + self.videoDisposable?.dispose() + self.blurredAvatarDisposable?.dispose() + } + + @objc private func pressed() { + guard let component = self.component, let action = component.action else { + return + } + action() + } + + func update(component: VideoChatParticipantVideoComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + self.isUpdating = true + defer { + self.isUpdating = false + } + + self.component = component + self.componentState = state + + let alphaTransition: ComponentTransition + if !transition.animation.isImmediate { + alphaTransition = .easeInOut(duration: 0.2) + } else { + alphaTransition = .immediate + } + + let controlsAlpha: CGFloat = component.isUIHidden ? 0.0 : 1.0 + + let nameColor = component.participant.peer.nameColor ?? .blue + let nameColors = component.call.accountContext.peerNameColors.get(nameColor, dark: true) + self.backgroundColor = nameColors.main.withMultiplied(hue: 1.0, saturation: 1.0, brightness: 0.4) + + if let smallProfileImage = component.participant.peer.smallProfileImage { + let blurredAvatarView: UIImageView + if let current = self.blurredAvatarView { + blurredAvatarView = current + + transition.setFrame(view: blurredAvatarView, frame: CGRect(origin: CGPoint(), size: availableSize)) + } else { + blurredAvatarView = UIImageView() + blurredAvatarView.contentMode = .scaleAspectFill + self.blurredAvatarView = blurredAvatarView + self.insertSubview(blurredAvatarView, at: 0) + + blurredAvatarView.frame = CGRect(origin: CGPoint(), size: availableSize) + } + + if self.blurredAvatarDisposable == nil { + //TODO:release synchronous + if let imageCache = component.call.accountContext.imageCache as? DirectMediaImageCache, let peerReference = PeerReference(component.participant.peer) { + if let result = imageCache.getAvatarImage(peer: peerReference, resource: MediaResourceReference.avatar(peer: peerReference, resource: smallProfileImage.resource), immediateThumbnail: component.participant.peer.profileImageRepresentations.first?.immediateThumbnailData, size: 64, synchronous: false) { + if let image = result.image { + blurredAvatarView.image = blurredAvatarImage(image) + } + if let loadSignal = result.loadSignal { + self.blurredAvatarDisposable = (loadSignal + |> deliverOnMainQueue).startStrict(next: { [weak self] image in + guard let self else { + return + } + if let image { + self.blurredAvatarView?.image = blurredAvatarImage(image) + } else { + self.blurredAvatarView?.image = nil + } + }) + } + } + } + } + } else { + if let blurredAvatarView = self.blurredAvatarView { + self.blurredAvatarView = nil + blurredAvatarView.removeFromSuperview() + } + if let blurredAvatarDisposable = self.blurredAvatarDisposable { + self.blurredAvatarDisposable = nil + blurredAvatarDisposable.dispose() + } + } + + let muteStatusSize = self.muteStatus.update( + transition: transition, + component: AnyComponent(VideoChatMuteIconComponent( + color: .white, + content: component.isPresentation ? .screenshare : .mute(isFilled: true, isMuted: component.participant.muteState != nil && !component.isSpeaking) + )), + environment: {}, + containerSize: CGSize(width: 36.0, height: 36.0) + ) + let muteStatusFrame: CGRect + if component.isExpanded { + muteStatusFrame = CGRect(origin: CGPoint(x: 5.0, y: availableSize.height - component.controlInsets.bottom + 1.0 - muteStatusSize.height), size: muteStatusSize) + } else { + muteStatusFrame = CGRect(origin: CGPoint(x: 1.0, y: availableSize.height - component.controlInsets.bottom + 3.0 - muteStatusSize.height), size: muteStatusSize) + } + if let muteStatusView = self.muteStatus.view { + if muteStatusView.superview == nil { + self.addSubview(muteStatusView) + muteStatusView.alpha = controlsAlpha + } + transition.setPosition(view: muteStatusView, position: muteStatusFrame.center) + transition.setBounds(view: muteStatusView, bounds: CGRect(origin: CGPoint(), size: muteStatusFrame.size)) + transition.setScale(view: muteStatusView, scale: component.isExpanded ? 1.0 : 0.7) + alphaTransition.setAlpha(view: muteStatusView, alpha: controlsAlpha) + } + + let titleSize = self.title.update( + transition: .immediate, + component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString(string: component.participant.peer.debugDisplayTitle, font: Font.semibold(16.0), textColor: .white)) + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - 8.0 * 2.0, height: 100.0) + ) + let titleFrame: CGRect + if component.isExpanded { + titleFrame = CGRect(origin: CGPoint(x: 36.0, y: availableSize.height - component.controlInsets.bottom - 8.0 - titleSize.height), size: titleSize) + } else { + titleFrame = CGRect(origin: CGPoint(x: 29.0, y: availableSize.height - component.controlInsets.bottom - 4.0 - titleSize.height), size: titleSize) + } + if let titleView = self.title.view { + if titleView.superview == nil { + titleView.layer.anchorPoint = CGPoint() + self.addSubview(titleView) + titleView.alpha = controlsAlpha + } + transition.setPosition(view: titleView, position: titleFrame.origin) + titleView.bounds = CGRect(origin: CGPoint(), size: titleFrame.size) + transition.setScale(view: titleView, scale: component.isExpanded ? 1.0 : 0.825) + alphaTransition.setAlpha(view: titleView, alpha: controlsAlpha) + } + + if let videoDescription = component.isPresentation ? component.participant.presentationDescription : component.participant.videoDescription { + let videoBackgroundLayer: SimpleLayer + if let current = self.videoBackgroundLayer { + videoBackgroundLayer = current + } else { + videoBackgroundLayer = SimpleLayer() + videoBackgroundLayer.backgroundColor = UIColor(white: 0.1, alpha: 1.0).cgColor + self.videoBackgroundLayer = videoBackgroundLayer + if let blurredAvatarView = self.blurredAvatarView { + self.layer.insertSublayer(videoBackgroundLayer, above: blurredAvatarView.layer) + } else { + self.layer.insertSublayer(videoBackgroundLayer, at: 0) + } + videoBackgroundLayer.isHidden = true + } + + let videoLayer: PrivateCallVideoLayer + if let current = self.videoLayer { + videoLayer = current + } else { + videoLayer = PrivateCallVideoLayer() + self.videoLayer = videoLayer + self.layer.insertSublayer(videoLayer.blurredLayer, above: videoBackgroundLayer) + self.layer.insertSublayer(videoLayer, above: videoLayer.blurredLayer) + + videoLayer.blurredLayer.opacity = 0.25 + + if let input = (component.call as! PresentationGroupCallImpl).video(endpointId: videoDescription.endpointId) { + let videoSource = AdaptedCallVideoSource(videoStreamSignal: input) + self.videoSource = videoSource + + self.videoDisposable?.dispose() + self.videoDisposable = videoSource.addOnUpdated { [weak self] in + guard let self, let videoSource = self.videoSource, let videoLayer = self.videoLayer else { + return + } + + let videoOutput = videoSource.currentOutput + videoLayer.video = videoOutput + + if let videoOutput { + let videoSpec = VideoSpec(resolution: videoOutput.resolution, rotationAngle: videoOutput.rotationAngle, followsDeviceOrientation: videoOutput.followsDeviceOrientation) + if self.videoSpec != videoSpec { + self.videoSpec = videoSpec + if !self.isUpdating { + self.componentState?.updated(transition: .immediate, isLocal: true) + } + } + } else { + if self.videoSpec != nil { + self.videoSpec = nil + if !self.isUpdating { + self.componentState?.updated(transition: .immediate, isLocal: true) + } + } + } + } + } + } + + transition.setFrame(layer: videoBackgroundLayer, frame: CGRect(origin: CGPoint(), size: availableSize)) + + if let videoSpec = self.videoSpec { + videoBackgroundLayer.isHidden = false + + let rotationAngle = resolveCallVideoRotationAngle(angle: videoSpec.rotationAngle, followsDeviceOrientation: videoSpec.followsDeviceOrientation, interfaceOrientation: component.interfaceOrientation) + + var rotatedResolution = videoSpec.resolution + var videoIsRotated = false + if abs(rotationAngle - Float.pi * 0.5) < .ulpOfOne || abs(rotationAngle - Float.pi * 3.0 / 2.0) < .ulpOfOne { + videoIsRotated = true + } + if videoIsRotated { + rotatedResolution = CGSize(width: rotatedResolution.height, height: rotatedResolution.width) + } + + let videoSize = rotatedResolution.aspectFitted(availableSize) + let videoFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((availableSize.width - videoSize.width) * 0.5), y: floorToScreenPixels((availableSize.height - videoSize.height) * 0.5)), size: videoSize) + let blurredVideoSize = rotatedResolution.aspectFilled(availableSize) + let blurredVideoFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((availableSize.width - blurredVideoSize.width) * 0.5), y: floorToScreenPixels((availableSize.height - blurredVideoSize.height) * 0.5)), size: blurredVideoSize) + + let videoResolution = rotatedResolution + + var rotatedVideoResolution = videoResolution + var rotatedVideoFrame = videoFrame + var rotatedBlurredVideoFrame = blurredVideoFrame + var rotatedVideoBoundsSize = videoFrame.size + var rotatedBlurredVideoBoundsSize = blurredVideoFrame.size + + if videoIsRotated { + rotatedVideoBoundsSize = CGSize(width: rotatedVideoBoundsSize.height, height: rotatedVideoBoundsSize.width) + rotatedVideoFrame = rotatedVideoFrame.size.centered(around: rotatedVideoFrame.center) + + rotatedBlurredVideoBoundsSize = CGSize(width: rotatedBlurredVideoBoundsSize.height, height: rotatedBlurredVideoBoundsSize.width) + rotatedBlurredVideoFrame = rotatedBlurredVideoFrame.size.centered(around: rotatedBlurredVideoFrame.center) + } + rotatedVideoResolution = rotatedVideoResolution.aspectFittedOrSmaller(CGSize(width: rotatedVideoFrame.width * UIScreenScale, height: rotatedVideoFrame.height * UIScreenScale)) + + transition.setPosition(layer: videoLayer, position: rotatedVideoFrame.center) + transition.setBounds(layer: videoLayer, bounds: CGRect(origin: CGPoint(), size: rotatedVideoBoundsSize)) + transition.setTransform(layer: videoLayer, transform: CATransform3DMakeRotation(CGFloat(rotationAngle), 0.0, 0.0, 1.0)) + videoLayer.renderSpec = RenderLayerSpec(size: RenderSize(width: Int(rotatedVideoResolution.width), height: Int(rotatedVideoResolution.height)), edgeInset: 2) + + transition.setPosition(layer: videoLayer.blurredLayer, position: rotatedBlurredVideoFrame.center) + transition.setBounds(layer: videoLayer.blurredLayer, bounds: CGRect(origin: CGPoint(), size: rotatedBlurredVideoBoundsSize)) + transition.setTransform(layer: videoLayer.blurredLayer, transform: CATransform3DMakeRotation(CGFloat(rotationAngle), 0.0, 0.0, 1.0)) + } + } else { + if let videoBackgroundLayer = self.videoBackgroundLayer { + self.videoBackgroundLayer = nil + videoBackgroundLayer.removeFromSuperlayer() + } + if let videoLayer = self.videoLayer { + self.videoLayer = nil + videoLayer.blurredLayer.removeFromSuperlayer() + videoLayer.removeFromSuperlayer() + } + self.videoDisposable?.dispose() + self.videoDisposable = nil + self.videoSource = nil + self.videoSpec = nil + } + + if self.loadingEffectView == nil, let rootVideoLoadingEffectView = component.rootVideoLoadingEffectView { + if let loadingEffectView = PortalView(matchPosition: true) { + self.loadingEffectView = loadingEffectView + self.addSubview(loadingEffectView.view) + rootVideoLoadingEffectView.portalSource.addPortal(view: loadingEffectView) + loadingEffectView.view.isUserInteractionEnabled = false + loadingEffectView.view.frame = CGRect(origin: CGPoint(), size: availableSize) + } + } + if let loadingEffectView = self.loadingEffectView { + transition.setFrame(view: loadingEffectView.view, frame: CGRect(origin: CGPoint(), size: availableSize)) + } + + if component.isSpeaking && !component.isExpanded { + let activityBorderView: UIImageView + if let current = self.activityBorderView { + activityBorderView = current + } else { + activityBorderView = UIImageView() + self.activityBorderView = activityBorderView + self.addSubview(activityBorderView) + + activityBorderView.image = activityBorderImage + activityBorderView.tintColor = UIColor(rgb: 0x33C758) + + if let previousSize { + activityBorderView.frame = CGRect(origin: CGPoint(), size: previousSize) + } + } + } else if let activityBorderView = self.activityBorderView { + if !transition.animation.isImmediate { + let alphaTransition: ComponentTransition = .easeInOut(duration: 0.2) + if activityBorderView.alpha != 0.0 { + alphaTransition.setAlpha(view: activityBorderView, alpha: 0.0, completion: { [weak self, weak activityBorderView] completed in + guard let self, let component = self.component, let activityBorderView, self.activityBorderView === activityBorderView, completed else { + return + } + if !component.isSpeaking { + activityBorderView.removeFromSuperview() + self.activityBorderView = nil + } + }) + } + } else { + self.activityBorderView = nil + activityBorderView.removeFromSuperview() + } + } + + if let activityBorderView = self.activityBorderView { + transition.setFrame(view: activityBorderView, frame: CGRect(origin: CGPoint(), size: availableSize)) + } + + self.previousSize = availableSize + + return availableSize + } + } + + func makeView() -> View { + return View(frame: CGRect()) + } + + func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } +} diff --git a/submodules/TelegramCallsUI/Sources/VideoChatParticipantsComponent.swift b/submodules/TelegramCallsUI/Sources/VideoChatParticipantsComponent.swift new file mode 100644 index 00000000000..f4fe335ade4 --- /dev/null +++ b/submodules/TelegramCallsUI/Sources/VideoChatParticipantsComponent.swift @@ -0,0 +1,1602 @@ +import Foundation +import UIKit +import Display +import ComponentFlow +import Postbox +import TelegramCore +import AccountContext +import PlainButtonComponent +import SwiftSignalKit +import MultilineTextComponent +import TelegramPresentationData +import PeerListItemComponent + +final class VideoChatParticipantsComponent: Component { + struct Layout: Equatable { + struct Column: Equatable { + var width: CGFloat + var insets: UIEdgeInsets + + init(width: CGFloat, insets: UIEdgeInsets) { + self.width = width + self.insets = insets + } + } + + var videoColumn: Column? + var mainColumn: Column + var columnSpacing: CGFloat + + init(videoColumn: Column?, mainColumn: Column, columnSpacing: CGFloat) { + self.videoColumn = videoColumn + self.mainColumn = mainColumn + self.columnSpacing = columnSpacing + } + } + + final class Participants: Equatable { + enum InviteType { + case invite + case shareLink + } + + let myPeerId: EnginePeer.Id + let participants: [GroupCallParticipantsContext.Participant] + let totalCount: Int + let loadMoreToken: String? + let inviteType: InviteType? + + init(myPeerId: EnginePeer.Id, participants: [GroupCallParticipantsContext.Participant], totalCount: Int, loadMoreToken: String?, inviteType: InviteType?) { + self.myPeerId = myPeerId + self.participants = participants + self.totalCount = totalCount + self.loadMoreToken = loadMoreToken + self.inviteType = inviteType + } + + static func ==(lhs: Participants, rhs: Participants) -> Bool { + if lhs === rhs { + return true + } + if lhs.myPeerId != rhs.myPeerId { + return false + } + if lhs.participants != rhs.participants { + return false + } + if lhs.totalCount != rhs.totalCount { + return false + } + if lhs.loadMoreToken != rhs.loadMoreToken { + return false + } + if lhs.inviteType != rhs.inviteType { + return false + } + return true + } + } + + struct VideoParticipantKey: Hashable { + var id: EnginePeer.Id + var isPresentation: Bool + + init(id: EnginePeer.Id, isPresentation: Bool) { + self.id = id + self.isPresentation = isPresentation + } + } + + final class ExpandedVideoState: Equatable { + let mainParticipant: VideoParticipantKey + let isMainParticipantPinned: Bool + let isUIHidden: Bool + + init(mainParticipant: VideoParticipantKey, isMainParticipantPinned: Bool, isUIHidden: Bool) { + self.mainParticipant = mainParticipant + self.isMainParticipantPinned = isMainParticipantPinned + self.isUIHidden = isUIHidden + } + + static func ==(lhs: ExpandedVideoState, rhs: ExpandedVideoState) -> Bool { + if lhs === rhs { + return true + } + if lhs.mainParticipant != rhs.mainParticipant { + return false + } + if lhs.isMainParticipantPinned != rhs.isMainParticipantPinned { + return false + } + if lhs.isUIHidden != rhs.isUIHidden { + return false + } + return true + } + } + + let call: PresentationGroupCall + let participants: Participants? + let speakingParticipants: Set + let expandedVideoState: ExpandedVideoState? + let theme: PresentationTheme + let strings: PresentationStrings + let layout: Layout + let expandedInsets: UIEdgeInsets + let safeInsets: UIEdgeInsets + let interfaceOrientation: UIInterfaceOrientation + let openParticipantContextMenu: (EnginePeer.Id, ContextExtractedContentContainingView, ContextGesture?) -> Void + let updateMainParticipant: (VideoParticipantKey?) -> Void + let updateIsMainParticipantPinned: (Bool) -> Void + let updateIsExpandedUIHidden: (Bool) -> Void + let openInviteMembers: () -> Void + + init( + call: PresentationGroupCall, + participants: Participants?, + speakingParticipants: Set, + expandedVideoState: ExpandedVideoState?, + theme: PresentationTheme, + strings: PresentationStrings, + layout: Layout, + expandedInsets: UIEdgeInsets, + safeInsets: UIEdgeInsets, + interfaceOrientation: UIInterfaceOrientation, + openParticipantContextMenu: @escaping (EnginePeer.Id, ContextExtractedContentContainingView, ContextGesture?) -> Void, + updateMainParticipant: @escaping (VideoParticipantKey?) -> Void, + updateIsMainParticipantPinned: @escaping (Bool) -> Void, + updateIsExpandedUIHidden: @escaping (Bool) -> Void, + openInviteMembers: @escaping () -> Void + ) { + self.call = call + self.participants = participants + self.speakingParticipants = speakingParticipants + self.expandedVideoState = expandedVideoState + self.theme = theme + self.strings = strings + self.layout = layout + self.expandedInsets = expandedInsets + self.safeInsets = safeInsets + self.interfaceOrientation = interfaceOrientation + self.openParticipantContextMenu = openParticipantContextMenu + self.updateMainParticipant = updateMainParticipant + self.updateIsMainParticipantPinned = updateIsMainParticipantPinned + self.updateIsExpandedUIHidden = updateIsExpandedUIHidden + self.openInviteMembers = openInviteMembers + } + + static func ==(lhs: VideoChatParticipantsComponent, rhs: VideoChatParticipantsComponent) -> Bool { + if lhs.participants != rhs.participants { + return false + } + if lhs.speakingParticipants != rhs.speakingParticipants { + return false + } + if lhs.expandedVideoState != rhs.expandedVideoState { + return false + } + if lhs.theme !== rhs.theme { + return false + } + if lhs.strings !== rhs.strings { + return false + } + if lhs.layout != rhs.layout { + return false + } + if lhs.expandedInsets != rhs.expandedInsets { + return false + } + if lhs.safeInsets != rhs.safeInsets { + return false + } + if lhs.interfaceOrientation != rhs.interfaceOrientation { + return false + } + return true + } + + private final class ScrollView: UIScrollView { + override func touchesShouldCancel(in view: UIView) -> Bool { + return true + } + } + + private final class ItemLayout { + struct Grid { + let containerSize: CGSize + let sideInset: CGFloat + let itemCount: Int + let isDedicatedColumn: Bool + let itemSize: CGSize + let itemSpacing: CGFloat + let lastItemSize: CGFloat + let lastRowItemCount: Int + let lastRowItemSize: CGFloat + let itemsPerRow: Int + let rowCount: Int + + init(containerSize: CGSize, sideInset: CGFloat, itemCount: Int, isDedicatedColumn: Bool) { + self.containerSize = containerSize + self.sideInset = sideInset + self.itemCount = itemCount + self.isDedicatedColumn = isDedicatedColumn + + let width: CGFloat = containerSize.width - sideInset * 2.0 + + self.itemSpacing = 4.0 + + let itemsPerRow: Int + if isDedicatedColumn { + if itemCount <= 2 { + itemsPerRow = 1 + } else { + itemsPerRow = 2 + } + } else { + if itemCount == 1 { + itemsPerRow = 1 + } else { + itemsPerRow = 2 + } + } + self.itemsPerRow = Int(itemsPerRow) + + let itemWidth = floorToScreenPixels((width - (self.itemSpacing * CGFloat(self.itemsPerRow - 1))) / CGFloat(itemsPerRow)) + let itemHeight = min(180.0, itemWidth) + var itemSize = CGSize(width: itemWidth, height: itemHeight) + + self.rowCount = itemCount / self.itemsPerRow + ((itemCount % self.itemsPerRow) != 0 ? 1 : 0) + + if isDedicatedColumn && itemCount != 0 { + let contentHeight = itemSize.height * CGFloat(self.rowCount) + self.itemSpacing * CGFloat(max(0, self.rowCount - 1)) + if contentHeight < containerSize.height { + itemSize.height = (containerSize.height - self.itemSpacing * CGFloat(max(0, self.rowCount - 1))) / CGFloat(self.rowCount) + itemSize.height = floor(itemSize.height) + } + } + + self.itemSize = itemSize + + self.lastItemSize = width - (self.itemSize.width + self.itemSpacing) * CGFloat(self.itemsPerRow - 1) + var lastRowItemCount = itemCount % self.itemsPerRow + if lastRowItemCount == 0 { + lastRowItemCount = self.itemsPerRow + } + self.lastRowItemCount = lastRowItemCount + self.lastRowItemSize = width - (self.itemSize.width + self.itemSpacing) * CGFloat(lastRowItemCount - 1) + } + + func frame(at index: Int) -> CGRect { + let row = index / self.itemsPerRow + let column = index % self.itemsPerRow + + let itemWidth: CGFloat + if row == self.rowCount - 1 && column == self.lastRowItemCount - 1 { + itemWidth = self.lastRowItemSize + } else if column == self.itemsPerRow - 1 { + if row == self.rowCount - 1 { + itemWidth = self.lastRowItemSize + } else { + itemWidth = self.lastItemSize + } + } else { + itemWidth = self.itemSize.width + } + + let frame = CGRect(origin: CGPoint(x: self.sideInset + CGFloat(column) * (self.itemSize.width + self.itemSpacing), y: CGFloat(row) * (self.itemSize.height + self.itemSpacing)), size: CGSize(width: itemWidth, height: itemSize.height)) + return frame + } + + func contentHeight() -> CGFloat { + return self.frame(at: self.itemCount - 1).maxY + } + + func visibleItemRange(for rect: CGRect) -> (minIndex: Int, maxIndex: Int) { + if self.itemCount == 0 { + return (0, -1) + } + let offsetRect = rect.offsetBy(dx: 0.0, dy: 0.0) + var minVisibleRow = Int(floor((offsetRect.minY - self.itemSpacing) / (self.itemSize.height + self.itemSpacing))) + minVisibleRow = max(0, minVisibleRow) + let maxVisibleRow = Int(ceil((offsetRect.maxY - self.itemSpacing) / (self.itemSize.height + itemSpacing))) + + let minVisibleIndex = minVisibleRow * self.itemsPerRow + let maxVisibleIndex = min(self.itemCount - 1, (maxVisibleRow + 1) * self.itemsPerRow - 1) + + return (minVisibleIndex, maxVisibleIndex) + } + } + + struct ExpandedGrid { + let containerSize: CGSize + let layout: Layout + let expandedInsets: UIEdgeInsets + let isUIHidden: Bool + + init(containerSize: CGSize, layout: Layout, expandedInsets: UIEdgeInsets, isUIHidden: Bool) { + self.containerSize = containerSize + self.layout = layout + self.expandedInsets = expandedInsets + self.isUIHidden = isUIHidden + } + + func itemContainerFrame() -> CGRect { + let containerInsets: UIEdgeInsets + if self.isUIHidden { + containerInsets = UIEdgeInsets() + } else { + containerInsets = self.expandedInsets + } + + if self.layout.videoColumn != nil { + return CGRect(origin: CGPoint(x: containerInsets.left, y: containerInsets.top), size: CGSize(width: self.containerSize.width - containerInsets.left - containerInsets.right, height: self.containerSize.height - containerInsets.top - containerInsets.bottom)) + } else { + return CGRect(origin: CGPoint(x: containerInsets.left, y: containerInsets.top), size: CGSize(width: self.containerSize.width - containerInsets.left - containerInsets.right, height: self.containerSize.height - containerInsets.top - containerInsets.bottom)) + } + } + + func itemContainerInsets() -> UIEdgeInsets { + if self.isUIHidden { + return self.expandedInsets + } else { + return UIEdgeInsets() + } + } + } + + struct List { + let containerSize: CGSize + let sideInset: CGFloat + let itemCount: Int + let itemHeight: CGFloat + let trailingItemHeight: CGFloat + + init(containerSize: CGSize, sideInset: CGFloat, itemCount: Int, itemHeight: CGFloat, trailingItemHeight: CGFloat) { + self.containerSize = containerSize + self.sideInset = sideInset + self.itemCount = itemCount + self.itemHeight = itemHeight + self.trailingItemHeight = trailingItemHeight + } + + func frame(at index: Int) -> CGRect { + let frame = CGRect(origin: CGPoint(x: self.sideInset, y: CGFloat(index) * self.itemHeight), size: CGSize(width: self.containerSize.width - self.sideInset * 2.0, height: self.itemHeight)) + return frame + } + + func trailingItemFrame() -> CGRect { + return CGRect(origin: CGPoint(x: self.sideInset, y: CGFloat(self.itemCount) * self.itemHeight), size: CGSize(width: self.containerSize.width - self.sideInset * 2.0, height: self.trailingItemHeight)) + } + + func contentHeight() -> CGFloat { + var result: CGFloat = 0.0 + if self.itemCount != 0 { + result = self.frame(at: self.itemCount - 1).maxY + } + result += self.trailingItemHeight + return result + } + + func visibleItemRange(for rect: CGRect) -> (minIndex: Int, maxIndex: Int) { + if self.itemCount == 0 { + return (0, -1) + } + let offsetRect = rect.offsetBy(dx: 0.0, dy: 0.0) + var minVisibleRow = Int(floor((offsetRect.minY) / (self.itemHeight))) + minVisibleRow = max(0, minVisibleRow) + let maxVisibleRow = Int(ceil((offsetRect.maxY) / (self.itemHeight))) + + let minVisibleIndex = minVisibleRow + let maxVisibleIndex = min(self.itemCount - 1, (maxVisibleRow + 1) - 1) + + return (minVisibleIndex, maxVisibleIndex) + } + } + + let containerSize: CGSize + let layout: Layout + let isUIHidden: Bool + let expandedInsets: UIEdgeInsets + let safeInsets: UIEdgeInsets + let grid: Grid + let expandedGrid: ExpandedGrid + let list: List + let spacing: CGFloat + let gridOffsetY: CGFloat + let listOffsetY: CGFloat + let listFrame: CGRect + let separateVideoGridFrame: CGRect + let scrollClippingFrame: CGRect + let separateVideoScrollClippingFrame: CGRect + + init(containerSize: CGSize, layout: Layout, isUIHidden: Bool, expandedInsets: UIEdgeInsets, safeInsets: UIEdgeInsets, gridItemCount: Int, listItemCount: Int, listItemHeight: CGFloat, listTrailingItemHeight: CGFloat) { + self.containerSize = containerSize + self.layout = layout + self.isUIHidden = isUIHidden + self.expandedInsets = expandedInsets + self.safeInsets = safeInsets + + let listWidth: CGFloat = layout.mainColumn.width + let gridWidth: CGFloat + let gridSideInset: CGFloat + let gridContainerHeight: CGFloat + if let videoColumn = layout.videoColumn { + gridWidth = videoColumn.width + gridSideInset = videoColumn.insets.left + gridContainerHeight = containerSize.height - videoColumn.insets.top - videoColumn.insets.bottom + } else { + gridWidth = listWidth + gridSideInset = layout.mainColumn.insets.left + gridContainerHeight = containerSize.height + } + + self.grid = Grid(containerSize: CGSize(width: gridWidth, height: gridContainerHeight), sideInset: gridSideInset, itemCount: gridItemCount, isDedicatedColumn: layout.videoColumn != nil) + self.list = List(containerSize: CGSize(width: listWidth, height: containerSize.height), sideInset: layout.mainColumn.insets.left, itemCount: listItemCount, itemHeight: listItemHeight, trailingItemHeight: listTrailingItemHeight) + self.spacing = 4.0 + + if let videoColumn = layout.videoColumn, !isUIHidden { + self.expandedGrid = ExpandedGrid(containerSize: CGSize(width: videoColumn.width + expandedInsets.left, height: containerSize.height), layout: layout, expandedInsets: UIEdgeInsets(top: expandedInsets.top, left: expandedInsets.left, bottom: expandedInsets.bottom, right: 0.0), isUIHidden: isUIHidden) + } else { + self.expandedGrid = ExpandedGrid(containerSize: containerSize, layout: layout, expandedInsets: expandedInsets, isUIHidden: isUIHidden) + } + + self.gridOffsetY = layout.mainColumn.insets.top + + var listOffsetY: CGFloat = self.gridOffsetY + if layout.videoColumn == nil { + if self.grid.itemCount != 0 { + listOffsetY += self.grid.contentHeight() + listOffsetY += self.spacing + } + } + self.listOffsetY = listOffsetY + + if let videoColumn = layout.videoColumn { + let columnsWidth: CGFloat = videoColumn.width + layout.columnSpacing + layout.mainColumn.width + let columnsSideInset: CGFloat = floorToScreenPixels((containerSize.width - columnsWidth) * 0.5) + + var separateVideoGridFrame = CGRect(origin: CGPoint(x: floor((containerSize.width - columnsWidth) * 0.5), y: 0.0), size: CGSize(width: gridWidth, height: containerSize.height)) + + var listFrame = CGRect(origin: CGPoint(x: separateVideoGridFrame.maxX + layout.columnSpacing, y: 0.0), size: CGSize(width: listWidth, height: containerSize.height)) + if isUIHidden { + listFrame.origin.x += columnsSideInset + layout.mainColumn.width + separateVideoGridFrame = CGRect(origin: CGPoint(x: floor((containerSize.width - columnsWidth) * 0.5), y: 0.0), size: CGSize(width: columnsWidth, height: containerSize.height)) + } + + self.separateVideoGridFrame = separateVideoGridFrame + self.listFrame = listFrame + + self.separateVideoScrollClippingFrame = CGRect(origin: CGPoint(x: self.separateVideoGridFrame.minX, y: videoColumn.insets.top), size: CGSize(width: self.separateVideoGridFrame.width, height: containerSize.height - videoColumn.insets.top)) + self.scrollClippingFrame = CGRect(origin: CGPoint(x: self.listFrame.minX, y: layout.mainColumn.insets.top), size: CGSize(width: self.listFrame.width, height: containerSize.height - layout.mainColumn.insets.top)) + } else { + self.listFrame = CGRect(origin: CGPoint(x: floor((containerSize.width - listWidth) * 0.5), y: 0.0), size: CGSize(width: listWidth, height: containerSize.height)) + self.scrollClippingFrame = CGRect(origin: CGPoint(x: self.listFrame.minX, y: layout.mainColumn.insets.top), size: CGSize(width: listWidth, height: containerSize.height - layout.mainColumn.insets.top - layout.mainColumn.insets.bottom)) + + self.separateVideoGridFrame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: 0.0, height: containerSize.height)) + self.separateVideoScrollClippingFrame = CGRect(origin: CGPoint(x: self.separateVideoGridFrame.minX, y: layout.mainColumn.insets.top), size: CGSize(width: self.separateVideoGridFrame.width, height: containerSize.height - layout.mainColumn.insets.top)) + } + } + + func contentHeight() -> CGFloat { + var result: CGFloat = self.gridOffsetY + if self.layout.videoColumn == nil { + if self.grid.itemCount != 0 { + result += self.grid.contentHeight() + result += self.spacing + } + } + result += self.list.contentHeight() + result += self.layout.mainColumn.insets.bottom + result += 24.0 + return result + } + + func separateVideoGridContentHeight() -> CGFloat { + var result: CGFloat = self.gridOffsetY + if let videoColumn = self.layout.videoColumn { + if self.grid.itemCount != 0 { + result += self.grid.contentHeight() + } + result += videoColumn.insets.bottom + } + return result + } + + func visibleGridItemRange(for rect: CGRect) -> (minIndex: Int, maxIndex: Int) { + return self.grid.visibleItemRange(for: rect.offsetBy(dx: 0.0, dy: -self.gridOffsetY)) + } + + func gridItemFrame(at index: Int) -> CGRect { + return self.grid.frame(at: index) + } + + func gridItemContainerFrame() -> CGRect { + if let _ = self.layout.videoColumn { + return CGRect(origin: CGPoint(x: 0.0, y: self.gridOffsetY), size: CGSize(width: self.separateVideoGridFrame.width, height: self.grid.contentHeight())) + } else { + return CGRect(origin: CGPoint(x: 0.0, y: self.gridOffsetY), size: CGSize(width: self.containerSize.width, height: self.grid.contentHeight())) + } + } + + func visibleListItemRange(for rect: CGRect) -> (minIndex: Int, maxIndex: Int) { + return self.list.visibleItemRange(for: rect.offsetBy(dx: 0.0, dy: -self.listOffsetY)) + } + + func listItemFrame(at index: Int) -> CGRect { + return self.list.frame(at: index) + } + + func listItemContainerFrame() -> CGRect { + if let _ = self.layout.videoColumn { + return CGRect(origin: CGPoint(x: 0.0, y: self.listOffsetY), size: CGSize(width: self.separateVideoGridFrame.width, height: self.list.contentHeight())) + } else { + return CGRect(origin: CGPoint(x: 0.0, y: self.listOffsetY), size: CGSize(width: self.containerSize.width, height: self.list.contentHeight())) + } + } + + func listTrailingItemFrame() -> CGRect { + return self.list.trailingItemFrame() + } + } + + private struct ExpandedGridSwipeState { + var fraction: CGFloat + + init(fraction: CGFloat) { + self.fraction = fraction + } + } + + private final class VideoParticipant: Equatable { + let participant: GroupCallParticipantsContext.Participant + let isPresentation: Bool + + var key: VideoParticipantKey { + return VideoParticipantKey(id: self.participant.peer.id, isPresentation: self.isPresentation) + } + + init(participant: GroupCallParticipantsContext.Participant, isPresentation: Bool) { + self.participant = participant + self.isPresentation = isPresentation + } + + static func ==(lhs: VideoParticipant, rhs: VideoParticipant) -> Bool { + if lhs.participant != rhs.participant { + return false + } + if lhs.isPresentation != rhs.isPresentation { + return false + } + return true + } + } + + private final class GridItem { + let key: VideoParticipantKey + let view = ComponentView() + var isCollapsing: Bool = false + + init(key: VideoParticipantKey) { + self.key = key + } + } + + private final class ListItem { + let view = ComponentView() + let separatorLayer = SimpleLayer() + + init() { + } + } + + final class View: UIView, UIScrollViewDelegate { + private var rootVideoLoadingEffectView: VideoChatVideoLoadingEffectView? + + private let scrollViewClippingContainer: SolidRoundedCornersContainer + private let scrollView: ScrollView + + private let separateVideoScrollViewClippingContainer: SolidRoundedCornersContainer + private let separateVideoScrollView: ScrollView + + private var component: VideoChatParticipantsComponent? + private weak var state: EmptyComponentState? + private var isUpdating: Bool = false + + private var ignoreScrolling: Bool = false + + private var gridParticipants: [VideoParticipant] = [] + private var listParticipants: [GroupCallParticipantsContext.Participant] = [] + + private let measureListItemView = ComponentView() + private let inviteListItemView = ComponentView() + + private var gridItemViews: [VideoParticipantKey: GridItem] = [:] + private let gridItemViewContainer: UIView + + private let expandedGridItemContainer: UIView + private var expandedControlsView: ComponentView? + private var expandedThumbnailsView: ComponentView? + + private var listItemViews: [EnginePeer.Id: ListItem] = [:] + private let listItemViewContainer: UIView + private let listItemViewSeparatorContainer: SimpleLayer + private let listItemsBackground = ComponentView() + + private var itemLayout: ItemLayout? + private var expandedGridSwipeState: ExpandedGridSwipeState? + + private var appliedGridIsEmpty: Bool = true + + override init(frame: CGRect) { + self.scrollViewClippingContainer = SolidRoundedCornersContainer() + self.scrollView = ScrollView() + + self.separateVideoScrollViewClippingContainer = SolidRoundedCornersContainer() + self.separateVideoScrollView = ScrollView() + + self.gridItemViewContainer = UIView() + self.gridItemViewContainer.layer.anchorPoint = CGPoint(x: 0.5, y: 0.0) + + self.listItemViewContainer = UIView() + self.listItemViewContainer.clipsToBounds = true + self.listItemViewSeparatorContainer = SimpleLayer() + + self.expandedGridItemContainer = UIView() + self.expandedGridItemContainer.clipsToBounds = true + + super.init(frame: frame) + + self.scrollView.delaysContentTouches = false + self.scrollView.canCancelContentTouches = true + self.scrollView.clipsToBounds = false + 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 = false + self.scrollView.scrollsToTop = false + self.scrollView.delegate = self + self.scrollView.clipsToBounds = true + + self.separateVideoScrollView.delaysContentTouches = false + self.separateVideoScrollView.canCancelContentTouches = true + self.separateVideoScrollView.clipsToBounds = false + self.separateVideoScrollView.contentInsetAdjustmentBehavior = .never + if #available(iOS 13.0, *) { + self.separateVideoScrollView.automaticallyAdjustsScrollIndicatorInsets = false + } + self.separateVideoScrollView.showsVerticalScrollIndicator = false + self.separateVideoScrollView.showsHorizontalScrollIndicator = false + self.separateVideoScrollView.alwaysBounceHorizontal = false + self.separateVideoScrollView.alwaysBounceVertical = false + self.separateVideoScrollView.scrollsToTop = false + self.separateVideoScrollView.delegate = self + self.separateVideoScrollView.clipsToBounds = true + + self.scrollViewClippingContainer.addSubview(self.scrollView) + self.addSubview(self.scrollViewClippingContainer) + self.addSubview(self.scrollViewClippingContainer.cornersView) + + self.separateVideoScrollViewClippingContainer.addSubview(self.separateVideoScrollView) + self.addSubview(self.separateVideoScrollViewClippingContainer) + self.addSubview(self.separateVideoScrollViewClippingContainer.cornersView) + + self.scrollView.addSubview(self.listItemViewContainer) + self.addSubview(self.expandedGridItemContainer) + + self.expandedGridItemContainer.addGestureRecognizer(UIPanGestureRecognizer(target: self, action: #selector(self.expandedGridPanGesture(_:)))) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + guard let component = self.component else { + return nil + } + + if component.expandedVideoState != nil { + if let result = self.expandedGridItemContainer.hitTest(self.convert(point, to: self.expandedGridItemContainer), with: event) { + return result + } else { + return self + } + } else { + if let result = self.scrollViewClippingContainer.hitTest(self.convert(point, to: self.scrollViewClippingContainer), with: event) { + return result + } else if let result = self.separateVideoScrollViewClippingContainer.hitTest(self.convert(point, to: self.separateVideoScrollViewClippingContainer), with: event) { + return result + } else { + return nil + } + } + } + + @objc private func expandedGridPanGesture(_ recognizer: UIPanGestureRecognizer) { + guard let component = self.component else { + return + } + if self.bounds.height == 0.0 { + return + } + switch recognizer.state { + case .began, .changed: + let translation = recognizer.translation(in: self) + let fraction = translation.y / self.bounds.height + self.expandedGridSwipeState = ExpandedGridSwipeState(fraction: fraction) + self.state?.updated(transition: .immediate) + case .ended, .cancelled: + let translation = recognizer.translation(in: self) + let fraction = translation.y / self.bounds.height + self.expandedGridSwipeState = nil + + let velocity = recognizer.velocity(in: self) + if abs(velocity.y) > 100.0 || abs(fraction) >= 0.5 { + component.updateMainParticipant(nil) + } else { + self.state?.updated(transition: .spring(duration: 0.4)) + } + default: + break + } + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + if !self.ignoreScrolling { + self.updateScrolling(transition: .immediate) + } + } + + private func updateScrolling(transition: ComponentTransition) { + guard let component = self.component, let itemLayout = self.itemLayout else { + return + } + + let alphaTransition: ComponentTransition + if !transition.animation.isImmediate { + alphaTransition = .easeInOut(duration: 0.2) + } else { + alphaTransition = .immediate + } + + let gridWasEmpty = self.appliedGridIsEmpty + let gridIsEmpty = self.gridParticipants.isEmpty + self.appliedGridIsEmpty = gridIsEmpty + + var previousExpandedItemId: VideoParticipantKey? + for (key, item) in self.gridItemViews { + if item.view.view?.superview == self.expandedGridItemContainer { + previousExpandedItemId = key + break + } + } + + let previousExpandedGridItemContainerFrame = self.expandedGridItemContainer.frame + var expandedGridItemContainerFrame: CGRect + if component.expandedVideoState != nil { + expandedGridItemContainerFrame = itemLayout.expandedGrid.itemContainerFrame() + if let expandedGridSwipeState = self.expandedGridSwipeState { + expandedGridItemContainerFrame.origin.y += expandedGridSwipeState.fraction * itemLayout.containerSize.height + } + } else { + if let videoColumn = itemLayout.layout.videoColumn { + expandedGridItemContainerFrame = itemLayout.gridItemContainerFrame().offsetBy(dx: itemLayout.separateVideoScrollClippingFrame.minX, dy: 0.0).offsetBy(dx: 0.0, dy: -self.separateVideoScrollView.bounds.minY) + + if expandedGridItemContainerFrame.origin.y < videoColumn.insets.top { + expandedGridItemContainerFrame.size.height -= videoColumn.insets.top - expandedGridItemContainerFrame.origin.y + expandedGridItemContainerFrame.origin.y = videoColumn.insets.top + } + if expandedGridItemContainerFrame.origin.y + expandedGridItemContainerFrame.height > itemLayout.containerSize.height { + expandedGridItemContainerFrame.size.height -= (expandedGridItemContainerFrame.origin.y + expandedGridItemContainerFrame.height) - (itemLayout.containerSize.height) + } + } else { + expandedGridItemContainerFrame = itemLayout.gridItemContainerFrame().offsetBy(dx: 0.0, dy: -self.scrollView.bounds.minY) + + if expandedGridItemContainerFrame.origin.y < itemLayout.layout.mainColumn.insets.top { + expandedGridItemContainerFrame.size.height -= itemLayout.layout.mainColumn.insets.top - expandedGridItemContainerFrame.origin.y + expandedGridItemContainerFrame.origin.y = itemLayout.layout.mainColumn.insets.top + } + if expandedGridItemContainerFrame.origin.y + expandedGridItemContainerFrame.height > itemLayout.containerSize.height - itemLayout.layout.mainColumn.insets.bottom { + expandedGridItemContainerFrame.size.height -= (expandedGridItemContainerFrame.origin.y + expandedGridItemContainerFrame.height) - (itemLayout.containerSize.height - itemLayout.layout.mainColumn.insets.bottom) + } + } + if expandedGridItemContainerFrame.size.height < 0.0 { + expandedGridItemContainerFrame.size.height = 0.0 + } + } + + let commonGridItemTransition: ComponentTransition = (gridIsEmpty == gridWasEmpty) ? transition : .immediate + + var validGridItemIds: [VideoParticipantKey] = [] + var validGridItemIndices: [Int] = [] + + let visibleGridItemRange: (minIndex: Int, maxIndex: Int) + if itemLayout.layout.videoColumn == nil { + visibleGridItemRange = itemLayout.visibleGridItemRange(for: self.scrollView.bounds) + } else { + visibleGridItemRange = itemLayout.visibleGridItemRange(for: self.separateVideoScrollView.bounds) + } + if visibleGridItemRange.maxIndex >= visibleGridItemRange.minIndex { + for index in visibleGridItemRange.minIndex ... visibleGridItemRange.maxIndex { + let videoParticipant = self.gridParticipants[index] + let videoParticipantKey = videoParticipant.key + validGridItemIds.append(videoParticipantKey) + validGridItemIndices.append(index) + } + } + if let expandedVideoState = component.expandedVideoState { + if !validGridItemIds.contains(expandedVideoState.mainParticipant), let index = self.gridParticipants.firstIndex(where: { $0.key == expandedVideoState.mainParticipant }) { + validGridItemIds.append(expandedVideoState.mainParticipant) + validGridItemIndices.append(index) + } + } + + for index in validGridItemIndices { + let videoParticipant = self.gridParticipants[index] + let videoParticipantKey = videoParticipant.key + validGridItemIds.append(videoParticipantKey) + + var itemTransition = commonGridItemTransition + let itemView: GridItem + if let current = self.gridItemViews[videoParticipantKey] { + itemView = current + } else { + itemTransition = itemTransition.withAnimation(.none) + itemView = GridItem(key: videoParticipant.key) + self.gridItemViews[videoParticipantKey] = itemView + } + + var isItemExpanded = false + var isItemUIHidden = false + if let expandedVideoState = component.expandedVideoState { + if expandedVideoState.mainParticipant == videoParticipantKey { + isItemExpanded = true + } + if expandedVideoState.isUIHidden { + isItemUIHidden = true + } + } + + var suppressItemExpansionCollapseAnimation = false + if isItemExpanded { + if let previousExpandedItemId, previousExpandedItemId != videoParticipantKey { + suppressItemExpansionCollapseAnimation = true + } + } else if component.expandedVideoState != nil { + if let previousExpandedItemId, previousExpandedItemId == videoParticipantKey { + suppressItemExpansionCollapseAnimation = true + } + } + var resultingItemTransition = commonGridItemTransition + if suppressItemExpansionCollapseAnimation { + itemTransition = itemTransition.withAnimation(.none) + resultingItemTransition = commonGridItemTransition.withAnimation(.none) + } + + let itemFrame: CGRect + if isItemExpanded { + itemFrame = CGRect(origin: CGPoint(), size: itemLayout.expandedGrid.itemContainerFrame().size) + } else { + itemFrame = itemLayout.gridItemFrame(at: index) + } + + let itemContentInsets: UIEdgeInsets + if isItemExpanded { + itemContentInsets = itemLayout.expandedGrid.itemContainerInsets() + } else { + itemContentInsets = UIEdgeInsets() + } + + var itemControlInsets: UIEdgeInsets + if isItemExpanded { + itemControlInsets = itemContentInsets + itemControlInsets.bottom = max(itemControlInsets.bottom, 96.0) + } else { + itemControlInsets = itemContentInsets + } + + let itemAlpha: CGFloat + if isItemExpanded { + itemAlpha = 1.0 + } else if component.expandedVideoState != nil && itemLayout.layout.videoColumn != nil { + itemAlpha = 0.0 + } else { + itemAlpha = 1.0 + } + + let _ = itemView.view.update( + transition: itemTransition, + component: AnyComponent(VideoChatParticipantVideoComponent( + call: component.call, + participant: videoParticipant.participant, + isPresentation: videoParticipant.isPresentation, + isSpeaking: component.speakingParticipants.contains(videoParticipant.participant.peer.id), + isExpanded: isItemExpanded, + isUIHidden: isItemUIHidden, + contentInsets: itemContentInsets, + controlInsets: itemControlInsets, + interfaceOrientation: component.interfaceOrientation, + rootVideoLoadingEffectView: self.rootVideoLoadingEffectView, + action: { [weak self] in + guard let self, let component = self.component else { + return + } + if let expandedVideoState = component.expandedVideoState, expandedVideoState.mainParticipant == videoParticipantKey { + component.updateIsExpandedUIHidden(!expandedVideoState.isUIHidden) + } else { + component.updateMainParticipant(videoParticipantKey) + } + } + )), + environment: {}, + containerSize: itemFrame.size + ) + if let itemComponentView = itemView.view.view { + if itemComponentView.superview == nil { + itemComponentView.layer.allowsGroupOpacity = true + + if isItemExpanded { + if let expandedThumbnailsView = self.expandedThumbnailsView?.view { + self.expandedGridItemContainer.insertSubview(itemComponentView, belowSubview: expandedThumbnailsView) + } else { + self.expandedGridItemContainer.addSubview(itemComponentView) + } + } else { + self.gridItemViewContainer.addSubview(itemComponentView) + } + + itemComponentView.frame = itemFrame + itemComponentView.alpha = itemAlpha + + if !resultingItemTransition.animation.isImmediate { + resultingItemTransition.animateScale(view: itemComponentView, from: 0.001, to: 1.0) + } + if !resultingItemTransition.animation.isImmediate && itemAlpha != 0.0 { + itemComponentView.layer.animateAlpha(from: 0.0, to: itemAlpha, duration: 0.1) + } + } else if isItemExpanded && itemComponentView.superview != self.expandedGridItemContainer { + let fromFrame = itemComponentView.convert(itemComponentView.bounds, to: self.expandedGridItemContainer) + itemComponentView.center = fromFrame.center + if let expandedThumbnailsView = self.expandedThumbnailsView?.view { + self.expandedGridItemContainer.insertSubview(itemComponentView, belowSubview: expandedThumbnailsView) + } else { + self.expandedGridItemContainer.addSubview(itemComponentView) + } + } else if !isItemExpanded && itemComponentView.superview != self.gridItemViewContainer { + if suppressItemExpansionCollapseAnimation { + self.gridItemViewContainer.addSubview(itemComponentView) + } else if !itemView.isCollapsing { + itemView.isCollapsing = true + let targetLocalItemFrame = itemLayout.gridItemFrame(at: index) + var targetItemFrame = self.gridItemViewContainer.convert(targetLocalItemFrame, to: self) + targetItemFrame.origin.y -= expandedGridItemContainerFrame.minY + targetItemFrame.origin.x -= expandedGridItemContainerFrame.minX + commonGridItemTransition.setPosition(view: itemComponentView, position: targetItemFrame.center) + commonGridItemTransition.setBounds(view: itemComponentView, bounds: CGRect(origin: CGPoint(), size: targetItemFrame.size), completion: { [weak self, weak itemView, weak itemComponentView] _ in + guard let self, let itemView, let itemComponentView else { + return + } + itemView.isCollapsing = false + self.gridItemViewContainer.addSubview(itemComponentView) + itemComponentView.center = targetLocalItemFrame.center + itemComponentView.bounds = CGRect(origin: CGPoint(), size: targetLocalItemFrame.size) + }) + } + } + if !itemView.isCollapsing { + resultingItemTransition.setPosition(view: itemComponentView, position: itemFrame.center) + resultingItemTransition.setBounds(view: itemComponentView, bounds: CGRect(origin: CGPoint(), size: itemFrame.size)) + + let resultingItemAlphaTransition: ComponentTransition + if !resultingItemTransition.animation.isImmediate { + resultingItemAlphaTransition = alphaTransition + } else { + resultingItemAlphaTransition = .immediate + } + resultingItemAlphaTransition.setAlpha(view: itemComponentView, alpha: itemAlpha) + } + } + } + + var removedGridItemIds: [VideoParticipantKey] = [] + for (itemId, itemView) in self.gridItemViews { + if !validGridItemIds.contains(itemId) { + removedGridItemIds.append(itemId) + + if let itemComponentView = itemView.view.view { + if !transition.animation.isImmediate { + if commonGridItemTransition.animation.isImmediate == transition.animation.isImmediate { + transition.setScale(view: itemComponentView, scale: 0.001) + } + itemComponentView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak itemComponentView] _ in + itemComponentView?.removeFromSuperview() + }) + } else { + itemComponentView.removeFromSuperview() + } + } + } + } + for itemId in removedGridItemIds { + self.gridItemViews.removeValue(forKey: itemId) + } + + var validListItemIds: [EnginePeer.Id] = [] + let visibleListItemRange = itemLayout.visibleListItemRange(for: self.scrollView.bounds) + if visibleListItemRange.maxIndex >= visibleListItemRange.minIndex { + for i in visibleListItemRange.minIndex ... visibleListItemRange.maxIndex { + let participant = self.listParticipants[i] + validListItemIds.append(participant.peer.id) + + var itemTransition = transition + let itemView: ListItem + if let current = self.listItemViews[participant.peer.id] { + itemView = current + } else { + itemTransition = itemTransition.withAnimation(.none) + itemView = ListItem() + self.listItemViews[participant.peer.id] = itemView + } + + let itemFrame = itemLayout.listItemFrame(at: i) + + let subtitle: PeerListItemComponent.Subtitle + if participant.peer.id == component.call.accountContext.account.peerId { + subtitle = PeerListItemComponent.Subtitle(text: "this is you", color: .accent) + } else if component.speakingParticipants.contains(participant.peer.id) { + subtitle = PeerListItemComponent.Subtitle(text: "speaking", color: .constructive) + } else { + subtitle = PeerListItemComponent.Subtitle(text: participant.about ?? "listening", color: .neutral) + } + + let rightAccessoryComponent: AnyComponent = AnyComponent(VideoChatParticipantStatusComponent( + muteState: participant.muteState, + hasRaiseHand: participant.hasRaiseHand, + isSpeaking: component.speakingParticipants.contains(participant.peer.id), + theme: component.theme + )) + + let _ = itemView.view.update( + transition: itemTransition, + component: AnyComponent(PeerListItemComponent( + context: component.call.accountContext, + theme: component.theme, + strings: component.strings, + style: .generic, + sideInset: 0.0, + title: EnginePeer(participant.peer).displayTitle(strings: component.strings, displayOrder: .firstLast), + avatarComponent: AnyComponent(VideoChatParticipantAvatarComponent( + call: component.call, + peer: EnginePeer(participant.peer), + isSpeaking: component.speakingParticipants.contains(participant.peer.id), + theme: component.theme + )), + peer: EnginePeer(participant.peer), + subtitle: subtitle, + subtitleAccessory: .none, + presence: nil, + rightAccessoryComponent: rightAccessoryComponent, + selectionState: .none, + hasNext: false, + extractedTheme: PeerListItemComponent.ExtractedTheme( + inset: 2.0, + background: UIColor(white: 0.1, alpha: 1.0) + ), + action: { [weak self] peer, _, itemView in + guard let self, let component = self.component else { + return + } + component.openParticipantContextMenu(peer.id, itemView.extractedContainerView, nil) + }, + contextAction: { [weak self] peer, sourceView, gesture in + guard let self, let component = self.component else { + return + } + component.openParticipantContextMenu(peer.id, sourceView, gesture) + } + )), + environment: {}, + containerSize: itemFrame.size + ) + let itemSeparatorFrame = CGRect(origin: CGPoint(x: itemFrame.minX + 63.0, y: itemFrame.maxY - UIScreenPixel), size: CGSize(width: itemFrame.width - 63.0, height: UIScreenPixel)) + if let itemComponentView = itemView.view.view { + if itemComponentView.superview == nil { + itemComponentView.clipsToBounds = true + + itemView.separatorLayer.backgroundColor = component.theme.list.itemBlocksSeparatorColor.blitOver(UIColor(white: 0.1, alpha: 1.0), alpha: 1.0).cgColor + + self.listItemViewContainer.addSubview(itemComponentView) + self.listItemViewSeparatorContainer.addSublayer(itemView.separatorLayer) + + if !transition.animation.isImmediate { + itemComponentView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15) + itemComponentView.frame = CGRect(origin: itemFrame.origin, size: CGSize(width: itemFrame.width, height: 0.0)) + + var startingItemSeparatorFrame = itemSeparatorFrame + startingItemSeparatorFrame.origin.y = itemFrame.minY - UIScreenPixel + itemView.separatorLayer.frame = startingItemSeparatorFrame + } + } + transition.setFrame(view: itemComponentView, frame: itemFrame) + transition.setFrame(layer: itemView.separatorLayer, frame: itemSeparatorFrame) + } + } + } + + var removedListItemIds: [EnginePeer.Id] = [] + for (itemId, itemView) in self.listItemViews { + if !validListItemIds.contains(itemId) { + removedListItemIds.append(itemId) + + if let itemComponentView = itemView.view.view { + let itemSeparatorLayer = itemView.separatorLayer + + if !transition.animation.isImmediate { + var itemFrame = itemComponentView.frame + itemFrame.size.height = 0.0 + transition.setFrame(view: itemComponentView, frame: itemFrame) + var itemSeparatorFrame = itemSeparatorLayer.frame + itemSeparatorFrame.origin.y = itemFrame.minY - UIScreenPixel + transition.setFrame(layer: itemSeparatorLayer, frame: itemSeparatorFrame, completion: { [weak itemSeparatorLayer] _ in + itemSeparatorLayer?.removeFromSuperlayer() + }) + itemComponentView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak itemComponentView] _ in + itemComponentView?.removeFromSuperview() + }) + } else { + itemComponentView.removeFromSuperview() + itemSeparatorLayer.removeFromSuperlayer() + } + } + } + } + for itemId in removedListItemIds { + self.listItemViews.removeValue(forKey: itemId) + } + + do { + var itemTransition = transition + let itemView = self.inviteListItemView + + let itemFrame = itemLayout.listTrailingItemFrame() + + if let itemComponentView = itemView.view { + if itemComponentView.superview == nil { + itemTransition = itemTransition.withAnimation(.none) + self.listItemViewContainer.addSubview(itemComponentView) + } + itemTransition.setFrame(view: itemComponentView, frame: itemFrame) + } + } + + transition.setScale(view: self.gridItemViewContainer, scale: gridIsEmpty ? 0.001 : 1.0) + transition.setPosition(view: self.gridItemViewContainer, position: CGPoint(x: itemLayout.gridItemContainerFrame().midX, y: itemLayout.gridItemContainerFrame().minY)) + transition.setBounds(view: self.gridItemViewContainer, bounds: CGRect(origin: CGPoint(), size: itemLayout.gridItemContainerFrame().size)) + transition.setFrame(view: self.listItemViewContainer, frame: itemLayout.listItemContainerFrame()) + transition.setFrame(layer: self.listItemViewSeparatorContainer, frame: CGRect(origin: CGPoint(), size: itemLayout.listItemContainerFrame().size)) + + if self.expandedGridItemContainer.frame != expandedGridItemContainerFrame { + self.expandedGridItemContainer.layer.cornerRadius = 10.0 + + transition.setFrame(view: self.expandedGridItemContainer, frame: expandedGridItemContainerFrame, completion: { [weak self] completed in + guard let self, completed else { + return + } + self.expandedGridItemContainer.layer.cornerRadius = 0.0 + }) + } + + if let expandedVideoState = component.expandedVideoState { + var thumbnailParticipants: [VideoChatExpandedParticipantThumbnailsComponent.Participant] = [] + for participant in self.gridParticipants { + thumbnailParticipants.append(VideoChatExpandedParticipantThumbnailsComponent.Participant( + participant: participant.participant, + isPresentation: participant.isPresentation + )) + } + /*for participant in self.listParticipants { + thumbnailParticipants.append(VideoChatExpandedParticipantThumbnailsComponent.Participant( + participant: participant, + isPresentation: false + )) + }*/ + + let expandedControlsAlpha: CGFloat = expandedVideoState.isUIHidden ? 0.0 : 1.0 + let expandedThumbnailsAlpha: CGFloat = expandedControlsAlpha + /*if itemLayout.layout.videoColumn == nil { + if expandedVideoState.isUIHidden { + expandedThumbnailsAlpha = 0.0 + } else { + expandedThumbnailsAlpha = 1.0 + } + } else { + expandedThumbnailsAlpha = 0.0 + }*/ + + var expandedThumbnailsTransition = transition + let expandedThumbnailsView: ComponentView + if let current = self.expandedThumbnailsView { + expandedThumbnailsView = current + } else { + expandedThumbnailsTransition = expandedThumbnailsTransition.withAnimation(.none) + expandedThumbnailsView = ComponentView() + self.expandedThumbnailsView = expandedThumbnailsView + } + let expandedThumbnailsSize = expandedThumbnailsView.update( + transition: expandedThumbnailsTransition, + component: AnyComponent(VideoChatExpandedParticipantThumbnailsComponent( + call: component.call, + theme: component.theme, + participants: thumbnailParticipants, + selectedParticipant: component.expandedVideoState.flatMap { expandedVideoState in + return VideoChatExpandedParticipantThumbnailsComponent.Participant.Key(id: expandedVideoState.mainParticipant.id, isPresentation: expandedVideoState.mainParticipant.isPresentation) + }, + speakingParticipants: component.speakingParticipants, + updateSelectedParticipant: { [weak self] key in + guard let self, let component = self.component else { + return + } + component.updateMainParticipant(VideoParticipantKey(id: key.id, isPresentation: key.isPresentation)) + } + )), + environment: {}, + containerSize: itemLayout.expandedGrid.itemContainerFrame().size + ) + let expandedThumbnailsFrame = CGRect(origin: CGPoint(x: 0.0, y: expandedGridItemContainerFrame.height - expandedThumbnailsSize.height), size: expandedThumbnailsSize) + if let expandedThumbnailsComponentView = expandedThumbnailsView.view { + if expandedThumbnailsComponentView.superview == nil { + self.expandedGridItemContainer.addSubview(expandedThumbnailsComponentView) + expandedThumbnailsComponentView.alpha = expandedThumbnailsAlpha + + let fromReferenceFrame: CGRect + if let index = self.gridParticipants.firstIndex(where: { $0.participant.peer.id == expandedVideoState.mainParticipant.id && $0.isPresentation == expandedVideoState.mainParticipant.isPresentation }) { + fromReferenceFrame = self.gridItemViewContainer.convert(itemLayout.gridItemFrame(at: index), to: self.expandedGridItemContainer) + } else { + fromReferenceFrame = previousExpandedGridItemContainerFrame + } + + expandedThumbnailsComponentView.frame = CGRect(origin: CGPoint(x: fromReferenceFrame.minX - previousExpandedGridItemContainerFrame.minX, y: fromReferenceFrame.maxY - expandedThumbnailsSize.height), size: expandedThumbnailsFrame.size) + + if !transition.animation.isImmediate && expandedThumbnailsAlpha != 0.0 { + expandedThumbnailsComponentView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15) + } + } + transition.setFrame(view: expandedThumbnailsComponentView, frame: expandedThumbnailsFrame) + alphaTransition.setAlpha(view: expandedThumbnailsComponentView, alpha: expandedThumbnailsAlpha) + } + + var expandedControlsTransition = transition + let expandedControlsView: ComponentView + if let current = self.expandedControlsView { + expandedControlsView = current + } else { + expandedControlsTransition = expandedControlsTransition.withAnimation(.none) + expandedControlsView = ComponentView() + self.expandedControlsView = expandedControlsView + } + let expandedControlsSize = expandedControlsView.update( + transition: expandedControlsTransition, + component: AnyComponent(VideoChatExpandedControlsComponent( + theme: component.theme, + strings: component.strings, + isPinned: expandedVideoState.isMainParticipantPinned, + backAction: { [weak self] in + guard let self, let component = self.component else { + return + } + component.updateMainParticipant(nil) + }, + pinAction: { [weak self] in + guard let self, let component = self.component else { + return + } + guard let expandedVideoState = component.expandedVideoState else { + return + } + + component.updateIsMainParticipantPinned(!expandedVideoState.isMainParticipantPinned) + } + )), + environment: {}, + containerSize: itemLayout.expandedGrid.itemContainerFrame().size + ) + let expandedControlsFrame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: expandedControlsSize) + if let expandedControlsComponentView = expandedControlsView.view { + if expandedControlsComponentView.superview == nil { + self.expandedGridItemContainer.addSubview(expandedControlsComponentView) + + expandedControlsComponentView.alpha = expandedControlsAlpha + + let fromReferenceFrame: CGRect + if let index = self.gridParticipants.firstIndex(where: { $0.participant.peer.id == expandedVideoState.mainParticipant.id && $0.isPresentation == expandedVideoState.mainParticipant.isPresentation }) { + fromReferenceFrame = self.gridItemViewContainer.convert(itemLayout.gridItemFrame(at: index), to: self.expandedGridItemContainer) + } else { + fromReferenceFrame = previousExpandedGridItemContainerFrame + } + + expandedControlsComponentView.frame = CGRect(origin: CGPoint(x: fromReferenceFrame.minX - previousExpandedGridItemContainerFrame.minX, y: fromReferenceFrame.minY - previousExpandedGridItemContainerFrame.minY), size: expandedControlsFrame.size) + + if !transition.animation.isImmediate && expandedControlsAlpha != 0.0 { + expandedControlsComponentView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15) + } + } + transition.setFrame(view: expandedControlsComponentView, frame: expandedControlsFrame) + alphaTransition.setAlpha(view: expandedControlsComponentView, alpha: expandedControlsAlpha) + } + } else { + if let expandedThumbnailsView = self.expandedThumbnailsView { + self.expandedThumbnailsView = nil + + if transition.containedViewLayoutTransition.isAnimated, let expandedThumbnailsComponentView = expandedThumbnailsView.view { + if let collapsingItemView = self.gridItemViews.values.first(where: { $0.isCollapsing }), let index = self.gridParticipants.firstIndex(where: { $0.participant.peer.id == collapsingItemView.key.id && $0.isPresentation == collapsingItemView.key.isPresentation }) { + let targetLocalItemFrame = itemLayout.gridItemFrame(at: index) + var targetItemFrame = self.gridItemViewContainer.convert(targetLocalItemFrame, to: self) + targetItemFrame.origin.y -= expandedGridItemContainerFrame.minY + targetItemFrame.origin.x -= expandedGridItemContainerFrame.minX + + let targetThumbnailsFrame = CGRect(origin: CGPoint(x: targetItemFrame.minX, y: targetItemFrame.maxY - expandedThumbnailsComponentView.bounds.height), size: expandedThumbnailsComponentView.bounds.size) + transition.setFrame(view: expandedThumbnailsComponentView, frame: targetThumbnailsFrame) + } + expandedThumbnailsComponentView.layer.animateAlpha(from: expandedThumbnailsComponentView.alpha, to: 0.0, duration: 0.12, removeOnCompletion: false, completion: { [weak expandedThumbnailsComponentView] _ in + expandedThumbnailsComponentView?.removeFromSuperview() + }) + } else { + expandedThumbnailsView.view?.removeFromSuperview() + } + } + + if let expandedControlsView = self.expandedControlsView { + self.expandedControlsView = nil + + if transition.containedViewLayoutTransition.isAnimated, let expandedControlsComponentView = expandedControlsView.view { + if let collapsingItemView = self.gridItemViews.values.first(where: { $0.isCollapsing }), let index = self.gridParticipants.firstIndex(where: { $0.participant.peer.id == collapsingItemView.key.id && $0.isPresentation == collapsingItemView.key.isPresentation }) { + let targetLocalItemFrame = itemLayout.gridItemFrame(at: index) + var targetItemFrame = self.gridItemViewContainer.convert(targetLocalItemFrame, to: self) + targetItemFrame.origin.y -= expandedGridItemContainerFrame.minY + targetItemFrame.origin.x -= expandedGridItemContainerFrame.minX + + let targetThumbnailsFrame = CGRect(origin: CGPoint(x: targetItemFrame.minX, y: targetItemFrame.minY), size: expandedControlsComponentView.bounds.size) + transition.setFrame(view: expandedControlsComponentView, frame: targetThumbnailsFrame) + } + expandedControlsComponentView.layer.animateAlpha(from: expandedControlsComponentView.alpha, to: 0.0, duration: 0.12, removeOnCompletion: false, completion: { [weak expandedControlsComponentView] _ in + expandedControlsComponentView?.removeFromSuperview() + }) + } else { + expandedControlsView.view?.removeFromSuperview() + } + } + } + } + + func update(component: VideoChatParticipantsComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + self.isUpdating = true + defer { + self.isUpdating = false + } + + self.component = component + self.state = state + + let measureListItemSize = self.measureListItemView.update( + transition: .immediate, + component: AnyComponent(PeerListItemComponent( + context: component.call.accountContext, + theme: component.theme, + strings: component.strings, + style: .generic, + sideInset: 0.0, + title: "AAA", + peer: nil, + subtitle: PeerListItemComponent.Subtitle(text: "bbb", color: .neutral), + subtitleAccessory: .none, + presence: nil, + selectionState: .none, + hasNext: true, + action: { _, _, _ in + } + )), + environment: {}, + containerSize: CGSize(width: availableSize.width, height: 1000.0) + ) + + let inviteText: String + if let participants = component.participants, let inviteType = participants.inviteType { + switch inviteType { + case .invite: + inviteText = "Invite Members" + case .shareLink: + inviteText = "Share Invite Link" + } + } else { + inviteText = "Invite Members" + } + let inviteListItemSize = self.inviteListItemView.update( + transition: transition, + component: AnyComponent(VideoChatListInviteComponent( + title: inviteText, + theme: component.theme, + action: { [weak self] in + guard let self, let component = self.component else { + return + } + component.openInviteMembers() + } + )), + environment: {}, + containerSize: CGSize(width: availableSize.width, height: 1000.0) + ) + + var gridParticipants: [VideoParticipant] = [] + var listParticipants: [GroupCallParticipantsContext.Participant] = [] + if let participants = component.participants { + for participant in participants.participants { + var hasVideo = false + if participant.videoDescription != nil { + hasVideo = true + let videoParticipant = VideoParticipant(participant: participant, isPresentation: false) + if participant.peer.id == component.call.accountContext.account.peerId || participant.peer.id == participants.myPeerId { + gridParticipants.insert(videoParticipant, at: 0) + } else { + gridParticipants.append(videoParticipant) + } + } + if participant.presentationDescription != nil { + hasVideo = true + let videoParticipant = VideoParticipant(participant: participant, isPresentation: true) + if participant.peer.id == component.call.accountContext.account.peerId { + gridParticipants.insert(videoParticipant, at: 0) + } else { + gridParticipants.append(videoParticipant) + } + } + if !hasVideo || component.layout.videoColumn != nil { + if participant.peer.id == component.call.accountContext.account.peerId { + listParticipants.insert(participant, at: 0) + } else { + listParticipants.append(participant) + } + } + } + } + self.gridParticipants = gridParticipants + self.listParticipants = listParticipants + + let itemLayout = ItemLayout( + containerSize: availableSize, + layout: component.layout, + isUIHidden: component.expandedVideoState?.isUIHidden ?? false, + expandedInsets: component.expandedInsets, + safeInsets: component.safeInsets, + gridItemCount: gridParticipants.count, + listItemCount: listParticipants.count, + listItemHeight: measureListItemSize.height, + listTrailingItemHeight: inviteListItemSize.height + ) + self.itemLayout = itemLayout + + let listItemsBackgroundSize = self.listItemsBackground.update( + transition: transition, + component: AnyComponent(RoundedRectangle( + color: UIColor(white: 0.1, alpha: 1.0), + cornerRadius: 10.0 + )), + environment: {}, + containerSize: CGSize(width: itemLayout.listFrame.width - itemLayout.layout.mainColumn.insets.left - itemLayout.layout.mainColumn.insets.right, height: itemLayout.list.contentHeight()) + ) + let listItemsBackgroundFrame = CGRect(origin: CGPoint(x: itemLayout.layout.mainColumn.insets.left, y: 0.0), size: listItemsBackgroundSize) + if let listItemsBackgroundView = self.listItemsBackground.view { + if listItemsBackgroundView.superview == nil { + self.listItemViewContainer.addSubview(listItemsBackgroundView) + self.listItemViewContainer.layer.addSublayer(self.listItemViewSeparatorContainer) + } + transition.setFrame(view: listItemsBackgroundView, frame: listItemsBackgroundFrame) + } + + var requestedVideo: [PresentationGroupCallRequestedVideo] = [] + if let participants = component.participants { + for participant in participants.participants { + var maxVideoQuality: PresentationGroupCallRequestedVideo.Quality = .medium + if let expandedVideoState = component.expandedVideoState { + if expandedVideoState.mainParticipant.id == participant.peer.id, !expandedVideoState.mainParticipant.isPresentation { + maxVideoQuality = .full + } else { + maxVideoQuality = .thumbnail + } + } + + var maxPresentationQuality: PresentationGroupCallRequestedVideo.Quality = .medium + if let expandedVideoState = component.expandedVideoState { + if expandedVideoState.mainParticipant.id == participant.peer.id, expandedVideoState.mainParticipant.isPresentation { + maxPresentationQuality = .full + } else { + maxPresentationQuality = .thumbnail + } + } + + if component.layout.videoColumn != nil && gridParticipants.count == 1 { + maxVideoQuality = .full + maxPresentationQuality = .full + } + + if let videoChannel = participant.requestedVideoChannel(minQuality: .thumbnail, maxQuality: maxVideoQuality) { + if !requestedVideo.contains(videoChannel) { + requestedVideo.append(videoChannel) + } + } + if let videoChannel = participant.requestedPresentationVideoChannel(minQuality: .thumbnail, maxQuality: maxPresentationQuality) { + if !requestedVideo.contains(videoChannel) { + requestedVideo.append(videoChannel) + } + } + } + } + (component.call as! PresentationGroupCallImpl).setRequestedVideoList(items: requestedVideo) + + transition.setPosition(view: self.scrollViewClippingContainer, position: itemLayout.scrollClippingFrame.center) + transition.setBounds(view: self.scrollViewClippingContainer, bounds: CGRect(origin: CGPoint(x: itemLayout.scrollClippingFrame.minX - itemLayout.listFrame.minX, y: itemLayout.scrollClippingFrame.minY - itemLayout.listFrame.minY), size: itemLayout.scrollClippingFrame.size)) + transition.setFrame(view: self.scrollViewClippingContainer.cornersView, frame: itemLayout.scrollClippingFrame) + self.scrollViewClippingContainer.update(params: SolidRoundedCornersContainer.Params( + size: itemLayout.scrollClippingFrame.size, + color: .black, + cornerRadius: 10.0, + smoothCorners: false + ), transition: transition) + + transition.setPosition(view: self.separateVideoScrollViewClippingContainer, position: itemLayout.separateVideoScrollClippingFrame.center) + transition.setBounds(view: self.separateVideoScrollViewClippingContainer, bounds: CGRect(origin: CGPoint(x: itemLayout.separateVideoScrollClippingFrame.minX - itemLayout.separateVideoGridFrame.minX, y: itemLayout.separateVideoScrollClippingFrame.minY - itemLayout.separateVideoGridFrame.minY), size: itemLayout.separateVideoScrollClippingFrame.size)) + transition.setFrame(view: self.separateVideoScrollViewClippingContainer.cornersView, frame: itemLayout.separateVideoScrollClippingFrame) + self.separateVideoScrollViewClippingContainer.update(params: SolidRoundedCornersContainer.Params( + size: itemLayout.separateVideoScrollClippingFrame.size, + color: .black, + cornerRadius: 10.0, + smoothCorners: false + ), transition: transition) + + self.ignoreScrolling = true + if self.scrollView.bounds.size != itemLayout.listFrame.size { + transition.setFrame(view: self.scrollView, frame: CGRect(origin: CGPoint(), size: itemLayout.listFrame.size)) + } + let contentSize = CGSize(width: itemLayout.listFrame.width, height: itemLayout.contentHeight()) + if self.scrollView.contentSize != contentSize { + self.scrollView.contentSize = contentSize + } + + if self.separateVideoScrollView.bounds.size != itemLayout.separateVideoGridFrame.size { + transition.setFrame(view: self.separateVideoScrollView, frame: CGRect(origin: CGPoint(), size: itemLayout.separateVideoGridFrame.size)) + } + let separateVideoContentSize = CGSize(width: itemLayout.separateVideoGridFrame.width, height: itemLayout.separateVideoGridContentHeight()) + if self.separateVideoScrollView.contentSize != separateVideoContentSize { + self.separateVideoScrollView.contentSize = separateVideoContentSize + } + + self.ignoreScrolling = false + + if itemLayout.layout.videoColumn == nil { + if self.gridItemViewContainer.superview !== self.scrollView { + self.scrollView.addSubview(self.gridItemViewContainer) + } + } else { + if self.gridItemViewContainer.superview !== self.separateVideoScrollView { + self.separateVideoScrollView.addSubview(self.gridItemViewContainer) + } + } + + self.updateScrolling(transition: transition) + + return availableSize + } + } + + func makeView() -> View { + return View() + } + + func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } +} diff --git a/submodules/TelegramCallsUI/Sources/VideoChatPinStatusComponent.swift b/submodules/TelegramCallsUI/Sources/VideoChatPinStatusComponent.swift new file mode 100644 index 00000000000..68645fed164 --- /dev/null +++ b/submodules/TelegramCallsUI/Sources/VideoChatPinStatusComponent.swift @@ -0,0 +1,99 @@ +import Foundation +import UIKit +import Display +import ComponentFlow +import TelegramPresentationData +import ComponentDisplayAdapters + +final class VideoChatPinStatusComponent: Component { + let theme: PresentationTheme + let strings: PresentationStrings + let isPinned: Bool + let action: () -> Void + + init( + theme: PresentationTheme, + strings: PresentationStrings, + isPinned: Bool, + action: @escaping () -> Void + ) { + self.theme = theme + self.strings = strings + self.isPinned = isPinned + self.action = action + } + + static func ==(lhs: VideoChatPinStatusComponent, rhs: VideoChatPinStatusComponent) -> Bool { + if lhs.theme !== rhs.theme { + return false + } + if lhs.strings !== rhs.strings { + return false + } + if lhs.isPinned != rhs.isPinned { + return false + } + return true + } + + final class View: UIView { + private var pinNode: VoiceChatPinButtonNode? + + private var component: VideoChatPinStatusComponent? + private var isUpdating: Bool = false + + override init(frame: CGRect) { + super.init(frame: frame) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + } + + @objc private func pinPressed() { + guard let component = self.component else { + return + } + component.action() + } + + func update(component: VideoChatPinStatusComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + self.isUpdating = true + defer { + self.isUpdating = false + } + + self.component = component + + let pinNode: VoiceChatPinButtonNode + if let current = self.pinNode { + pinNode = current + } else { + pinNode = VoiceChatPinButtonNode(theme: component.theme, strings: component.strings) + self.pinNode = pinNode + self.addSubview(pinNode.view) + pinNode.addTarget(self, action: #selector(self.pinPressed), forControlEvents: .touchUpInside) + } + let pinNodeSize = pinNode.update(size: availableSize, transition: transition.containedViewLayoutTransition) + let pinNodeFrame = CGRect(origin: CGPoint(), size: pinNodeSize) + transition.setFrame(view: pinNode.view, frame: pinNodeFrame) + + pinNode.update(pinned: component.isPinned, animated: !transition.animation.isImmediate) + + let size = pinNodeSize + + return size + } + } + + func makeView() -> View { + return View() + } + + func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } +} diff --git a/submodules/TelegramCallsUI/Sources/VideoChatScreen.swift b/submodules/TelegramCallsUI/Sources/VideoChatScreen.swift new file mode 100644 index 00000000000..da973d873d6 --- /dev/null +++ b/submodules/TelegramCallsUI/Sources/VideoChatScreen.swift @@ -0,0 +1,3098 @@ +import Foundation +import AVFoundation +import UIKit +import AsyncDisplayKit +import Display +import ComponentFlow +import ViewControllerComponent +import Postbox +import TelegramCore +import AccountContext +import PlainButtonComponent +import SwiftSignalKit +import LottieComponent +import BundleIconComponent +import ContextUI +import TelegramPresentationData +import DeviceAccess +import TelegramVoip +import PresentationDataUtils +import UndoUI +import ShareController +import AvatarNode +import TelegramAudio + +import PeerInfoUI + +import DeleteChatPeerActionSheetItem +import PeerListItemComponent +import LegacyComponents +import LegacyUI +import WebSearchUI +import MapResourceToAvatarSizes +import LegacyMediaPickerUI + +private final class VideoChatScreenComponent: Component { + typealias EnvironmentType = ViewControllerComponentContainer.Environment + + let initialData: VideoChatScreenV2Impl.InitialData + let call: PresentationGroupCall + + init( + initialData: VideoChatScreenV2Impl.InitialData, + call: PresentationGroupCall + ) { + self.initialData = initialData + self.call = call + } + + static func ==(lhs: VideoChatScreenComponent, rhs: VideoChatScreenComponent) -> Bool { + return true + } + + private struct PanGestureState { + var offsetFraction: CGFloat + + init(offsetFraction: CGFloat) { + self.offsetFraction = offsetFraction + } + } + + final class View: UIView { + private let containerView: UIView + + private var component: VideoChatScreenComponent? + private var environment: ViewControllerComponentContainer.Environment? + private weak var state: EmptyComponentState? + private var isUpdating: Bool = false + + private var panGestureState: PanGestureState? + private var notifyDismissedInteractivelyOnPanGestureApply: Bool = false + private var completionOnPanGestureApply: (() -> Void)? + + private let videoRenderingContext = VideoRenderingContext() + + private let title = ComponentView() + private let navigationLeftButton = ComponentView() + private let navigationRightButton = ComponentView() + private var navigationSidebarButton: ComponentView? + + private let videoButton = ComponentView() + private let leaveButton = ComponentView() + private let microphoneButton = ComponentView() + + private let participants = ComponentView() + + private var reconnectedAsEventsDisposable: Disposable? + + private var peer: EnginePeer? + private var callState: PresentationGroupCallState? + private var stateDisposable: Disposable? + + private var audioOutputState: ([AudioSessionOutput], AudioSessionOutput?)? + private var audioOutputStateDisposable: Disposable? + + private var displayAsPeers: [FoundPeer]? + private var displayAsPeersDisposable: Disposable? + + private var inviteLinks: GroupCallInviteLinks? + private var inviteLinksDisposable: Disposable? + + private var isPushToTalkActive: Bool = false + + private var members: PresentationGroupCallMembers? + private var membersDisposable: Disposable? + + private let isPresentedValue = ValuePromise(false, ignoreRepeated: true) + private var applicationStateDisposable: Disposable? + + private var expandedParticipantsVideoState: VideoChatParticipantsComponent.ExpandedVideoState? + + private let inviteDisposable = MetaDisposable() + private let currentAvatarMixin = Atomic(value: nil) + private let updateAvatarDisposable = MetaDisposable() + private var currentUpdatingAvatar: (TelegramMediaImageRepresentation, Float)? + + override init(frame: CGRect) { + self.containerView = UIView() + self.containerView.clipsToBounds = true + + super.init(frame: frame) + + self.backgroundColor = nil + self.isOpaque = false + + self.addSubview(self.containerView) + + self.addGestureRecognizer(UIPanGestureRecognizer(target: self, action: #selector(self.panGesture(_:)))) + + self.panGestureState = PanGestureState(offsetFraction: 1.0) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + self.stateDisposable?.dispose() + self.membersDisposable?.dispose() + self.applicationStateDisposable?.dispose() + self.reconnectedAsEventsDisposable?.dispose() + self.displayAsPeersDisposable?.dispose() + self.audioOutputStateDisposable?.dispose() + self.inviteLinksDisposable?.dispose() + self.updateAvatarDisposable.dispose() + self.inviteDisposable.dispose() + } + + func animateIn() { + self.panGestureState = PanGestureState(offsetFraction: 1.0) + self.state?.updated(transition: .immediate) + + self.panGestureState = nil + self.state?.updated(transition: .spring(duration: 0.5)) + } + + func animateOut(completion: @escaping () -> Void) { + self.panGestureState = PanGestureState(offsetFraction: 1.0) + self.completionOnPanGestureApply = completion + self.state?.updated(transition: .spring(duration: 0.5)) + } + + @objc private func panGesture(_ recognizer: UIPanGestureRecognizer) { + switch recognizer.state { + case .began, .changed: + if !self.bounds.height.isZero && !self.notifyDismissedInteractivelyOnPanGestureApply { + let translation = recognizer.translation(in: self) + self.panGestureState = PanGestureState(offsetFraction: translation.y / self.bounds.height) + self.state?.updated(transition: .immediate) + } + case .cancelled, .ended: + if !self.bounds.height.isZero { + let translation = recognizer.translation(in: self) + let panGestureState = PanGestureState(offsetFraction: translation.y / self.bounds.height) + + let velocity = recognizer.velocity(in: self) + + self.panGestureState = nil + if abs(panGestureState.offsetFraction) > 0.6 || abs(velocity.y) >= 100.0 { + self.panGestureState = PanGestureState(offsetFraction: panGestureState.offsetFraction < 0.0 ? -1.0 : 1.0) + self.notifyDismissedInteractivelyOnPanGestureApply = true + if let controller = self.environment?.controller() as? VideoChatScreenV2Impl { + controller.notifyDismissed() + } + } + + self.state?.updated(transition: .spring(duration: 0.4)) + } + default: + break + } + } + + private func openMoreMenu() { + guard let sourceView = self.navigationLeftButton.view else { + return + } + guard let component = self.component, let environment = self.environment, let controller = environment.controller() else { + return + } + guard let peer = self.peer else { + return + } + guard let callState = self.callState else { + return + } + + let canManageCall = callState.canManageCall + + var items: [ContextMenuItem] = [] + + if let displayAsPeers = self.displayAsPeers, displayAsPeers.count > 1 { + for peer in displayAsPeers { + if peer.peer.id == callState.myPeerId { + let avatarSize = CGSize(width: 28.0, height: 28.0) + items.append(.action(ContextMenuActionItem(text: environment.strings.VoiceChat_DisplayAs, textLayout: .secondLineWithValue(EnginePeer(peer.peer).displayTitle(strings: environment.strings, displayOrder: component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).nameDisplayOrder)), icon: { _ in nil }, iconSource: ContextMenuActionItemIconSource(size: avatarSize, signal: peerAvatarCompleteImage(account: component.call.accountContext.account, peer: EnginePeer(peer.peer), size: avatarSize)), action: { [weak self] c, _ in + guard let self else { + return + } + c?.pushItems(items: .single(ContextController.Items(content: .list(self.contextMenuDisplayAsItems())))) + }))) + items.append(.separator) + break + } + } + } + + if let (availableOutputs, currentOutput) = self.audioOutputState, availableOutputs.count > 1 { + var currentOutputTitle = "" + for output in availableOutputs { + if output == currentOutput { + let title: String + switch output { + case .builtin: + title = UIDevice.current.model + case .speaker: + title = environment.strings.Call_AudioRouteSpeaker + case .headphones: + title = environment.strings.Call_AudioRouteHeadphones + case let .port(port): + title = port.name + } + currentOutputTitle = title + break + } + } + items.append(.action(ContextMenuActionItem(text: environment.strings.VoiceChat_ContextAudio, textLayout: .secondLineWithValue(currentOutputTitle), icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Call/Context Menu/Audio"), color: theme.actionSheet.primaryTextColor) + }, action: { [weak self] c, _ in + guard let self else { + return + } + c?.pushItems(items: .single(ContextController.Items(content: .list(self.contextMenuAudioItems())))) + }))) + } + + if canManageCall { + let text: String + if case let .channel(channel) = peer, case .broadcast = channel.info { + text = environment.strings.LiveStream_EditTitle + } else { + text = environment.strings.VoiceChat_EditTitle + } + items.append(.action(ContextMenuActionItem(text: text, icon: { theme -> UIImage? in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Pencil"), color: theme.actionSheet.primaryTextColor) + }, action: { [weak self] _, f in + f(.default) + + guard let self else { + return + } + self.openTitleEditing() + }))) + + var hasPermissions = true + if case let .channel(chatPeer) = peer { + if case .broadcast = chatPeer.info { + hasPermissions = false + } else if chatPeer.flags.contains(.isGigagroup) { + hasPermissions = false + } + } + if hasPermissions { + items.append(.action(ContextMenuActionItem(text: environment.strings.VoiceChat_EditPermissions, icon: { theme -> UIImage? in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Restrict"), color: theme.actionSheet.primaryTextColor) + }, action: { [weak self] c, _ in + guard let self else { + return + } + c?.pushItems(items: .single(ContextController.Items(content: .list(self.contextMenuPermissionItems())))) + }))) + } + } + + if let inviteLinks = self.inviteLinks { + items.append(.action(ContextMenuActionItem(text: environment.strings.VoiceChat_Share, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Link"), color: theme.actionSheet.primaryTextColor) + }, action: { [weak self] _, f in + f(.default) + + guard let self else { + return + } + self.presentShare(inviteLinks) + }))) + } + + //let isScheduled = strongSelf.isScheduled + //TODO:release + let isScheduled: Bool = !"".isEmpty + + let canSpeak: Bool + if let muteState = callState.muteState { + canSpeak = muteState.canUnmute + } else { + canSpeak = true + } + + if !isScheduled && canSpeak { + if #available(iOS 15.0, *) { + items.append(.action(ContextMenuActionItem(text: environment.strings.VoiceChat_MicrophoneModes, textColor: .primary, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Call/Context Menu/Noise"), color: theme.actionSheet.primaryTextColor) + }, action: { _, f in + f(.dismissWithoutContent) + AVCaptureDevice.showSystemUserInterface(.microphoneModes) + }))) + } + } + + if callState.isVideoEnabled && (callState.muteState?.canUnmute ?? true) { + if component.call.hasScreencast { + items.append(.action(ContextMenuActionItem(text: environment.strings.VoiceChat_StopScreenSharing, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Call/Context Menu/ShareScreen"), color: theme.actionSheet.primaryTextColor) + }, action: { [weak self] _, f in + f(.default) + + guard let self, let component = self.component else { + return + } + component.call.disableScreencast() + }))) + } else { + items.append(.custom(VoiceChatShareScreenContextItem(context: component.call.accountContext, text: environment.strings.VoiceChat_ShareScreen, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Call/Context Menu/ShareScreen"), color: theme.actionSheet.primaryTextColor) + }, action: { _, _ in }), false)) + } + } + + if canManageCall { + if let recordingStartTimestamp = callState.recordingStartTimestamp { + items.append(.custom(VoiceChatRecordingContextItem(timestamp: recordingStartTimestamp, action: { [weak self] _, f in + f(.dismissWithoutContent) + + guard let self, let component = self.component, let environment = self.environment else { + return + } + + let alertController = textAlertController(context: component.call.accountContext, forceTheme: environment.theme, title: nil, text: environment.strings.VoiceChat_StopRecordingTitle, actions: [TextAlertAction(type: .genericAction, title: environment.strings.Common_Cancel, action: {}), TextAlertAction(type: .defaultAction, title: environment.strings.VoiceChat_StopRecordingStop, action: { [weak self] in + guard let self, let component = self.component, let environment = self.environment else { + return + } + component.call.setShouldBeRecording(false, title: nil, videoOrientation: nil) + + Queue.mainQueue().after(0.88) { + HapticFeedback().success() + } + + let text: String + if case let .channel(channel) = self.peer, case .broadcast = channel.info { + text = environment.strings.LiveStream_RecordingSaved + } else { + text = environment.strings.VideoChat_RecordingSaved + } + self.presentUndoOverlay(content: .forward(savedMessages: true, text: text), action: { [weak self] value in + if case .info = value, let self, let component = self.component, let environment = self.environment, let navigationController = environment.controller()?.navigationController as? NavigationController { + let context = component.call.accountContext + environment.controller()?.dismiss(completion: { [weak navigationController] in + Queue.mainQueue().justDispatch { + let _ = (context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: context.account.peerId)) + |> deliverOnMainQueue).start(next: { peer in + guard let peer, let navigationController else { + return + } + context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: context, chatLocation: .peer(peer), keepStack: .always, purposefulAction: {}, peekData: nil)) + }) + } + }) + + return true + } + return false + }) + })]) + environment.controller()?.present(alertController, in: .window(.root)) + }), false)) + } else { + let text: String + if case let .channel(channel) = peer, case .broadcast = channel.info { + text = environment.strings.LiveStream_StartRecording + } else { + text = environment.strings.VoiceChat_StartRecording + } + if callState.scheduleTimestamp == nil { + items.append(.action(ContextMenuActionItem(text: text, icon: { theme -> UIImage? in + return generateStartRecordingIcon(color: theme.actionSheet.primaryTextColor) + }, action: { [weak self] _, f in + f(.dismissWithoutContent) + + guard let self, let component = self.component, let environment = self.environment, let peer = self.peer else { + return + } + + let controller = VoiceChatRecordingSetupController(context: component.call.accountContext, peer: peer, completion: { [weak self] videoOrientation in + guard let self, let component = self.component, let environment = self.environment, let peer = self.peer else { + return + } + let title: String + let text: String + let placeholder: String + if let _ = videoOrientation { + placeholder = environment.strings.VoiceChat_RecordingTitlePlaceholderVideo + } else { + placeholder = environment.strings.VoiceChat_RecordingTitlePlaceholder + } + if case let .channel(channel) = peer, case .broadcast = channel.info { + title = environment.strings.LiveStream_StartRecordingTitle + if let _ = videoOrientation { + text = environment.strings.LiveStream_StartRecordingTextVideo + } else { + text = environment.strings.LiveStream_StartRecordingText + } + } else { + title = environment.strings.VoiceChat_StartRecordingTitle + if let _ = videoOrientation { + text = environment.strings.VoiceChat_StartRecordingTextVideo + } else { + text = environment.strings.VoiceChat_StartRecordingText + } + } + + let controller = voiceChatTitleEditController(sharedContext: component.call.accountContext.sharedContext, account: component.call.account, forceTheme: environment.theme, title: title, text: text, placeholder: placeholder, value: nil, maxLength: 40, apply: { [weak self] title in + guard let self, let component = self.component, let environment = self.environment, let peer = self.peer, let title else { + return + } + + component.call.setShouldBeRecording(true, title: title, videoOrientation: videoOrientation) + + let text: String + if case let .channel(channel) = peer, case .broadcast = channel.info { + text = environment.strings.LiveStream_RecordingStarted + } else { + text = environment.strings.VoiceChat_RecordingStarted + } + + self.presentUndoOverlay(content: .voiceChatRecording(text: text), action: { _ in return false }) + component.call.playTone(.recordingStarted) + }) + environment.controller()?.present(controller, in: .window(.root)) + }) + environment.controller()?.present(controller, in: .window(.root)) + }))) + } + } + } + + if canManageCall { + let text: String + if case let .channel(channel) = peer, case .broadcast = channel.info { + text = isScheduled ? environment.strings.VoiceChat_CancelLiveStream : environment.strings.VoiceChat_EndLiveStream + } else { + text = isScheduled ? environment.strings.VoiceChat_CancelVoiceChat : environment.strings.VoiceChat_EndVoiceChat + } + items.append(.action(ContextMenuActionItem(text: text, textColor: .destructive, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Clear"), color: theme.actionSheet.destructiveActionTextColor) + }, action: { [weak self] _, f in + f(.dismissWithoutContent) + + guard let self, let component = self.component, let environment = self.environment else { + return + } + + let action: () -> Void = { [weak self] in + guard let self, let component = self.component else { + return + } + + let _ = (component.call.leave(terminateIfPossible: true) + |> filter { $0 } + |> take(1) + |> deliverOnMainQueue).start(completed: { [weak self] in + guard let self, let environment = self.environment else { + return + } + environment.controller()?.dismiss() + }) + } + + let title: String + let text: String + if case let .channel(channel) = self.peer, case .broadcast = channel.info { + title = isScheduled ? environment.strings.LiveStream_CancelConfirmationTitle : environment.strings.LiveStream_EndConfirmationTitle + text = isScheduled ? environment.strings.LiveStream_CancelConfirmationText : environment.strings.LiveStream_EndConfirmationText + } else { + title = isScheduled ? environment.strings.VoiceChat_CancelConfirmationTitle : environment.strings.VoiceChat_EndConfirmationTitle + text = isScheduled ? environment.strings.VoiceChat_CancelConfirmationText : environment.strings.VoiceChat_EndConfirmationText + } + + let alertController = textAlertController(context: component.call.accountContext, forceTheme: environment.theme, title: title, text: text, actions: [TextAlertAction(type: .defaultAction, title: environment.strings.Common_Cancel, action: {}), TextAlertAction(type: .genericAction, title: isScheduled ? environment.strings.VoiceChat_CancelConfirmationEnd : environment.strings.VoiceChat_EndConfirmationEnd, action: { + action() + })]) + environment.controller()?.present(alertController, in: .window(.root)) + }))) + } else { + let leaveText: String + if case let .channel(channel) = peer, case .broadcast = channel.info { + leaveText = environment.strings.LiveStream_LeaveVoiceChat + } else { + leaveText = environment.strings.VoiceChat_LeaveVoiceChat + } + items.append(.action(ContextMenuActionItem(text: leaveText, textColor: .destructive, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Clear"), color: theme.actionSheet.destructiveActionTextColor) + }, action: { [weak self] _, f in + f(.dismissWithoutContent) + + guard let self, let component = self.component else { + return + } + + let _ = (component.call.leave(terminateIfPossible: false) + |> filter { $0 } + |> take(1) + |> deliverOnMainQueue).start(completed: { [weak self] in + guard let self, let environment = self.environment else { + return + } + environment.controller()?.dismiss() + }) + }))) + } + + let presentationData = component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: environment.theme) + let contextController = ContextController(presentationData: presentationData, source: .reference(VoiceChatContextReferenceContentSource(controller: controller, sourceView: sourceView)), items: .single(ContextController.Items(content: .list(items))), gesture: nil) + controller.presentInGlobalOverlay(contextController) + } + + private func contextMenuDisplayAsItems() -> [ContextMenuItem] { + guard let component = self.component, let environment = self.environment else { + return [] + } + guard let callState = self.callState else { + return [] + } + let myPeerId = callState.myPeerId + + let avatarSize = CGSize(width: 28.0, height: 28.0) + + var items: [ContextMenuItem] = [] + + items.append(.action(ContextMenuActionItem(text: environment.strings.Common_Back, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Back"), color: theme.actionSheet.primaryTextColor) + }, iconPosition: .left, action: { (c, _) in + c?.popItems() + }))) + items.append(.separator) + + var isGroup = false + if let displayAsPeers = self.displayAsPeers { + for peer in displayAsPeers { + if peer.peer is TelegramGroup { + isGroup = true + break + } else if let peer = peer.peer as? TelegramChannel, case .group = peer.info { + isGroup = true + break + } + } + } + + items.append(.custom(VoiceChatInfoContextItem(text: isGroup ? environment.strings.VoiceChat_DisplayAsInfoGroup : environment.strings.VoiceChat_DisplayAsInfo, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Call/Context Menu/Accounts"), color: theme.actionSheet.primaryTextColor) + }), true)) + + if let displayAsPeers = self.displayAsPeers { + for peer in displayAsPeers { + var subtitle: String? + if peer.peer.id.namespace == Namespaces.Peer.CloudUser { + subtitle = environment.strings.VoiceChat_PersonalAccount + } else if let subscribers = peer.subscribers { + if let peer = peer.peer as? TelegramChannel, case .broadcast = peer.info { + subtitle = environment.strings.Conversation_StatusSubscribers(subscribers) + } else { + subtitle = environment.strings.Conversation_StatusMembers(subscribers) + } + } + + let isSelected = peer.peer.id == myPeerId + let extendedAvatarSize = CGSize(width: 35.0, height: 35.0) + let theme = environment.theme + let avatarSignal = peerAvatarCompleteImage(account: component.call.accountContext.account, peer: EnginePeer(peer.peer), size: avatarSize) + |> map { image -> UIImage? in + if isSelected, let image = image { + return generateImage(extendedAvatarSize, rotatedContext: { size, context in + let bounds = CGRect(origin: CGPoint(), size: size) + context.clear(bounds) + context.translateBy(x: size.width / 2.0, y: size.height / 2.0) + context.scaleBy(x: 1.0, y: -1.0) + context.translateBy(x: -size.width / 2.0, y: -size.height / 2.0) + context.draw(image.cgImage!, in: CGRect(x: (extendedAvatarSize.width - avatarSize.width) / 2.0, y: (extendedAvatarSize.height - avatarSize.height) / 2.0, width: avatarSize.width, height: avatarSize.height)) + + let lineWidth = 1.0 + UIScreenPixel + context.setLineWidth(lineWidth) + context.setStrokeColor(theme.actionSheet.controlAccentColor.cgColor) + context.strokeEllipse(in: bounds.insetBy(dx: lineWidth / 2.0, dy: lineWidth / 2.0)) + }) + } else { + return image + } + } + + items.append(.action(ContextMenuActionItem(text: EnginePeer(peer.peer).displayTitle(strings: environment.strings, displayOrder: component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).nameDisplayOrder), textLayout: subtitle.flatMap { .secondLineWithValue($0) } ?? .singleLine, icon: { _ in nil }, iconSource: ContextMenuActionItemIconSource(size: isSelected ? extendedAvatarSize : avatarSize, signal: avatarSignal), action: { [weak self] _, f in + f(.default) + + guard let self, let component = self.component else { + return + } + + if peer.peer.id != myPeerId { + component.call.reconnect(as: peer.peer.id) + } + }))) + + if peer.peer.id.namespace == Namespaces.Peer.CloudUser { + items.append(.separator) + } + } + } + return items + } + + private func contextMenuAudioItems() -> [ContextMenuItem] { + guard let environment = self.environment else { + return [] + } + guard let (availableOutputs, currentOutput) = self.audioOutputState else { + return [] + } + + var items: [ContextMenuItem] = [] + + items.append(.action(ContextMenuActionItem(text: environment.strings.Common_Back, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Back"), color: theme.actionSheet.primaryTextColor) + }, iconPosition: .left, action: { (c, _) in + c?.popItems() + }))) + items.append(.separator) + + for output in availableOutputs { + let title: String + switch output { + case .builtin: + title = UIDevice.current.model + case .speaker: + title = environment.strings.Call_AudioRouteSpeaker + case .headphones: + title = environment.strings.Call_AudioRouteHeadphones + case let .port(port): + title = port.name + } + items.append(.action(ContextMenuActionItem(text: title, icon: { theme in + if output == currentOutput { + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Check"), color: theme.actionSheet.primaryTextColor) + } else { + return nil + } + }, action: { [weak self] _, f in + f(.default) + + guard let self, let component = self.component else { + return + } + + component.call.setCurrentAudioOutput(output) + }))) + } + + return items + } + + private func contextMenuPermissionItems() -> [ContextMenuItem] { + guard let environment = self.environment, let callState = self.callState else { + return [] + } + + var items: [ContextMenuItem] = [] + if callState.canManageCall, let defaultParticipantMuteState = callState.defaultParticipantMuteState { + let isMuted = defaultParticipantMuteState == .muted + + items.append(.action(ContextMenuActionItem(text: environment.strings.Common_Back, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Back"), color: theme.actionSheet.primaryTextColor) + }, iconPosition: .left, action: { (c, _) in + c?.popItems() + }))) + items.append(.separator) + items.append(.action(ContextMenuActionItem(text: environment.strings.VoiceChat_SpeakPermissionEveryone, icon: { theme in + if isMuted { + return nil + } else { + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Check"), color: theme.actionSheet.primaryTextColor) + } + }, action: { [weak self] _, f in + f(.dismissWithoutContent) + + guard let self, let component = self.component else { + return + } + component.call.updateDefaultParticipantsAreMuted(isMuted: false) + }))) + items.append(.action(ContextMenuActionItem(text: environment.strings.VoiceChat_SpeakPermissionAdmin, icon: { theme in + if !isMuted { + return nil + } else { + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Check"), color: theme.actionSheet.primaryTextColor) + } + }, action: { [weak self] _, f in + f(.dismissWithoutContent) + + guard let self, let component = self.component else { + return + } + component.call.updateDefaultParticipantsAreMuted(isMuted: true) + }))) + } + return items + } + + private func openParticipantContextMenu(id: EnginePeer.Id, sourceView: ContextExtractedContentContainingView, gesture: ContextGesture?) { + guard let component = self.component, let environment = self.environment else { + return + } + guard let members = self.members, let participant = members.participants.first(where: { $0.peer.id == id }) else { + return + } + + let muteStatePromise = Promise(participant.muteState) + + let itemsForEntry: (GroupCallParticipantsContext.Participant.MuteState?) -> [ContextMenuItem] = { [weak self] muteState in + guard let self, let component = self.component, let environment = self.environment else { + return [] + } + guard let callState = self.callState else { + return [] + } + + var items: [ContextMenuItem] = [] + + var hasVolumeSlider = false + let peer = participant.peer + if let muteState = muteState, !muteState.canUnmute || muteState.mutedByYou { + } else { + if callState.canManageCall || callState.myPeerId != id { + hasVolumeSlider = true + + let minValue: CGFloat + if callState.canManageCall && callState.adminIds.contains(peer.id) && muteState != nil { + minValue = 0.01 + } else { + minValue = 0.0 + } + items.append(.custom(VoiceChatVolumeContextItem(minValue: minValue, value: participant.volume.flatMap { CGFloat($0) / 10000.0 } ?? 1.0, valueChanged: { [weak self] newValue, finished in + guard let self, let component = self.component else { + return + } + + if finished && newValue.isZero { + let updatedMuteState = component.call.updateMuteState(peerId: peer.id, isMuted: true) + muteStatePromise.set(.single(updatedMuteState)) + } else { + component.call.setVolume(peerId: peer.id, volume: Int32(newValue * 10000), sync: finished) + } + }), true)) + } + } + + if callState.myPeerId == id && !hasVolumeSlider && ((participant.about?.isEmpty ?? true) || participant.peer.smallProfileImage == nil) { + items.append(.custom(VoiceChatInfoContextItem(text: environment.strings.VoiceChat_ImproveYourProfileText, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Tip"), color: theme.actionSheet.primaryTextColor) + }), true)) + } + + if peer.id == callState.myPeerId { + if participant.hasRaiseHand { + items.append(.action(ContextMenuActionItem(text: environment.strings.VoiceChat_CancelSpeakRequest, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Call/Context Menu/RevokeSpeak"), color: theme.actionSheet.primaryTextColor) + }, action: { [weak self] _, f in + guard let self, let component = self.component else { + return + } + component.call.lowerHand() + + f(.default) + }))) + } + items.append(.action(ContextMenuActionItem(text: peer.smallProfileImage == nil ? environment.strings.VoiceChat_AddPhoto : environment.strings.VoiceChat_ChangePhoto, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Camera"), color: theme.actionSheet.primaryTextColor) + }, action: { [weak self] _, f in + f(.default) + Queue.mainQueue().after(0.1) { + guard let self else { + return + } + + self.openAvatarForEditing(fromGallery: false, completion: {}) + } + }))) + + items.append(.action(ContextMenuActionItem(text: (participant.about?.isEmpty ?? true) ? environment.strings.VoiceChat_AddBio : environment.strings.VoiceChat_EditBio, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Info"), color: theme.actionSheet.primaryTextColor) + }, action: { [weak self] _, f in + f(.default) + + Queue.mainQueue().after(0.1) { + guard let self, let component = self.component, let environment = self.environment else { + return + } + let maxBioLength: Int + if peer.id.namespace == Namespaces.Peer.CloudUser { + maxBioLength = 70 + } else { + maxBioLength = 100 + } + let controller = voiceChatTitleEditController(sharedContext: component.call.accountContext.sharedContext, account: component.call.accountContext.account, forceTheme: environment.theme, title: environment.strings.VoiceChat_EditBioTitle, text: environment.strings.VoiceChat_EditBioText, placeholder: environment.strings.VoiceChat_EditBioPlaceholder, doneButtonTitle: environment.strings.VoiceChat_EditBioSave, value: participant.about, maxLength: maxBioLength, apply: { [weak self] bio in + guard let self, let component = self.component, let environment = self.environment, let bio else { + return + } + if peer.id.namespace == Namespaces.Peer.CloudUser { + let _ = (component.call.accountContext.engine.accountData.updateAbout(about: bio) + |> `catch` { _ -> Signal in + return .complete() + }).start() + } else { + let _ = (component.call.accountContext.engine.peers.updatePeerDescription(peerId: peer.id, description: bio) + |> `catch` { _ -> Signal in + return .complete() + }).start() + } + + self.presentUndoOverlay(content: .info(title: nil, text: environment.strings.VoiceChat_EditBioSuccess, timeout: nil, customUndoText: nil), action: { _ in return false }) + }) + environment.controller()?.present(controller, in: .window(.root)) + } + }))) + + if let peer = peer as? TelegramUser { + items.append(.action(ContextMenuActionItem(text: environment.strings.VoiceChat_ChangeName, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Call/Context Menu/ChangeName"), color: theme.actionSheet.primaryTextColor) + }, action: { [weak self] _, f in + f(.default) + + Queue.mainQueue().after(0.1) { + guard let self, let component = self.component, let environment = self.environment else { + return + } + let controller = voiceChatUserNameController(sharedContext: component.call.accountContext.sharedContext, account: component.call.accountContext.account, forceTheme: environment.theme, title: environment.strings.VoiceChat_ChangeNameTitle, firstNamePlaceholder: environment.strings.UserInfo_FirstNamePlaceholder, lastNamePlaceholder: environment.strings.UserInfo_LastNamePlaceholder, doneButtonTitle: environment.strings.VoiceChat_EditBioSave, firstName: peer.firstName, lastName: peer.lastName, maxLength: 128, apply: { [weak self] firstAndLastName in + guard let self, let component = self.component, let environment = self.environment, let (firstName, lastName) = firstAndLastName else { + return + } + let _ = component.call.accountContext.engine.accountData.updateAccountPeerName(firstName: firstName, lastName: lastName).startStandalone() + + self.presentUndoOverlay(content: .info(title: nil, text: environment.strings.VoiceChat_EditNameSuccess, timeout: nil, customUndoText: nil), action: { _ in return false }) + }) + environment.controller()?.present(controller, in: .window(.root)) + } + }))) + } + } else { + if (callState.canManageCall || callState.adminIds.contains(component.call.accountContext.account.peerId)) { + if callState.adminIds.contains(peer.id) { + if let _ = muteState { + } else { + items.append(.action(ContextMenuActionItem(text: environment.strings.VoiceChat_MutePeer, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Call/Context Menu/Mute"), color: theme.actionSheet.primaryTextColor) + }, action: { [weak self] _, f in + guard let self, let component = self.component else { + return + } + + let _ = component.call.updateMuteState(peerId: peer.id, isMuted: true) + f(.default) + }))) + } + } else { + if let muteState = muteState, !muteState.canUnmute { + items.append(.action(ContextMenuActionItem(text: environment.strings.VoiceChat_UnmutePeer, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: participant.hasRaiseHand ? "Call/Context Menu/AllowToSpeak" : "Call/Context Menu/Unmute"), color: theme.actionSheet.primaryTextColor) + }, action: { [weak self] _, f in + guard let self, let component = self.component, let environment = self.environment else { + return + } + + let _ = component.call.updateMuteState(peerId: peer.id, isMuted: false) + f(.default) + + self.presentUndoOverlay(content: .voiceChatCanSpeak(text: environment.strings.VoiceChat_UserCanNowSpeak(EnginePeer(participant.peer).displayTitle(strings: environment.strings, displayOrder: component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).nameDisplayOrder)).string), action: { _ in return true }) + }))) + } else { + items.append(.action(ContextMenuActionItem(text: environment.strings.VoiceChat_MutePeer, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Call/Context Menu/Mute"), color: theme.actionSheet.primaryTextColor) + }, action: { [weak self] _, f in + guard let self, let component = self.component else { + return + } + + let _ = component.call.updateMuteState(peerId: peer.id, isMuted: true) + f(.default) + }))) + } + } + } else { + if let muteState = muteState, muteState.mutedByYou { + items.append(.action(ContextMenuActionItem(text: environment.strings.VoiceChat_UnmuteForMe, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Call/Context Menu/Unmute"), color: theme.actionSheet.primaryTextColor) + }, action: { [weak self] _, f in + guard let self, let component = self.component else { + return + } + + let _ = component.call.updateMuteState(peerId: peer.id, isMuted: false) + f(.default) + }))) + } else { + items.append(.action(ContextMenuActionItem(text: environment.strings.VoiceChat_MuteForMe, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Call/Context Menu/Mute"), color: theme.actionSheet.primaryTextColor) + }, action: { [weak self] _, f in + guard let self, let component = self.component else { + return + } + + let _ = component.call.updateMuteState(peerId: peer.id, isMuted: true) + f(.default) + }))) + } + } + + let openTitle: String + let openIcon: UIImage? + if [Namespaces.Peer.CloudChannel, Namespaces.Peer.CloudGroup].contains(peer.id.namespace) { + if let peer = peer as? TelegramChannel, case .broadcast = peer.info { + openTitle = environment.strings.VoiceChat_OpenChannel + openIcon = UIImage(bundleImageName: "Chat/Context Menu/Channels") + } else { + openTitle = environment.strings.VoiceChat_OpenGroup + openIcon = UIImage(bundleImageName: "Chat/Context Menu/Groups") + } + } else { + openTitle = environment.strings.Conversation_ContextMenuSendMessage + openIcon = UIImage(bundleImageName: "Chat/Context Menu/Message") + } + items.append(.action(ContextMenuActionItem(text: openTitle, icon: { theme in + return generateTintedImage(image: openIcon, color: theme.actionSheet.primaryTextColor) + }, action: { [weak self] _, f in + guard let self, let component = self.component, let environment = self.environment else { + return + } + + guard let controller = environment.controller() as? VideoChatScreenV2Impl, let navigationController = controller.parentNavigationController else { + return + } + + let context = component.call.accountContext + environment.controller()?.dismiss(completion: { [weak navigationController] in + Queue.mainQueue().after(0.3) { + guard let navigationController else { + return + } + context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: context, chatLocation: .peer(EnginePeer(peer)), keepStack: .always, purposefulAction: {}, peekData: nil)) + } + }) + + f(.dismissWithoutContent) + }))) + + if (callState.canManageCall && !callState.adminIds.contains(peer.id)), peer.id != component.call.peerId { + items.append(.action(ContextMenuActionItem(text: environment.strings.VoiceChat_RemovePeer, textColor: .destructive, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Clear"), color: theme.actionSheet.destructiveActionTextColor) + }, action: { [weak self] c, _ in + c?.dismiss(completion: { + guard let self, let component = self.component else { + return + } + + let _ = (component.call.accountContext.account.postbox.loadedPeerWithId(component.call.peerId) + |> deliverOnMainQueue).start(next: { [weak self] chatPeer in + guard let self, let component = self.component, let environment = self.environment else { + return + } + + let presentationData = component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: environment.theme) + let actionSheet = ActionSheetController(presentationData: presentationData) + var items: [ActionSheetItem] = [] + + let nameDisplayOrder = presentationData.nameDisplayOrder + items.append(DeleteChatPeerActionSheetItem(context: component.call.accountContext, peer: EnginePeer(peer), chatPeer: EnginePeer(chatPeer), action: .removeFromGroup, strings: environment.strings, nameDisplayOrder: nameDisplayOrder)) + + items.append(ActionSheetButtonItem(title: environment.strings.VoiceChat_RemovePeerRemove, color: .destructive, action: { [weak self, weak actionSheet] in + actionSheet?.dismissAnimated() + + guard let self, let component = self.component, let environment = self.environment else { + return + } + + let _ = component.call.accountContext.peerChannelMemberCategoriesContextsManager.updateMemberBannedRights(engine: component.call.accountContext.engine, peerId: component.call.peerId, memberId: peer.id, bannedRights: TelegramChatBannedRights(flags: [.banReadMessages], untilDate: Int32.max)).start() + component.call.removedPeer(peer.id) + + self.presentUndoOverlay(content: .banned(text: environment.strings.VoiceChat_RemovedPeerText(EnginePeer(peer).displayTitle(strings: environment.strings, displayOrder: nameDisplayOrder)).string), action: { _ in return false }) + })) + + actionSheet.setItemGroups([ + ActionSheetItemGroup(items: items), + ActionSheetItemGroup(items: [ + ActionSheetButtonItem(title: environment.strings.Common_Cancel, color: .accent, font: .bold, action: { [weak actionSheet] in + actionSheet?.dismissAnimated() + }) + ]) + ]) + environment.controller()?.present(actionSheet, in: .window(.root)) + }) + }) + }))) + } + } + return items + } + + let items = muteStatePromise.get() + |> map { muteState -> [ContextMenuItem] in + return itemsForEntry(muteState) + } + + let presentationData = component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: environment.theme) + let contextController = ContextController( + presentationData: presentationData, + source: .extracted(ParticipantExtractedContentSource(contentView: sourceView)), + items: items |> map { items in + return ContextController.Items(content: .list(items)) + }, + recognizer: nil, + gesture: gesture + ) + + environment.controller()?.forEachController({ controller in + if let controller = controller as? UndoOverlayController { + controller.dismiss() + } + return true + }) + + environment.controller()?.presentInGlobalOverlay(contextController) + } + + private func openAvatarForEditing(fromGallery: Bool = false, completion: @escaping () -> Void = {}) { + guard let component = self.component else { + return + } + guard let callState = self.callState else { + return + } + let peerId = callState.myPeerId + + let _ = (component.call.accountContext.engine.data.get( + TelegramEngine.EngineData.Item.Peer.Peer(id: peerId), + TelegramEngine.EngineData.Item.Configuration.SearchBots() + ) + |> deliverOnMainQueue).start(next: { [weak self] peer, searchBotsConfiguration in + guard let self, let component = self.component, let environment = self.environment else { + return + } + guard let peer else { + return + } + + let presentationData = component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: environment.theme) + + let legacyController = LegacyController(presentation: .custom, theme: environment.theme) + legacyController.statusBar.statusBarStyle = .Ignore + + let emptyController = LegacyEmptyController(context: legacyController.context)! + let navigationController = makeLegacyNavigationController(rootController: emptyController) + navigationController.setNavigationBarHidden(true, animated: false) + navigationController.navigationBar.transform = CGAffineTransform(translationX: -1000.0, y: 0.0) + + legacyController.bind(controller: navigationController) + + self.endEditing(true) + environment.controller()?.present(legacyController, in: .window(.root)) + + var hasPhotos = false + if !peer.profileImageRepresentations.isEmpty { + hasPhotos = true + } + + let mixin = TGMediaAvatarMenuMixin(context: legacyController.context, parentController: emptyController, hasSearchButton: true, hasDeleteButton: hasPhotos && !fromGallery, hasViewButton: false, personalPhoto: peerId.namespace == Namespaces.Peer.CloudUser, isVideo: false, saveEditedPhotos: false, saveCapturedMedia: false, signup: false, forum: false, title: nil, isSuggesting: false)! + mixin.forceDark = true + mixin.stickersContext = LegacyPaintStickersContext(context: component.call.accountContext) + let _ = self.currentAvatarMixin.swap(mixin) + mixin.requestSearchController = { [weak self] assetsController in + guard let self, let component = self.component, let environment = self.environment else { + return + } + let controller = WebSearchController(context: component.call.accountContext, peer: peer, chatLocation: nil, configuration: searchBotsConfiguration, mode: .avatar(initialQuery: peer.id.namespace == Namespaces.Peer.CloudUser ? nil : peer.displayTitle(strings: environment.strings, displayOrder: presentationData.nameDisplayOrder), completion: { [weak self] result in + assetsController?.dismiss() + + guard let self else { + return + } + self.updateProfilePhoto(result) + })) + controller.navigationPresentation = .modal + environment.controller()?.push(controller) + + if fromGallery { + completion() + } + } + mixin.didFinishWithImage = { [weak self] image in + if let image = image { + completion() + self?.updateProfilePhoto(image) + } + } + mixin.didFinishWithVideo = { [weak self] image, asset, adjustments in + if let image = image, let asset = asset { + completion() + self?.updateProfileVideo(image, asset: asset, adjustments: adjustments) + } + } + mixin.didFinishWithDelete = { [weak self] in + guard let self, let environment = self.environment else { + return + } + + let proceed = { [weak self] in + guard let self, let component = self.component else { + return + } + + let _ = self.currentAvatarMixin.swap(nil) + let postbox = component.call.accountContext.account.postbox + self.updateAvatarDisposable.set((component.call.accountContext.engine.peers.updatePeerPhoto(peerId: peerId, photo: nil, mapResourceToAvatarSizes: { resource, representations in + return mapResourceToAvatarSizes(postbox: postbox, resource: resource, representations: representations) + }) + |> deliverOnMainQueue).start()) + } + + let actionSheet = ActionSheetController(presentationData: presentationData) + let items: [ActionSheetItem] = [ + ActionSheetButtonItem(title: environment.strings.Settings_RemoveConfirmation, color: .destructive, action: { [weak actionSheet] in + actionSheet?.dismissAnimated() + proceed() + }) + ] + + actionSheet.setItemGroups([ + ActionSheetItemGroup(items: items), + ActionSheetItemGroup(items: [ + ActionSheetButtonItem(title: presentationData.strings.Common_Cancel, color: .accent, font: .bold, action: { [weak actionSheet] in + actionSheet?.dismissAnimated() + }) + ]) + ]) + environment.controller()?.present(actionSheet, in: .window(.root)) + } + mixin.didDismiss = { [weak self, weak legacyController] in + guard let self else { + return + } + let _ = self.currentAvatarMixin.swap(nil) + legacyController?.dismiss() + } + let menuController = mixin.present() + if let menuController = menuController { + menuController.customRemoveFromParentViewController = { [weak legacyController] in + legacyController?.dismiss() + } + } + }) + } + + private func updateProfilePhoto(_ image: UIImage) { + guard let component = self.component else { + return + } + guard let callState = self.callState else { + return + } + guard let data = image.jpegData(compressionQuality: 0.6) else { + return + } + + let peerId = callState.myPeerId + + let resource = LocalFileMediaResource(fileId: Int64.random(in: Int64.min ... Int64.max)) + component.call.account.postbox.mediaBox.storeResourceData(resource.id, data: data) + let representation = TelegramMediaImageRepresentation(dimensions: PixelDimensions(width: 640, height: 640), resource: resource, progressiveSizes: [], immediateThumbnailData: nil, hasVideo: false, isPersonal: false) + + self.currentUpdatingAvatar = (representation, 0.0) + + let postbox = component.call.account.postbox + let signal = peerId.namespace == Namespaces.Peer.CloudUser ? component.call.accountContext.engine.accountData.updateAccountPhoto(resource: resource, videoResource: nil, videoStartTimestamp: nil, markup: nil, mapResourceToAvatarSizes: { resource, representations in + return mapResourceToAvatarSizes(postbox: postbox, resource: resource, representations: representations) + }) : component.call.accountContext.engine.peers.updatePeerPhoto(peerId: peerId, photo: component.call.accountContext.engine.peers.uploadedPeerPhoto(resource: resource), mapResourceToAvatarSizes: { resource, representations in + return mapResourceToAvatarSizes(postbox: postbox, resource: resource, representations: representations) + }) + + self.updateAvatarDisposable.set((signal + |> deliverOnMainQueue).start(next: { [weak self] result in + guard let self else { + return + } + switch result { + case .complete: + self.currentUpdatingAvatar = nil + self.state?.updated(transition: .spring(duration: 0.4)) + case let .progress(value): + self.currentUpdatingAvatar = (representation, value) + } + })) + + self.state?.updated(transition: .spring(duration: 0.4)) + } + + private func updateProfileVideo(_ image: UIImage, asset: Any?, adjustments: TGVideoEditAdjustments?) { + guard let component = self.component else { + return + } + guard let callState = self.callState else { + return + } + guard let data = image.jpegData(compressionQuality: 0.6) else { + return + } + let peerId = callState.myPeerId + + let photoResource = LocalFileMediaResource(fileId: Int64.random(in: Int64.min ... Int64.max)) + component.call.accountContext.account.postbox.mediaBox.storeResourceData(photoResource.id, data: data) + let representation = TelegramMediaImageRepresentation(dimensions: PixelDimensions(width: 640, height: 640), resource: photoResource, progressiveSizes: [], immediateThumbnailData: nil, hasVideo: false, isPersonal: false) + + self.currentUpdatingAvatar = (representation, 0.0) + + var videoStartTimestamp: Double? = nil + if let adjustments = adjustments, adjustments.videoStartValue > 0.0 { + videoStartTimestamp = adjustments.videoStartValue - adjustments.trimStartValue + } + + let context = component.call.accountContext + let account = context.account + let signal = Signal { [weak self] subscriber in + let entityRenderer: LegacyPaintEntityRenderer? = adjustments.flatMap { adjustments in + if let paintingData = adjustments.paintingData, paintingData.hasAnimation { + return LegacyPaintEntityRenderer(postbox: account.postbox, adjustments: adjustments) + } else { + return nil + } + } + + let tempFile = EngineTempBox.shared.tempFile(fileName: "video.mp4") + let uploadInterface = LegacyLiveUploadInterface(context: context) + let signal: SSignal + if let url = asset as? URL, url.absoluteString.hasSuffix(".jpg"), let data = try? Data(contentsOf: url, options: [.mappedRead]), let image = UIImage(data: data), let entityRenderer = entityRenderer { + let durationSignal: SSignal = SSignal(generator: { subscriber in + let disposable = (entityRenderer.duration()).start(next: { duration in + subscriber.putNext(duration) + subscriber.putCompletion() + }) + + return SBlockDisposable(block: { + disposable.dispose() + }) + }) + signal = durationSignal.map(toSignal: { duration -> SSignal in + if let duration = duration as? Double { + return TGMediaVideoConverter.renderUIImage(image, duration: duration, adjustments: adjustments, path: tempFile.path, watcher: nil, entityRenderer: entityRenderer)! + } else { + return SSignal.single(nil) + } + }) + + } else if let asset = asset as? AVAsset { + signal = TGMediaVideoConverter.convert(asset, adjustments: adjustments, path: tempFile.path, watcher: uploadInterface, entityRenderer: entityRenderer)! + } else { + signal = SSignal.complete() + } + + let signalDisposable = signal.start(next: { next in + if let result = next as? TGMediaVideoConversionResult { + if let image = result.coverImage, let data = image.jpegData(compressionQuality: 0.7) { + account.postbox.mediaBox.storeResourceData(photoResource.id, data: data) + } + + if let timestamp = videoStartTimestamp { + videoStartTimestamp = max(0.0, min(timestamp, result.duration - 0.05)) + } + + var value = stat() + if stat(result.fileURL.path, &value) == 0 { + if let data = try? Data(contentsOf: result.fileURL) { + let resource: TelegramMediaResource + if let liveUploadData = result.liveUploadData as? LegacyLiveUploadInterfaceResult { + resource = LocalFileMediaResource(fileId: liveUploadData.id) + } else { + resource = LocalFileMediaResource(fileId: Int64.random(in: Int64.min ... Int64.max)) + } + account.postbox.mediaBox.storeResourceData(resource.id, data: data, synchronous: true) + subscriber.putNext(resource) + + EngineTempBox.shared.dispose(tempFile) + } + } + subscriber.putCompletion() + } else if let progress = next as? NSNumber { + Queue.mainQueue().async { [weak self] in + guard let self else { + return + } + self.currentUpdatingAvatar = (representation, Float(truncating: progress) * 0.25) + self.state?.updated(transition: .spring(duration: 0.4)) + } + } + }, error: { _ in + }, completed: nil) + + let disposable = ActionDisposable { + signalDisposable?.dispose() + } + + return ActionDisposable { + disposable.dispose() + } + } + + self.updateAvatarDisposable.set((signal + |> mapToSignal { videoResource -> Signal in + if peerId.namespace == Namespaces.Peer.CloudUser { + return context.engine.accountData.updateAccountPhoto(resource: photoResource, videoResource: videoResource, videoStartTimestamp: videoStartTimestamp, markup: nil, mapResourceToAvatarSizes: { resource, representations in + return mapResourceToAvatarSizes(postbox: account.postbox, resource: resource, representations: representations) + }) + } else { + return context.engine.peers.updatePeerPhoto(peerId: peerId, photo: context.engine.peers.uploadedPeerPhoto(resource: photoResource), video: context.engine.peers.uploadedPeerVideo(resource: videoResource) |> map(Optional.init), videoStartTimestamp: videoStartTimestamp, mapResourceToAvatarSizes: { resource, representations in + return mapResourceToAvatarSizes(postbox: account.postbox, resource: resource, representations: representations) + }) + } + } + |> deliverOnMainQueue).start(next: { [weak self] result in + guard let self else { + return + } + switch result { + case .complete: + self.currentUpdatingAvatar = nil + self.state?.updated(transition: .spring(duration: 0.4)) + case let .progress(value): + self.currentUpdatingAvatar = (representation, 0.25 + value * 0.75) + self.state?.updated(transition: .spring(duration: 0.4)) + } + })) + } + + private func openTitleEditing() { + guard let component = self.component else { + return + } + + let _ = (component.call.accountContext.account.postbox.loadedPeerWithId(component.call.peerId) + |> deliverOnMainQueue).start(next: { [weak self] chatPeer in + guard let self, let component = self.component, let environment = self.environment else { + return + } + guard let callState = self.callState, let peer = self.peer else { + return + } + + let initialTitle = callState.title + + let title: String + let text: String + if case let .channel(channel) = peer, case .broadcast = channel.info { + title = environment.strings.LiveStream_EditTitle + text = environment.strings.LiveStream_EditTitleText + } else { + title = environment.strings.VoiceChat_EditTitle + text = environment.strings.VoiceChat_EditTitleText + } + + let controller = voiceChatTitleEditController(sharedContext: component.call.accountContext.sharedContext, account: component.call.accountContext.account, forceTheme: environment.theme, title: title, text: text, placeholder: EnginePeer(chatPeer).displayTitle(strings: environment.strings, displayOrder: component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).nameDisplayOrder), value: initialTitle, maxLength: 40, apply: { [weak self] title in + guard let self, let component = self.component, let environment = self.environment else { + return + } + guard let title = title, title != initialTitle else { + return + } + + component.call.updateTitle(title) + + let text: String + if case let .channel(channel) = self.peer, case .broadcast = channel.info { + text = title.isEmpty ? environment.strings.LiveStream_EditTitleRemoveSuccess : environment.strings.LiveStream_EditTitleSuccess(title).string + } else { + text = title.isEmpty ? environment.strings.VoiceChat_EditTitleRemoveSuccess : environment.strings.VoiceChat_EditTitleSuccess(title).string + } + + self.presentUndoOverlay(content: .voiceChatFlag(text: text), action: { _ in return false }) + }) + environment.controller()?.present(controller, in: .window(.root)) + }) + } + + private func presentUndoOverlay(content: UndoOverlayContent, action: @escaping (UndoOverlayAction) -> Bool) { + guard let component = self.component, let environment = self.environment else { + return + } + var animateInAsReplacement = false + environment.controller()?.forEachController { c in + if let c = c as? UndoOverlayController { + animateInAsReplacement = true + c.dismiss() + } + return true + } + let presentationData = component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: environment.theme) + environment.controller()?.present(UndoOverlayController(presentationData: presentationData, content: content, elevatedLayout: false, animateInAsReplacement: animateInAsReplacement, action: action), in: .current) + } + + private func openInviteMembers() { + guard let component = self.component else { + return + } + + var canInvite = true + var inviteIsLink = false + if case let .channel(peer) = self.peer { + if peer.flags.contains(.isGigagroup) { + if peer.flags.contains(.isCreator) || peer.adminRights != nil { + } else { + canInvite = false + } + } + if case .broadcast = peer.info, !(peer.addressName?.isEmpty ?? true) { + inviteIsLink = true + } + } + var inviteType: VideoChatParticipantsComponent.Participants.InviteType? + if canInvite { + if inviteIsLink { + inviteType = .shareLink + } else { + inviteType = .invite + } + } + + guard let inviteType else { + return + } + + switch inviteType { + case .invite: + let groupPeer = component.call.accountContext.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: component.call.peerId)) + let _ = (groupPeer + |> deliverOnMainQueue).start(next: { [weak self] groupPeer in + guard let self, let component = self.component, let environment = self.environment, let groupPeer else { + return + } + let inviteLinks = self.inviteLinks + + if case let .channel(groupPeer) = groupPeer { + var canInviteMembers = true + if case .broadcast = groupPeer.info, !(groupPeer.addressName?.isEmpty ?? true) { + canInviteMembers = false + } + if !canInviteMembers { + if let inviteLinks { + self.presentShare(inviteLinks) + } + return + } + } + + var filters: [ChannelMembersSearchFilter] = [] + if let members = self.members { + filters.append(.disable(Array(members.participants.map { $0.peer.id }))) + } + if case let .channel(groupPeer) = groupPeer { + if !groupPeer.hasPermission(.inviteMembers) && inviteLinks?.listenerLink == nil { + filters.append(.excludeNonMembers) + } + } else if case let .legacyGroup(groupPeer) = groupPeer { + if groupPeer.hasBannedPermission(.banAddMembers) { + filters.append(.excludeNonMembers) + } + } + filters.append(.excludeBots) + + var dismissController: (() -> Void)? + let controller = ChannelMembersSearchController(context: component.call.accountContext, peerId: groupPeer.id, forceTheme: environment.theme, mode: .inviteToCall, filters: filters, openPeer: { [weak self] peer, participant in + guard let self, let component = self.component, let environment = self.environment else { + dismissController?() + return + } + guard let callState = self.callState else { + return + } + + let presentationData = component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: environment.theme) + if peer.id == callState.myPeerId { + return + } + if let participant { + dismissController?() + + if component.call.invitePeer(participant.peer.id) { + let text: String + if case let .channel(channel) = self.peer, case .broadcast = channel.info { + text = environment.strings.LiveStream_InvitedPeerText(peer.displayTitle(strings: environment.strings, displayOrder: component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).nameDisplayOrder)).string + } else { + text = environment.strings.VoiceChat_InvitedPeerText(peer.displayTitle(strings: environment.strings, displayOrder: component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).nameDisplayOrder)).string + } + self.presentUndoOverlay(content: .invitedToVoiceChat(context: component.call.accountContext, peer: EnginePeer(participant.peer), title: nil, text: text, action: nil, duration: 3), action: { _ in return false }) + } + } else { + if case let .channel(groupPeer) = groupPeer, let listenerLink = inviteLinks?.listenerLink, !groupPeer.hasPermission(.inviteMembers) { + let text = environment.strings.VoiceChat_SendPublicLinkText(peer.displayTitle(strings: environment.strings, displayOrder: component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).nameDisplayOrder), EnginePeer(groupPeer).displayTitle(strings: environment.strings, displayOrder: component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).nameDisplayOrder)).string + + environment.controller()?.present(textAlertController(context: component.call.accountContext, forceTheme: environment.theme, title: nil, text: text, actions: [TextAlertAction(type: .genericAction, title: environment.strings.Common_Cancel, action: {}), TextAlertAction(type: .defaultAction, title: environment.strings.VoiceChat_SendPublicLinkSend, action: { [weak self] in + dismissController?() + + guard let self, let component = self.component else { + return + } + + let _ = (enqueueMessages(account: component.call.accountContext.account, peerId: peer.id, messages: [.message(text: listenerLink, attributes: [], inlineStickers: [:], mediaReference: nil, threadId: nil, replyToMessageId: nil, replyToStoryId: nil, localGroupingKey: nil, correlationId: nil, bubbleUpEmojiOrStickersets: [])]) + |> deliverOnMainQueue).start(next: { [weak self] _ in + guard let self, let environment = self.environment else { + return + } + self.presentUndoOverlay(content: .forward(savedMessages: false, text: environment.strings.UserInfo_LinkForwardTooltip_Chat_One(peer.displayTitle(strings: environment.strings, displayOrder: component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).nameDisplayOrder)).string), action: { _ in return true }) + }) + })]), in: .window(.root)) + } else { + let text: String + if case let .channel(groupPeer) = groupPeer, case .broadcast = groupPeer.info { + text = environment.strings.VoiceChat_InviteMemberToChannelFirstText(peer.displayTitle(strings: environment.strings, displayOrder: component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).nameDisplayOrder), EnginePeer(groupPeer).displayTitle(strings: environment.strings, displayOrder: component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).nameDisplayOrder)).string + } else { + text = environment.strings.VoiceChat_InviteMemberToGroupFirstText(peer.displayTitle(strings: environment.strings, displayOrder: component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).nameDisplayOrder), groupPeer.displayTitle(strings: environment.strings, displayOrder: component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).nameDisplayOrder)).string + } + + environment.controller()?.present(textAlertController(context: component.call.accountContext, forceTheme: environment.theme, title: nil, text: text, actions: [TextAlertAction(type: .genericAction, title: environment.strings.Common_Cancel, action: {}), TextAlertAction(type: .defaultAction, title: environment.strings.VoiceChat_InviteMemberToGroupFirstAdd, action: { [weak self] in + guard let self, let component = self.component, let environment = self.environment else { + return + } + + if case let .channel(groupPeer) = groupPeer { + guard let selfController = environment.controller() else { + return + } + let inviteDisposable = self.inviteDisposable + var inviteSignal = component.call.accountContext.peerChannelMemberCategoriesContextsManager.addMembers(engine: component.call.accountContext.engine, peerId: groupPeer.id, memberIds: [peer.id]) + var cancelImpl: (() -> Void)? + let progressSignal = Signal { [weak selfController] subscriber in + let controller = OverlayStatusController(theme: presentationData.theme, type: .loading(cancelled: { + cancelImpl?() + })) + selfController?.present(controller, in: .window(.root)) + return ActionDisposable { [weak controller] in + Queue.mainQueue().async() { + controller?.dismiss() + } + } + } + |> runOn(Queue.mainQueue()) + |> delay(0.15, queue: Queue.mainQueue()) + let progressDisposable = progressSignal.start() + + inviteSignal = inviteSignal + |> afterDisposed { + Queue.mainQueue().async { + progressDisposable.dispose() + } + } + cancelImpl = { + inviteDisposable.set(nil) + } + + inviteDisposable.set((inviteSignal |> deliverOnMainQueue).start(error: { [weak self] error in + dismissController?() + guard let self, let component = self.component, let environment = self.environment else { + return + } + + let text: String + switch error { + case .limitExceeded: + text = environment.strings.Channel_ErrorAddTooMuch + case .tooMuchJoined: + text = environment.strings.Invite_ChannelsTooMuch + case .generic: + text = environment.strings.Login_UnknownError + case .restricted: + text = environment.strings.Channel_ErrorAddBlocked + case .notMutualContact: + if case .broadcast = groupPeer.info { + text = environment.strings.Channel_AddUserLeftError + } else { + text = environment.strings.GroupInfo_AddUserLeftError + } + case .botDoesntSupportGroups: + text = environment.strings.Channel_BotDoesntSupportGroups + case .tooMuchBots: + text = environment.strings.Channel_TooMuchBots + case .bot: + text = environment.strings.Login_UnknownError + case .kicked: + text = environment.strings.Channel_AddUserKickedError + } + environment.controller()?.present(textAlertController(context: component.call.accountContext, forceTheme: environment.theme, title: nil, text: text, actions: [TextAlertAction(type: .defaultAction, title: environment.strings.Common_OK, action: {})]), in: .window(.root)) + }, completed: { [weak self] in + guard let self, let component = self.component, let environment = self.environment else { + dismissController?() + return + } + dismissController?() + + if component.call.invitePeer(peer.id) { + let text: String + if case let .channel(channel) = self.peer, case .broadcast = channel.info { + text = environment.strings.LiveStream_InvitedPeerText(peer.displayTitle(strings: environment.strings, displayOrder: presentationData.nameDisplayOrder)).string + } else { + text = environment.strings.VoiceChat_InvitedPeerText(peer.displayTitle(strings: environment.strings, displayOrder: presentationData.nameDisplayOrder)).string + } + self.presentUndoOverlay(content: .invitedToVoiceChat(context: component.call.accountContext, peer: peer, title: nil, text: text, action: nil, duration: 3), action: { _ in return false }) + } + })) + } else if case let .legacyGroup(groupPeer) = groupPeer { + guard let selfController = environment.controller() else { + return + } + let inviteDisposable = self.inviteDisposable + var inviteSignal = component.call.accountContext.engine.peers.addGroupMember(peerId: groupPeer.id, memberId: peer.id) + var cancelImpl: (() -> Void)? + let progressSignal = Signal { [weak selfController] subscriber in + let controller = OverlayStatusController(theme: presentationData.theme, type: .loading(cancelled: { + cancelImpl?() + })) + selfController?.present(controller, in: .window(.root)) + return ActionDisposable { [weak controller] in + Queue.mainQueue().async() { + controller?.dismiss() + } + } + } + |> runOn(Queue.mainQueue()) + |> delay(0.15, queue: Queue.mainQueue()) + let progressDisposable = progressSignal.start() + + inviteSignal = inviteSignal + |> afterDisposed { + Queue.mainQueue().async { + progressDisposable.dispose() + } + } + cancelImpl = { + inviteDisposable.set(nil) + } + + inviteDisposable.set((inviteSignal |> deliverOnMainQueue).start(error: { [weak self] error in + dismissController?() + guard let self, let component = self.component, let environment = self.environment else { + return + } + let context = component.call.accountContext + + switch error { + case .privacy: + let _ = (component.call.accountContext.account.postbox.loadedPeerWithId(peer.id) + |> deliverOnMainQueue).start(next: { [weak self] peer in + guard let self, let component = self.component, let environment = self.environment else { + return + } + environment.controller()?.present(textAlertController(context: component.call.accountContext, title: nil, text: environment.strings.Privacy_GroupsAndChannels_InviteToGroupError(EnginePeer(peer).compactDisplayTitle, EnginePeer(peer).compactDisplayTitle).string, actions: [TextAlertAction(type: .genericAction, title: environment.strings.Common_OK, action: {})]), in: .window(.root)) + }) + case .notMutualContact: + environment.controller()?.present(textAlertController(context: context, title: nil, text: environment.strings.GroupInfo_AddUserLeftError, actions: [TextAlertAction(type: .genericAction, title: environment.strings.Common_OK, action: {})]), in: .window(.root)) + case .tooManyChannels: + environment.controller()?.present(textAlertController(context: context, title: nil, text: environment.strings.Invite_ChannelsTooMuch, actions: [TextAlertAction(type: .genericAction, title: environment.strings.Common_OK, action: {})]), in: .window(.root)) + case .groupFull, .generic: + environment.controller()?.present(textAlertController(context: context, forceTheme: environment.theme, title: nil, text: environment.strings.Login_UnknownError, actions: [TextAlertAction(type: .defaultAction, title: environment.strings.Common_OK, action: {})]), in: .window(.root)) + } + }, completed: { [weak self] in + guard let self, let component = self.component, let environment = self.environment else { + dismissController?() + return + } + dismissController?() + + if component.call.invitePeer(peer.id) { + let text: String + if case let .channel(channel) = self.peer, case .broadcast = channel.info { + text = environment.strings.LiveStream_InvitedPeerText(peer.displayTitle(strings: environment.strings, displayOrder: presentationData.nameDisplayOrder)).string + } else { + text = environment.strings.VoiceChat_InvitedPeerText(peer.displayTitle(strings: environment.strings, displayOrder: presentationData.nameDisplayOrder)).string + } + self.presentUndoOverlay(content: .invitedToVoiceChat(context: component.call.accountContext, peer: peer, title: nil, text: text, action: nil, duration: 3), action: { _ in return false }) + } + })) + } + })]), in: .window(.root)) + } + } + }) + controller.copyInviteLink = { [weak self] in + dismissController?() + + guard let self, let component = self.component else { + return + } + let callPeerId = component.call.peerId + + let _ = (component.call.accountContext.engine.data.get( + TelegramEngine.EngineData.Item.Peer.Peer(id: callPeerId), + TelegramEngine.EngineData.Item.Peer.ExportedInvitation(id: callPeerId) + ) + |> map { peer, exportedInvitation -> String? in + if let link = inviteLinks?.listenerLink { + return link + } else if let peer = peer, let addressName = peer.addressName, !addressName.isEmpty { + return "https://t.me/\(addressName)" + } else if let link = exportedInvitation?.link { + return link + } else { + return nil + } + } + |> deliverOnMainQueue).start(next: { [weak self] link in + guard let self, let environment = self.environment else { + return + } + + if let link { + UIPasteboard.general.string = link + + self.presentUndoOverlay(content: .linkCopied(text: environment.strings.VoiceChat_InviteLinkCopiedText), action: { _ in return false }) + } + }) + } + dismissController = { [weak controller] in + controller?.dismiss() + } + environment.controller()?.push(controller) + }) + case .shareLink: + guard let inviteLinks = self.inviteLinks else { + return + } + self.presentShare(inviteLinks) + } + } + + private func presentShare(_ inviteLinks: GroupCallInviteLinks) { + guard let component = self.component else { + return + } + + let formatSendTitle: (String) -> String = { string in + var string = string + if string.contains("[") && string.contains("]") { + if let startIndex = string.firstIndex(of: "["), let endIndex = string.firstIndex(of: "]") { + string.removeSubrange(startIndex ... endIndex) + } + } else { + string = string.trimmingCharacters(in: CharacterSet(charactersIn: "0123456789-,.")) + } + return string + } + + let _ = (component.call.accountContext.account.postbox.loadedPeerWithId(component.call.peerId) + |> deliverOnMainQueue).start(next: { [weak self] peer in + guard let self, let component = self.component, let environment = self.environment else { + return + } + guard let peer = self.peer else { + return + } + guard let callState = self.callState else { + return + } + var inviteLinks = inviteLinks + + if case let .channel(peer) = peer, case .group = peer.info, !peer.flags.contains(.isGigagroup), !(peer.addressName ?? "").isEmpty, let defaultParticipantMuteState = callState.defaultParticipantMuteState { + let isMuted = defaultParticipantMuteState == .muted + + if !isMuted { + inviteLinks = GroupCallInviteLinks(listenerLink: inviteLinks.listenerLink, speakerLink: nil) + } + } + + var segmentedValues: [ShareControllerSegmentedValue]? + if let speakerLink = inviteLinks.speakerLink { + segmentedValues = [ShareControllerSegmentedValue(title: environment.strings.VoiceChat_InviteLink_Speaker, subject: .url(speakerLink), actionTitle: environment.strings.VoiceChat_InviteLink_CopySpeakerLink, formatSendTitle: { count in + return formatSendTitle(environment.strings.VoiceChat_InviteLink_InviteSpeakers(Int32(count))) + }), ShareControllerSegmentedValue(title: environment.strings.VoiceChat_InviteLink_Listener, subject: .url(inviteLinks.listenerLink), actionTitle: environment.strings.VoiceChat_InviteLink_CopyListenerLink, formatSendTitle: { count in + return formatSendTitle(environment.strings.VoiceChat_InviteLink_InviteListeners(Int32(count))) + })] + } + let shareController = ShareController(context: component.call.accountContext, subject: .url(inviteLinks.listenerLink), segmentedValues: segmentedValues, forceTheme: environment.theme, forcedActionTitle: environment.strings.VoiceChat_CopyInviteLink) + shareController.completed = { [weak self] peerIds in + guard let self, let component = self.component else { + return + } + let _ = (component.call.accountContext.engine.data.get( + EngineDataList( + peerIds.map(TelegramEngine.EngineData.Item.Peer.Peer.init) + ) + ) + |> deliverOnMainQueue).start(next: { [weak self] peerList in + guard let self, let component = self.component, let environment = self.environment else { + return + } + + let peers = peerList.compactMap { $0 } + let presentationData = component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: environment.theme) + + let text: String + var isSavedMessages = false + if peers.count == 1, let peer = peers.first { + isSavedMessages = peer.id == component.call.accountContext.account.peerId + let peerName = peer.id == component.call.accountContext.account.peerId ? presentationData.strings.DialogList_SavedMessages : peer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder) + text = presentationData.strings.VoiceChat_ForwardTooltip_Chat(peerName).string + } else if peers.count == 2, let firstPeer = peers.first, let secondPeer = peers.last { + let firstPeerName = firstPeer.id == component.call.accountContext.account.peerId ? presentationData.strings.DialogList_SavedMessages : firstPeer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder) + let secondPeerName = secondPeer.id == component.call.accountContext.account.peerId ? presentationData.strings.DialogList_SavedMessages : secondPeer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder) + text = presentationData.strings.VoiceChat_ForwardTooltip_TwoChats(firstPeerName, secondPeerName).string + } else if let peer = peers.first { + let peerName = peer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder) + text = presentationData.strings.VoiceChat_ForwardTooltip_ManyChats(peerName, "\(peers.count - 1)").string + } else { + text = "" + } + + environment.controller()?.present(UndoOverlayController(presentationData: presentationData, content: .forward(savedMessages: isSavedMessages, text: text), elevatedLayout: false, animateInAsReplacement: true, action: { _ in return false }), in: .current) + }) + } + shareController.actionCompleted = { [weak self] in + guard let self, let component = self.component, let environment = self.environment else { + return + } + let presentationData = component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: environment.theme) + environment.controller()?.present(UndoOverlayController(presentationData: presentationData, content: .linkCopied(text: presentationData.strings.VoiceChat_InviteLinkCopiedText), elevatedLayout: false, animateInAsReplacement: false, action: { _ in return false }), in: .window(.root)) + } + environment.controller()?.present(shareController, in: .window(.root)) + }) + } + + private func onCameraPressed() { + guard let component = self.component, let environment = self.environment else { + return + } + + HapticFeedback().impact(.light) + if component.call.hasVideo { + component.call.disableVideo() + } else { + let presentationData = component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: environment.theme) + DeviceAccess.authorizeAccess(to: .camera(.videoCall), onlyCheck: true, presentationData: presentationData, present: { [weak self] c, a in + guard let self, let environment = self.environment, let controller = environment.controller() else { + return + } + controller.present(c, in: .window(.root), with: a) + }, openSettings: { [weak self] in + guard let self, let component = self.component else { + return + } + component.call.accountContext.sharedContext.applicationBindings.openSettings() + }, _: { [weak self] ready in + guard let self, let component = self.component, let environment = self.environment, ready else { + return + } + var isFrontCamera = true + let videoCapturer = OngoingCallVideoCapturer() + let input = videoCapturer.video() + if let videoView = self.videoRenderingContext.makeView(input: input, blur: false) { + videoView.updateIsEnabled(true) + + let cameraNode = GroupVideoNode(videoView: videoView, backdropVideoView: nil) + let controller = VoiceChatCameraPreviewController(sharedContext: component.call.accountContext.sharedContext, cameraNode: cameraNode, shareCamera: { [weak self] _, unmuted in + guard let self, let component = self.component else { + return + } + + component.call.setIsMuted(action: unmuted ? .unmuted : .muted(isPushToTalkActive: false)) + (component.call as! PresentationGroupCallImpl).requestVideo(capturer: videoCapturer, useFrontCamera: isFrontCamera) + }, switchCamera: { + Queue.mainQueue().after(0.1) { + isFrontCamera = !isFrontCamera + videoCapturer.switchVideoInput(isFront: isFrontCamera) + } + }) + environment.controller()?.present(controller, in: .window(.root)) + } + }) + } + } + + private func onAudioRoutePressed() { + guard let component = self.component, let environment = self.environment else { + return + } + + HapticFeedback().impact(.light) + + guard let (availableOutputs, currentOutput) = self.audioOutputState else { + return + } + guard availableOutputs.count >= 2 else { + return + } + + if availableOutputs.count == 2 { + for output in availableOutputs { + if output != currentOutput { + component.call.setCurrentAudioOutput(output) + break + } + } + } else { + let presentationData = component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: environment.theme) + let actionSheet = ActionSheetController(presentationData: presentationData) + var items: [ActionSheetItem] = [] + for output in availableOutputs { + let title: String + var icon: UIImage? + switch output { + case .builtin: + title = UIDevice.current.model + case .speaker: + title = environment.strings.Call_AudioRouteSpeaker + icon = generateScaledImage(image: UIImage(bundleImageName: "Call/CallSpeakerButton"), size: CGSize(width: 48.0, height: 48.0), opaque: false) + case .headphones: + title = environment.strings.Call_AudioRouteHeadphones + case let .port(port): + title = port.name + if port.type == .bluetooth { + var image = UIImage(bundleImageName: "Call/CallBluetoothButton") + let portName = port.name.lowercased() + if portName.contains("airpods max") { + image = UIImage(bundleImageName: "Call/CallAirpodsMaxButton") + } else if portName.contains("airpods pro") { + image = UIImage(bundleImageName: "Call/CallAirpodsProButton") + } else if portName.contains("airpods") { + image = UIImage(bundleImageName: "Call/CallAirpodsButton") + } + icon = generateScaledImage(image: image, size: CGSize(width: 48.0, height: 48.0), opaque: false) + } + } + items.append(CallRouteActionSheetItem(title: title, icon: icon, selected: output == currentOutput, action: { [weak self, weak actionSheet] in + actionSheet?.dismissAnimated() + + guard let self, let component = self.component else { + return + } + component.call.setCurrentAudioOutput(output) + })) + } + + actionSheet.setItemGroups([ + ActionSheetItemGroup(items: items), + ActionSheetItemGroup(items: [ + ActionSheetButtonItem(title: environment.strings.Call_AudioRouteHide, color: .accent, font: .bold, action: { [weak actionSheet] in + actionSheet?.dismissAnimated() + }) + ]) + ]) + environment.controller()?.present(actionSheet, in: .window(.root)) + } + } + + func update(component: VideoChatScreenComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + self.isUpdating = true + defer { + self.isUpdating = false + } + + let environment = environment[ViewControllerComponentContainer.Environment.self].value + let themeUpdated = self.environment?.theme !== environment.theme + + if self.component == nil { + self.peer = component.initialData.peer + self.members = component.initialData.members + self.callState = component.initialData.callState + + self.membersDisposable = (component.call.members + |> deliverOnMainQueue).startStrict(next: { [weak self] members in + guard let self else { + return + } + if self.members != members { + var members = members + + #if DEBUG && false + if let membersValue = members { + var participants = membersValue.participants + for i in 1 ... 20 { + for participant in membersValue.participants { + guard let user = participant.peer as? TelegramUser else { + continue + } + let mappedUser = TelegramUser( + id: EnginePeer.Id(namespace: Namespaces.Peer.CloudUser, id: EnginePeer.Id.Id._internalFromInt64Value(user.id.id._internalGetInt64Value() + Int64(i))), + accessHash: user.accessHash, + firstName: user.firstName, + lastName: user.lastName, + username: user.username, + phone: user.phone, + photo: user.photo, + botInfo: user.botInfo, + restrictionInfo: user.restrictionInfo, + flags: user.flags, + emojiStatus: user.emojiStatus, + usernames: user.usernames, + storiesHidden: user.storiesHidden, + nameColor: user.nameColor, + backgroundEmojiId: user.backgroundEmojiId, + profileColor: user.profileColor, + profileBackgroundEmojiId: user.profileBackgroundEmojiId, + subscriberCount: user.subscriberCount + ) + participants.append(GroupCallParticipantsContext.Participant( + peer: mappedUser, + ssrc: participant.ssrc, + videoDescription: participant.videoDescription, + presentationDescription: participant.presentationDescription, + joinTimestamp: participant.joinTimestamp, + raiseHandRating: participant.raiseHandRating, + hasRaiseHand: participant.hasRaiseHand, + activityTimestamp: participant.activityTimestamp, + activityRank: participant.activityRank, + muteState: participant.muteState, + volume: participant.volume, + about: participant.about, + joinedVideo: participant.joinedVideo + )) + } + } + members = PresentationGroupCallMembers( + participants: participants, + speakingParticipants: membersValue.speakingParticipants, + totalCount: membersValue.totalCount, + loadMoreToken: membersValue.loadMoreToken + ) + } + #endif + + if let membersValue = members { + var participants = membersValue.participants + participants = participants.sorted(by: { lhs, rhs in + guard let lhsIndex = membersValue.participants.firstIndex(where: { $0.peer.id == lhs.peer.id }) else { + return false + } + guard let rhsIndex = membersValue.participants.firstIndex(where: { $0.peer.id == rhs.peer.id }) else { + return false + } + + if let lhsActivityRank = lhs.activityRank, let rhsActivityRank = rhs.activityRank { + if lhsActivityRank != rhsActivityRank { + return lhsActivityRank < rhsActivityRank + } + } else if (lhs.activityRank == nil) != (rhs.activityRank == nil) { + return lhs.activityRank != nil + } + + return lhsIndex < rhsIndex + }) + members = PresentationGroupCallMembers( + participants: participants, + speakingParticipants: membersValue.speakingParticipants, + totalCount: membersValue.totalCount, + loadMoreToken: membersValue.loadMoreToken + ) + } + + self.members = members + + if let expandedParticipantsVideoState = self.expandedParticipantsVideoState, let members { + if !expandedParticipantsVideoState.isMainParticipantPinned, let participant = members.participants.first(where: { participant in + if let callState = self.callState, participant.peer.id == callState.myPeerId { + return false + } + if participant.videoDescription != nil || participant.presentationDescription != nil { + if members.speakingParticipants.contains(participant.peer.id) { + return true + } + } + return false + }) { + if participant.peer.id != expandedParticipantsVideoState.mainParticipant.id { + if participant.presentationDescription != nil { + self.expandedParticipantsVideoState = VideoChatParticipantsComponent.ExpandedVideoState(mainParticipant: VideoChatParticipantsComponent.VideoParticipantKey(id: participant.peer.id, isPresentation: true), isMainParticipantPinned: false, isUIHidden: expandedParticipantsVideoState.isUIHidden) + } else { + self.expandedParticipantsVideoState = VideoChatParticipantsComponent.ExpandedVideoState(mainParticipant: VideoChatParticipantsComponent.VideoParticipantKey(id: participant.peer.id, isPresentation: false), isMainParticipantPinned: false, isUIHidden: expandedParticipantsVideoState.isUIHidden) + } + } + } + + if let _ = members.participants.first(where: { participant in + if participant.peer.id == expandedParticipantsVideoState.mainParticipant.id { + if expandedParticipantsVideoState.mainParticipant.isPresentation { + if participant.presentationDescription == nil { + return false + } + } else { + if participant.videoDescription == nil { + return false + } + } + return true + } + return false + }) { + } else if let participant = members.participants.first(where: { participant in + if participant.presentationDescription != nil { + return true + } + if participant.videoDescription != nil { + return true + } + return false + }) { + if participant.presentationDescription != nil { + self.expandedParticipantsVideoState = VideoChatParticipantsComponent.ExpandedVideoState(mainParticipant: VideoChatParticipantsComponent.VideoParticipantKey(id: participant.peer.id, isPresentation: true), isMainParticipantPinned: false, isUIHidden: expandedParticipantsVideoState.isUIHidden) + } else { + self.expandedParticipantsVideoState = VideoChatParticipantsComponent.ExpandedVideoState(mainParticipant: VideoChatParticipantsComponent.VideoParticipantKey(id: participant.peer.id, isPresentation: false), isMainParticipantPinned: false, isUIHidden: expandedParticipantsVideoState.isUIHidden) + } + } else { + self.expandedParticipantsVideoState = nil + } + } else { + self.expandedParticipantsVideoState = nil + } + + if !self.isUpdating { + self.state?.updated(transition: .spring(duration: 0.4)) + } + } + }) + + self.stateDisposable = (component.call.state + |> deliverOnMainQueue).startStrict(next: { [weak self] callState in + guard let self else { + return + } + if self.callState != callState { + self.callState = callState + + if !self.isUpdating { + self.state?.updated(transition: .spring(duration: 0.4)) + } + } + }) + + self.applicationStateDisposable = (combineLatest(queue: .mainQueue(), + component.call.accountContext.sharedContext.applicationBindings.applicationIsActive, + self.isPresentedValue.get() + ) + |> deliverOnMainQueue).startStrict(next: { [weak self] applicationIsActive, isPresented in + guard let self, let component = self.component else { + return + } + let suspendVideoChannelRequests = !applicationIsActive || !isPresented + component.call.setSuspendVideoChannelRequests(suspendVideoChannelRequests) + }) + + self.audioOutputStateDisposable = (component.call.audioOutputState + |> deliverOnMainQueue).start(next: { [weak self] state in + guard let self else { + return + } + + var existingOutputs = Set() + var filteredOutputs: [AudioSessionOutput] = [] + for output in state.0 { + if case let .port(port) = output { + if !existingOutputs.contains(port.name) { + existingOutputs.insert(port.name) + filteredOutputs.append(output) + } + } else { + filteredOutputs.append(output) + } + } + + self.audioOutputState = (filteredOutputs, state.1) + self.state?.updated(transition: .spring(duration: 0.4)) + }) + + let currentAccountPeer = component.call.accountContext.account.postbox.loadedPeerWithId(component.call.accountContext.account.peerId) + |> map { peer in + return [FoundPeer(peer: peer, subscribers: nil)] + } + let displayAsPeers: Signal<[FoundPeer], NoError> = currentAccountPeer + |> then( + combineLatest(currentAccountPeer, component.call.accountContext.engine.calls.cachedGroupCallDisplayAsAvailablePeers(peerId: component.call.peerId)) + |> map { currentAccountPeer, availablePeers -> [FoundPeer] in + var result = currentAccountPeer + result.append(contentsOf: availablePeers) + return result + } + ) + self.displayAsPeersDisposable = (displayAsPeers + |> deliverOnMainQueue).start(next: { [weak self] value in + guard let self else { + return + } + self.displayAsPeers = value + }) + + self.inviteLinksDisposable = (component.call.inviteLinks + |> deliverOnMainQueue).startStrict(next: { [weak self] value in + guard let self else { + return + } + self.inviteLinks = value + }) + + self.reconnectedAsEventsDisposable = (component.call.reconnectedAsEvents + |> deliverOnMainQueue).startStrict(next: { [weak self] peer in + guard let self, let component = self.component, let environment = self.environment else { + return + } + let text: String + if case let .channel(channel) = self.peer, case .broadcast = channel.info { + text = environment.strings.LiveStream_DisplayAsSuccess(peer.displayTitle(strings: environment.strings, displayOrder: component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).nameDisplayOrder)).string + } else { + text = environment.strings.VoiceChat_DisplayAsSuccess(peer.displayTitle(strings: environment.strings, displayOrder: component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).nameDisplayOrder)).string + } + self.presentUndoOverlay(content: .invitedToVoiceChat(context: component.call.accountContext, peer: peer, title: nil, text: text, action: nil, duration: 3), action: { _ in return false }) + }) + } + + self.isPresentedValue.set(environment.isVisible) + + self.component = component + self.environment = environment + self.state = state + + if themeUpdated { + self.containerView.backgroundColor = .black + } + + var mappedParticipants: VideoChatParticipantsComponent.Participants? + if let members = self.members, let callState = self.callState { + var canInvite = true + var inviteIsLink = false + if case let .channel(peer) = self.peer { + if peer.flags.contains(.isGigagroup) { + if peer.flags.contains(.isCreator) || peer.adminRights != nil { + } else { + canInvite = false + } + } + if case .broadcast = peer.info, !(peer.addressName?.isEmpty ?? true) { + inviteIsLink = true + } + } + var inviteType: VideoChatParticipantsComponent.Participants.InviteType? + if canInvite { + if inviteIsLink { + inviteType = .shareLink + } else { + inviteType = .invite + } + } + + mappedParticipants = VideoChatParticipantsComponent.Participants( + myPeerId: callState.myPeerId, + participants: members.participants, + totalCount: members.totalCount, + loadMoreToken: members.loadMoreToken, + inviteType: inviteType + ) + } + + let maxSingleColumnWidth: CGFloat = 620.0 + let isTwoColumnLayout: Bool + if availableSize.width > maxSingleColumnWidth { + if let mappedParticipants, mappedParticipants.participants.contains(where: { $0.videoDescription != nil || $0.presentationDescription != nil }) { + isTwoColumnLayout = true + } else { + isTwoColumnLayout = false + } + } else { + isTwoColumnLayout = false + } + + var containerOffset: CGFloat = 0.0 + if let panGestureState = self.panGestureState { + containerOffset = panGestureState.offsetFraction * availableSize.height + self.containerView.layer.cornerRadius = environment.deviceMetrics.screenCornerRadius + } + + transition.setFrame(view: self.containerView, frame: CGRect(origin: CGPoint(x: 0.0, y: containerOffset), size: availableSize), completion: { [weak self] completed in + guard let self, completed else { + return + } + if self.panGestureState == nil { + self.containerView.layer.cornerRadius = 0.0 + } + if self.notifyDismissedInteractivelyOnPanGestureApply { + self.notifyDismissedInteractivelyOnPanGestureApply = false + + if let controller = self.environment?.controller() as? VideoChatScreenV2Impl { + if self.isUpdating { + DispatchQueue.main.async { [weak controller] in + controller?.superDismiss() + } + } else { + controller.superDismiss() + } + } + } + if let completionOnPanGestureApply = self.completionOnPanGestureApply { + self.completionOnPanGestureApply = nil + DispatchQueue.main.async { + completionOnPanGestureApply() + } + } + }) + + let sideInset: CGFloat = max(environment.safeInsets.left, 14.0) + + let topInset: CGFloat = environment.statusBarHeight + 2.0 + let navigationBarHeight: CGFloat = 61.0 + let navigationHeight = topInset + navigationBarHeight + + let navigationButtonAreaWidth: CGFloat = 40.0 + let navigationButtonDiameter: CGFloat = 28.0 + + let navigationLeftButtonSize = self.navigationLeftButton.update( + transition: .immediate, + component: AnyComponent(PlainButtonComponent( + content: AnyComponent(LottieComponent( + content: LottieComponent.AppBundleContent( + name: "anim_profilemore" + ), + color: .white + )), + background: AnyComponent(Circle( + fillColor: UIColor(white: 1.0, alpha: 0.1), + size: CGSize(width: navigationButtonDiameter, height: navigationButtonDiameter) + )), + effectAlignment: .center, + action: { [weak self] in + guard let self else { + return + } + self.openMoreMenu() + } + )), + environment: {}, + containerSize: CGSize(width: navigationButtonDiameter, height: navigationButtonDiameter) + ) + + let navigationRightButtonSize = self.navigationRightButton.update( + transition: .immediate, + component: AnyComponent(PlainButtonComponent( + content: AnyComponent(Image( + image: closeButtonImage(dark: false) + )), + background: AnyComponent(Circle( + fillColor: UIColor(white: 1.0, alpha: 0.1), + size: CGSize(width: navigationButtonDiameter, height: navigationButtonDiameter) + )), + effectAlignment: .center, + action: { [weak self] in + guard let self else { + return + } + self.environment?.controller()?.dismiss() + } + )), + environment: {}, + containerSize: CGSize(width: navigationButtonDiameter, height: navigationButtonDiameter) + ) + + let navigationLeftButtonFrame = CGRect(origin: CGPoint(x: sideInset + floor((navigationButtonAreaWidth - navigationLeftButtonSize.width) * 0.5), y: topInset + floor((navigationBarHeight - navigationLeftButtonSize.height) * 0.5)), size: navigationLeftButtonSize) + if let navigationLeftButtonView = self.navigationLeftButton.view { + if navigationLeftButtonView.superview == nil { + self.containerView.addSubview(navigationLeftButtonView) + } + transition.setFrame(view: navigationLeftButtonView, frame: navigationLeftButtonFrame) + } + + let navigationRightButtonFrame = CGRect(origin: CGPoint(x: availableSize.width - sideInset - navigationButtonAreaWidth + floor((navigationButtonAreaWidth - navigationRightButtonSize.width) * 0.5), y: topInset + floor((navigationBarHeight - navigationRightButtonSize.height) * 0.5)), size: navigationRightButtonSize) + if let navigationRightButtonView = self.navigationRightButton.view { + if navigationRightButtonView.superview == nil { + self.containerView.addSubview(navigationRightButtonView) + } + transition.setFrame(view: navigationRightButtonView, frame: navigationRightButtonFrame) + } + + if isTwoColumnLayout, !"".isEmpty { + var navigationSidebarButtonTransition = transition + let navigationSidebarButton: ComponentView + if let current = self.navigationSidebarButton { + navigationSidebarButton = current + } else { + navigationSidebarButtonTransition = navigationSidebarButtonTransition.withAnimation(.none) + navigationSidebarButton = ComponentView() + self.navigationSidebarButton = navigationSidebarButton + } + let navigationSidebarButtonSize = navigationSidebarButton.update( + transition: .immediate, + component: AnyComponent(PlainButtonComponent( + content: AnyComponent(BundleIconComponent( + name: "Call/PanelIcon", + tintColor: .white + )), + background: AnyComponent(Circle( + fillColor: UIColor(white: 1.0, alpha: 0.1), + size: CGSize(width: navigationButtonDiameter, height: navigationButtonDiameter) + )), + effectAlignment: .center, + action: { [weak self] in + guard let self else { + return + } + if let expandedParticipantsVideoState = self.expandedParticipantsVideoState { + self.expandedParticipantsVideoState = VideoChatParticipantsComponent.ExpandedVideoState( + mainParticipant: expandedParticipantsVideoState.mainParticipant, + isMainParticipantPinned: expandedParticipantsVideoState.isMainParticipantPinned, + isUIHidden: !expandedParticipantsVideoState.isUIHidden + ) + self.state?.updated(transition: .spring(duration: 0.4)) + } else { + + } + } + )), + environment: {}, + containerSize: CGSize(width: navigationButtonDiameter, height: navigationButtonDiameter) + ) + let navigationSidebarButtonFrame = CGRect(origin: CGPoint(x: navigationRightButtonFrame.minX - 32.0 - navigationSidebarButtonSize.width, y: topInset + floor((navigationBarHeight - navigationSidebarButtonSize.height) * 0.5)), size: navigationSidebarButtonSize) + if let navigationSidebarButtonView = navigationSidebarButton.view { + if navigationSidebarButtonView.superview == nil { + if let navigationRightButtonView = self.navigationRightButton.view { + self.containerView.insertSubview(navigationSidebarButtonView, aboveSubview: navigationRightButtonView) + } + } + transition.setFrame(view: navigationSidebarButtonView, frame: navigationSidebarButtonFrame) + } + } else if let navigationSidebarButton = self.navigationSidebarButton { + self.navigationSidebarButton = nil + if let navigationSidebarButtonView = navigationSidebarButton.view { + transition.setScale(view: navigationSidebarButtonView, scale: 0.001) + transition.setAlpha(view: navigationSidebarButtonView, alpha: 0.0, completion: { [weak navigationSidebarButtonView] _ in + navigationSidebarButtonView?.removeFromSuperview() + }) + } + } + + let idleTitleStatusText: String + if let callState = self.callState, callState.networkState == .connected, let members = self.members { + idleTitleStatusText = environment.strings.VoiceChat_Panel_Members(Int32(max(1, members.totalCount))) + } else { + idleTitleStatusText = "connecting..." + } + let titleSize = self.title.update( + transition: transition, + component: AnyComponent(VideoChatTitleComponent( + title: self.callState?.title ?? self.peer?.debugDisplayTitle ?? " ", + status: idleTitleStatusText, + strings: environment.strings + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - sideInset * 2.0 - navigationButtonAreaWidth * 2.0 - 4.0 * 2.0, height: 100.0) + ) + let titleFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - titleSize.width) * 0.5), y: topInset + floor((navigationBarHeight - titleSize.height) * 0.5)), size: titleSize) + if let titleView = self.title.view { + if titleView.superview == nil { + self.containerView.addSubview(titleView) + } + transition.setFrame(view: titleView, frame: titleFrame) + } + + let areButtonsCollapsed: Bool + let mainColumnWidth: CGFloat + let mainColumnSideInset: CGFloat + + if isTwoColumnLayout { + areButtonsCollapsed = false + + mainColumnWidth = 320.0 + mainColumnSideInset = 0.0 + } else { + areButtonsCollapsed = self.expandedParticipantsVideoState != nil + + if availableSize.width > maxSingleColumnWidth { + mainColumnWidth = 420.0 + mainColumnSideInset = 0.0 + } else { + mainColumnWidth = availableSize.width + mainColumnSideInset = sideInset + } + } + + let actionButtonDiameter: CGFloat = 56.0 + let expandedMicrophoneButtonDiameter: CGFloat = actionButtonDiameter + var collapsedMicrophoneButtonDiameter: CGFloat = 116.0 + + let maxActionMicrophoneButtonSpacing: CGFloat = 38.0 + let minActionMicrophoneButtonSpacing: CGFloat = 20.0 + + if actionButtonDiameter * 2.0 + collapsedMicrophoneButtonDiameter + maxActionMicrophoneButtonSpacing * 2.0 > mainColumnWidth { + collapsedMicrophoneButtonDiameter = mainColumnWidth - (actionButtonDiameter * 2.0 + minActionMicrophoneButtonSpacing * 2.0) + collapsedMicrophoneButtonDiameter = max(actionButtonDiameter, collapsedMicrophoneButtonDiameter) + } + + let microphoneButtonDiameter: CGFloat + if isTwoColumnLayout { + microphoneButtonDiameter = collapsedMicrophoneButtonDiameter + } else { + if areButtonsCollapsed { + microphoneButtonDiameter = expandedMicrophoneButtonDiameter + } else { + microphoneButtonDiameter = self.expandedParticipantsVideoState == nil ? collapsedMicrophoneButtonDiameter : expandedMicrophoneButtonDiameter + } + } + + let buttonsSideInset: CGFloat = 42.0 + + let buttonsWidth: CGFloat = actionButtonDiameter * 2.0 + microphoneButtonDiameter + let remainingButtonsSpace: CGFloat = availableSize.width - buttonsSideInset * 2.0 - buttonsWidth + let actionMicrophoneButtonSpacing = min(maxActionMicrophoneButtonSpacing, floor(remainingButtonsSpace * 0.5)) + + var collapsedMicrophoneButtonFrame: CGRect = CGRect(origin: CGPoint(x: floor((availableSize.width - collapsedMicrophoneButtonDiameter) * 0.5), y: availableSize.height - 48.0 - environment.safeInsets.bottom - collapsedMicrophoneButtonDiameter), size: CGSize(width: collapsedMicrophoneButtonDiameter, height: collapsedMicrophoneButtonDiameter)) + var expandedMicrophoneButtonFrame: CGRect = CGRect(origin: CGPoint(x: floor((availableSize.width - expandedMicrophoneButtonDiameter) * 0.5), y: availableSize.height - environment.safeInsets.bottom - expandedMicrophoneButtonDiameter - 12.0), size: CGSize(width: expandedMicrophoneButtonDiameter, height: expandedMicrophoneButtonDiameter)) + if isTwoColumnLayout { + if let expandedParticipantsVideoState = self.expandedParticipantsVideoState, expandedParticipantsVideoState.isUIHidden { + collapsedMicrophoneButtonFrame.origin.x = availableSize.width - sideInset - mainColumnWidth + floor((mainColumnWidth - collapsedMicrophoneButtonDiameter) * 0.5) + sideInset + mainColumnWidth + } else { + collapsedMicrophoneButtonFrame.origin.x = availableSize.width - sideInset - mainColumnWidth + floor((mainColumnWidth - collapsedMicrophoneButtonDiameter) * 0.5) + } + expandedMicrophoneButtonFrame = collapsedMicrophoneButtonFrame + } else { + if let expandedParticipantsVideoState = self.expandedParticipantsVideoState, expandedParticipantsVideoState.isUIHidden { + expandedMicrophoneButtonFrame.origin.y = availableSize.height + expandedMicrophoneButtonDiameter + 12.0 + } + } + + let microphoneButtonFrame: CGRect + if areButtonsCollapsed { + microphoneButtonFrame = expandedMicrophoneButtonFrame + } else { + microphoneButtonFrame = collapsedMicrophoneButtonFrame + } + + let collapsedParticipantsClippingY: CGFloat + collapsedParticipantsClippingY = collapsedMicrophoneButtonFrame.minY - 16.0 + + let expandedParticipantsClippingY: CGFloat + if let expandedParticipantsVideoState = self.expandedParticipantsVideoState, expandedParticipantsVideoState.isUIHidden { + if isTwoColumnLayout { + expandedParticipantsClippingY = expandedMicrophoneButtonFrame.minY - 24.0 + } else { + expandedParticipantsClippingY = availableSize.height - max(14.0, environment.safeInsets.bottom) + } + } else { + expandedParticipantsClippingY = expandedMicrophoneButtonFrame.minY - 24.0 + } + + let leftActionButtonFrame = CGRect(origin: CGPoint(x: microphoneButtonFrame.minX - actionMicrophoneButtonSpacing - actionButtonDiameter, y: microphoneButtonFrame.minY + floor((microphoneButtonFrame.height - actionButtonDiameter) * 0.5)), size: CGSize(width: actionButtonDiameter, height: actionButtonDiameter)) + let rightActionButtonFrame = CGRect(origin: CGPoint(x: microphoneButtonFrame.maxX + actionMicrophoneButtonSpacing, y: microphoneButtonFrame.minY + floor((microphoneButtonFrame.height - actionButtonDiameter) * 0.5)), size: CGSize(width: actionButtonDiameter, height: actionButtonDiameter)) + + let participantsSize = availableSize + + let columnSpacing: CGFloat = 14.0 + let participantsLayout: VideoChatParticipantsComponent.Layout + if isTwoColumnLayout { + let mainColumnInsets: UIEdgeInsets = UIEdgeInsets(top: navigationHeight, left: mainColumnSideInset, bottom: availableSize.height - collapsedParticipantsClippingY, right: mainColumnSideInset) + let videoColumnWidth: CGFloat = max(10.0, availableSize.width - sideInset * 2.0 - mainColumnWidth - columnSpacing) + participantsLayout = VideoChatParticipantsComponent.Layout( + videoColumn: VideoChatParticipantsComponent.Layout.Column( + width: videoColumnWidth, + insets: UIEdgeInsets(top: navigationHeight, left: 0.0, bottom: max(14.0, environment.safeInsets.bottom), right: 0.0) + ), + mainColumn: VideoChatParticipantsComponent.Layout.Column( + width: mainColumnWidth, + insets: mainColumnInsets + ), + columnSpacing: columnSpacing + ) + } else { + let mainColumnInsets: UIEdgeInsets = UIEdgeInsets(top: navigationHeight, left: mainColumnSideInset, bottom: availableSize.height - collapsedParticipantsClippingY, right: mainColumnSideInset) + participantsLayout = VideoChatParticipantsComponent.Layout( + videoColumn: nil, + mainColumn: VideoChatParticipantsComponent.Layout.Column( + width: mainColumnWidth, + insets: mainColumnInsets + ), + columnSpacing: columnSpacing + ) + } + + let participantsSafeInsets = UIEdgeInsets( + top: environment.statusBarHeight, + left: environment.safeInsets.left, + bottom: max(14.0, environment.safeInsets.bottom), + right: environment.safeInsets.right + ) + let participantsExpandedInsets: UIEdgeInsets + if isTwoColumnLayout { + participantsExpandedInsets = UIEdgeInsets( + top: navigationHeight, + left: max(14.0, participantsSafeInsets.left), + bottom: participantsSafeInsets.bottom, + right: max(14.0, participantsSafeInsets.right) + ) + } else { + participantsExpandedInsets = UIEdgeInsets( + top: participantsSafeInsets.top, + left: participantsSafeInsets.left, + bottom: availableSize.height - expandedParticipantsClippingY, + right: participantsSafeInsets.right + ) + } + + let _ = self.participants.update( + transition: transition, + component: AnyComponent(VideoChatParticipantsComponent( + call: component.call, + participants: mappedParticipants, + speakingParticipants: members?.speakingParticipants ?? Set(), + expandedVideoState: self.expandedParticipantsVideoState, + theme: environment.theme, + strings: environment.strings, + layout: participantsLayout, + expandedInsets: participantsExpandedInsets, + safeInsets: participantsSafeInsets, + interfaceOrientation: environment.orientation ?? .portrait, + openParticipantContextMenu: { [weak self] id, sourceView, gesture in + guard let self else { + return + } + self.openParticipantContextMenu(id: id, sourceView: sourceView, gesture: gesture) + }, + updateMainParticipant: { [weak self] key in + guard let self else { + return + } + if let key { + if let expandedParticipantsVideoState = self.expandedParticipantsVideoState, expandedParticipantsVideoState.mainParticipant == key { + return + } + self.expandedParticipantsVideoState = VideoChatParticipantsComponent.ExpandedVideoState(mainParticipant: key, isMainParticipantPinned: false, isUIHidden: self.expandedParticipantsVideoState?.isUIHidden ?? false) + self.state?.updated(transition: .spring(duration: 0.4)) + } else if self.expandedParticipantsVideoState != nil { + self.expandedParticipantsVideoState = nil + self.state?.updated(transition: .spring(duration: 0.4)) + } + }, + updateIsMainParticipantPinned: { [weak self] isPinned in + guard let self else { + return + } + guard let expandedParticipantsVideoState = self.expandedParticipantsVideoState else { + return + } + let updatedExpandedParticipantsVideoState = VideoChatParticipantsComponent.ExpandedVideoState( + mainParticipant: expandedParticipantsVideoState.mainParticipant, + isMainParticipantPinned: isPinned, + isUIHidden: expandedParticipantsVideoState.isUIHidden + ) + if self.expandedParticipantsVideoState != updatedExpandedParticipantsVideoState { + self.expandedParticipantsVideoState = updatedExpandedParticipantsVideoState + self.state?.updated(transition: .spring(duration: 0.4)) + } + }, + updateIsExpandedUIHidden: { [weak self] isUIHidden in + guard let self else { + return + } + guard let expandedParticipantsVideoState = self.expandedParticipantsVideoState else { + return + } + let updatedExpandedParticipantsVideoState = VideoChatParticipantsComponent.ExpandedVideoState( + mainParticipant: expandedParticipantsVideoState.mainParticipant, + isMainParticipantPinned: expandedParticipantsVideoState.isMainParticipantPinned, + isUIHidden: isUIHidden + ) + if self.expandedParticipantsVideoState != updatedExpandedParticipantsVideoState { + self.expandedParticipantsVideoState = updatedExpandedParticipantsVideoState + self.state?.updated(transition: .spring(duration: 0.4)) + } + }, + openInviteMembers: { [weak self] in + guard let self else { + return + } + self.openInviteMembers() + } + )), + environment: {}, + containerSize: participantsSize + ) + let participantsFrame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: participantsSize) + if let participantsView = self.participants.view { + if participantsView.superview == nil { + self.containerView.addSubview(participantsView) + } + transition.setFrame(view: participantsView, frame: participantsFrame) + } + + let micButtonContent: VideoChatMicButtonComponent.Content + let actionButtonMicrophoneState: VideoChatActionButtonComponent.MicrophoneState + if let callState = self.callState { + switch callState.networkState { + case .connecting: + micButtonContent = .connecting + actionButtonMicrophoneState = .connecting + case .connected: + if let callState = callState.muteState { + if callState.canUnmute { + if self.isPushToTalkActive { + micButtonContent = .unmuted(pushToTalk: self.isPushToTalkActive) + actionButtonMicrophoneState = .unmuted + } else { + micButtonContent = .muted + actionButtonMicrophoneState = .muted + } + } else { + micButtonContent = .raiseHand + actionButtonMicrophoneState = .raiseHand + } + } else { + micButtonContent = .unmuted(pushToTalk: false) + actionButtonMicrophoneState = .unmuted + } + } + } else { + micButtonContent = .connecting + actionButtonMicrophoneState = .connecting + } + + let _ = self.microphoneButton.update( + transition: transition, + component: AnyComponent(VideoChatMicButtonComponent( + call: component.call, + content: micButtonContent, + isCollapsed: areButtonsCollapsed, + updateUnmutedStateIsPushToTalk: { [weak self] unmutedStateIsPushToTalk in + guard let self, let component = self.component else { + return + } + guard let callState = self.callState else { + return + } + + if let unmutedStateIsPushToTalk { + if unmutedStateIsPushToTalk { + if let muteState = callState.muteState { + if muteState.canUnmute { + self.isPushToTalkActive = true + component.call.setIsMuted(action: .muted(isPushToTalkActive: true)) + } else { + self.isPushToTalkActive = false + } + } else { + self.isPushToTalkActive = true + component.call.setIsMuted(action: .muted(isPushToTalkActive: true)) + } + } else { + if let muteState = callState.muteState { + if muteState.canUnmute { + component.call.setIsMuted(action: .unmuted) + } + } + self.isPushToTalkActive = false + } + self.state?.updated(transition: .spring(duration: 0.5)) + } else { + component.call.setIsMuted(action: .muted(isPushToTalkActive: false)) + self.isPushToTalkActive = false + self.state?.updated(transition: .spring(duration: 0.5)) + } + }, + raiseHand: { [weak self] in + guard let self, let component = self.component else { + return + } + guard let callState = self.callState else { + return + } + if !callState.raisedHand { + component.call.raiseHand() + } + } + )), + environment: {}, + containerSize: CGSize(width: microphoneButtonDiameter, height: microphoneButtonDiameter) + ) + if let microphoneButtonView = self.microphoneButton.view { + if microphoneButtonView.superview == nil { + self.containerView.addSubview(microphoneButtonView) + } + transition.setPosition(view: microphoneButtonView, position: microphoneButtonFrame.center) + transition.setBounds(view: microphoneButtonView, bounds: CGRect(origin: CGPoint(), size: microphoneButtonFrame.size)) + } + + let videoButtonContent: VideoChatActionButtonComponent.Content + if let callState = self.callState, let muteState = callState.muteState, !muteState.canUnmute { + var buttonAudio: VideoChatActionButtonComponent.Content.Audio = .speaker + if let (availableOutputs, maybeCurrentOutput) = self.audioOutputState, let currentOutput = maybeCurrentOutput { + switch currentOutput { + case .builtin: + buttonAudio = .builtin + case .speaker: + buttonAudio = .speaker + case .headphones: + buttonAudio = .headphones + case let .port(port): + var type: VideoChatActionButtonComponent.Content.BluetoothType = .generic + let portName = port.name.lowercased() + if portName.contains("airpods max") { + type = .airpodsMax + } else if portName.contains("airpods pro") { + type = .airpodsPro + } else if portName.contains("airpods") { + type = .airpods + } + buttonAudio = .bluetooth(type) + } + if availableOutputs.count <= 1 { + buttonAudio = .none + } + } + videoButtonContent = .audio(audio: buttonAudio) + } else { + //TODO:release + videoButtonContent = .video(isActive: false) + } + let _ = self.videoButton.update( + transition: transition, + component: AnyComponent(PlainButtonComponent( + content: AnyComponent(VideoChatActionButtonComponent( + strings: environment.strings, + content: videoButtonContent, + microphoneState: actionButtonMicrophoneState, + isCollapsed: areButtonsCollapsed + )), + effectAlignment: .center, + action: { [weak self] in + guard let self else { + return + } + if let callState = self.callState, let muteState = callState.muteState, !muteState.canUnmute { + self.onAudioRoutePressed() + } else { + self.onCameraPressed() + } + }, + animateAlpha: false + )), + environment: {}, + containerSize: CGSize(width: actionButtonDiameter, height: actionButtonDiameter) + ) + if let videoButtonView = self.videoButton.view { + if videoButtonView.superview == nil { + self.containerView.addSubview(videoButtonView) + } + transition.setPosition(view: videoButtonView, position: leftActionButtonFrame.center) + transition.setBounds(view: videoButtonView, bounds: CGRect(origin: CGPoint(), size: leftActionButtonFrame.size)) + } + + let _ = self.leaveButton.update( + transition: transition, + component: AnyComponent(PlainButtonComponent( + content: AnyComponent(VideoChatActionButtonComponent( + strings: environment.strings, + content: .leave, + microphoneState: actionButtonMicrophoneState, + isCollapsed: areButtonsCollapsed + )), + effectAlignment: .center, + action: { [weak self] in + guard let self, let component = self.component else { + return + } + let _ = component.call.leave(terminateIfPossible: false).startStandalone() + + if let controller = self.environment?.controller() as? VideoChatScreenV2Impl { + controller.dismiss(closing: true, manual: false) + } + }, + animateAlpha: false + )), + environment: {}, + containerSize: CGSize(width: actionButtonDiameter, height: actionButtonDiameter) + ) + if let leaveButtonView = self.leaveButton.view { + if leaveButtonView.superview == nil { + self.containerView.addSubview(leaveButtonView) + } + transition.setPosition(view: leaveButtonView, position: rightActionButtonFrame.center) + transition.setBounds(view: leaveButtonView, bounds: CGRect(origin: CGPoint(), size: rightActionButtonFrame.size)) + } + + return availableSize + } + } + + func makeView() -> View { + return View() + } + + func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } +} + +final class VideoChatScreenV2Impl: ViewControllerComponentContainer, VoiceChatController { + final class InitialData { + let peer: EnginePeer? + let members: PresentationGroupCallMembers? + let callState: PresentationGroupCallState + + init( + peer: EnginePeer?, + members: PresentationGroupCallMembers?, + callState: PresentationGroupCallState + ) { + self.peer = peer + self.members = members + self.callState = callState + } + } + + public let call: PresentationGroupCall + public var currentOverlayController: VoiceChatOverlayController? + public var parentNavigationController: NavigationController? + + public var onViewDidAppear: (() -> Void)? + public var onViewDidDisappear: (() -> Void)? + + private var isDismissed: Bool = true + private var didAppearOnce: Bool = false + private var isAnimatingDismiss: Bool = false + + private var idleTimerExtensionDisposable: Disposable? + + public init( + initialData: InitialData, + call: PresentationGroupCall + ) { + self.call = call + + let theme = customizeDefaultDarkPresentationTheme( + theme: defaultDarkPresentationTheme, + editing: false, + title: nil, + accentColor: UIColor(rgb: 0x3E88F7), + backgroundColors: [], + bubbleColors: [], + animateBubbleColors: false + ) + + super.init( + context: call.accountContext, + component: VideoChatScreenComponent( + initialData: initialData, + call: call + ), + navigationBarAppearance: .none, + statusBarStyle: .default, + presentationMode: .default, + theme: .custom(theme) + ) + } + + required init(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + self.idleTimerExtensionDisposable?.dispose() + } + + override public func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + if self.isDismissed { + self.isDismissed = false + + if let componentView = self.node.hostView.componentView as? VideoChatScreenComponent.View { + componentView.animateIn() + } + } + + if !self.didAppearOnce { + self.didAppearOnce = true + + self.idleTimerExtensionDisposable?.dispose() + self.idleTimerExtensionDisposable = self.call.accountContext.sharedContext.applicationBindings.pushIdleTimerExtension() + } + + DispatchQueue.main.async { + self.onViewDidAppear?() + } + } + + override public func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + + self.idleTimerExtensionDisposable?.dispose() + self.idleTimerExtensionDisposable = nil + + self.didAppearOnce = false + self.notifyDismissed() + } + + func notifyDismissed() { + if !self.isDismissed { + self.isDismissed = true + DispatchQueue.main.async { + self.onViewDidDisappear?() + } + } + } + + public func dismiss(closing: Bool, manual: Bool) { + self.dismiss() + } + + override public func dismiss(completion: (() -> Void)? = nil) { + if !self.isAnimatingDismiss { + self.notifyDismissed() + + if let componentView = self.node.hostView.componentView as? VideoChatScreenComponent.View { + self.isAnimatingDismiss = true + componentView.animateOut(completion: { [weak self] in + guard let self else { + return + } + self.isAnimatingDismiss = false + self.superDismiss() + }) + } else { + self.superDismiss() + } + } + } + + func superDismiss() { + super.dismiss() + } + + static func initialData(call: PresentationGroupCall) -> Signal { + return combineLatest( + call.accountContext.engine.data.get( + TelegramEngine.EngineData.Item.Peer.Peer(id: call.peerId) + ), + call.members |> take(1), + call.state |> take(1) + ) + |> map { peer, members, callState -> InitialData in + return InitialData( + peer: peer, + members: members, + callState: callState + ) + } + } +} + +private final class ParticipantExtractedContentSource: ContextExtractedContentSource { + let keepInPlace: Bool = false + let ignoreContentTouches: Bool = false + let blurBackground: Bool = true + + //let actionsHorizontalAlignment: ContextActionsHorizontalAlignment = .center + + private let contentView: ContextExtractedContentContainingView + + init(contentView: ContextExtractedContentContainingView) { + self.contentView = contentView + } + + func takeView() -> ContextControllerTakeViewInfo? { + return ContextControllerTakeViewInfo(containingItem: .view(self.contentView), contentAreaInScreenSpace: UIScreen.main.bounds) + } + + func putBack() -> ContextControllerPutBackViewInfo? { + return ContextControllerPutBackViewInfo(contentAreaInScreenSpace: UIScreen.main.bounds) + } +} diff --git a/submodules/TelegramCallsUI/Sources/VideoChatTitleComponent.swift b/submodules/TelegramCallsUI/Sources/VideoChatTitleComponent.swift new file mode 100644 index 00000000000..93b9115a314 --- /dev/null +++ b/submodules/TelegramCallsUI/Sources/VideoChatTitleComponent.swift @@ -0,0 +1,120 @@ +import Foundation +import UIKit +import Display +import ComponentFlow +import MultilineTextComponent +import TelegramPresentationData + +final class VideoChatTitleComponent: Component { + let title: String + let status: String + let strings: PresentationStrings + + init( + title: String, + status: String, + strings: PresentationStrings + ) { + self.title = title + self.status = status + self.strings = strings + } + + static func ==(lhs: VideoChatTitleComponent, rhs: VideoChatTitleComponent) -> Bool { + if lhs.title != rhs.title { + return false + } + if lhs.status != rhs.status { + return false + } + if lhs.strings !== rhs.strings { + return false + } + return true + } + + final class View: UIView { + private let title = ComponentView() + private var status: ComponentView? + + private var component: VideoChatTitleComponent? + private var isUpdating: Bool = false + + override init(frame: CGRect) { + super.init(frame: frame) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func update(component: VideoChatTitleComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + self.isUpdating = true + defer { + self.isUpdating = false + } + + self.component = component + + let spacing: CGFloat = 1.0 + + let titleSize = self.title.update( + transition: .immediate, + component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString(string: component.title, font: Font.semibold(17.0), textColor: .white)) + )), + environment: {}, + containerSize: CGSize(width: availableSize.width, height: 100.0) + ) + + let status: ComponentView + if let current = self.status { + status = current + } else { + status = ComponentView() + self.status = status + } + let statusComponent: AnyComponent + statusComponent = AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString(string: component.status, font: Font.regular(13.0), textColor: UIColor(white: 1.0, alpha: 0.5))) + )) + + let statusSize = status.update( + transition: .immediate, + component: statusComponent, + environment: {}, + containerSize: CGSize(width: availableSize.width, height: 100.0) + ) + + let size = CGSize(width: availableSize.width, height: titleSize.height + spacing + statusSize.height) + + let titleFrame = CGRect(origin: CGPoint(x: floor((size.width - titleSize.width) * 0.5), y: 0.0), size: titleSize) + if let titleView = self.title.view { + if titleView.superview == nil { + self.addSubview(titleView) + } + transition.setPosition(view: titleView, position: titleFrame.center) + titleView.bounds = CGRect(origin: CGPoint(), size: titleFrame.size) + } + + let statusFrame = CGRect(origin: CGPoint(x: floor((size.width - statusSize.width) * 0.5), y: titleFrame.maxY + spacing), size: statusSize) + if let statusView = status.view { + if statusView.superview == nil { + self.addSubview(statusView) + } + transition.setPosition(view: statusView, position: statusFrame.center) + statusView.bounds = CGRect(origin: CGPoint(), size: statusFrame.size) + } + + return size + } + } + + func makeView() -> View { + return View() + } + + func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } +} diff --git a/submodules/TelegramCallsUI/Sources/VideoChatVideoContext.swift b/submodules/TelegramCallsUI/Sources/VideoChatVideoContext.swift new file mode 100644 index 00000000000..c7d67189cb8 --- /dev/null +++ b/submodules/TelegramCallsUI/Sources/VideoChatVideoContext.swift @@ -0,0 +1,6 @@ +import Foundation +import SwiftSignalKit + +final class VideoChatVideoContext { + +} diff --git a/submodules/TelegramCallsUI/Sources/VideoChatVideoLoadingEffectView.swift b/submodules/TelegramCallsUI/Sources/VideoChatVideoLoadingEffectView.swift new file mode 100644 index 00000000000..8b812681487 --- /dev/null +++ b/submodules/TelegramCallsUI/Sources/VideoChatVideoLoadingEffectView.swift @@ -0,0 +1,132 @@ +import Foundation +import UIKit +import Display +import ComponentFlow +import HierarchyTrackingLayer + +private let shadowImage: UIImage? = { + UIImage(named: "Stories/PanelGradient") +}() + +final class VideoChatVideoLoadingEffectView: UIView { + private let duration: Double + private let hasCustomBorder: Bool + private let playOnce: Bool + + private let hierarchyTrackingLayer: HierarchyTrackingLayer + + private let gradientWidth: CGFloat + + let portalSource: PortalSourceView + + private let backgroundView: UIImageView + + private let borderGradientView: UIImageView + private let borderContainerView: UIView + let borderMaskLayer: SimpleShapeLayer + + private var didPlayOnce = false + + init(effectAlpha: CGFloat, borderAlpha: CGFloat, gradientWidth: CGFloat = 200.0, duration: Double, hasCustomBorder: Bool, playOnce: Bool) { + self.portalSource = PortalSourceView() + + self.hierarchyTrackingLayer = HierarchyTrackingLayer() + + self.duration = duration + self.hasCustomBorder = hasCustomBorder + self.playOnce = playOnce + + self.gradientWidth = gradientWidth + self.backgroundView = UIImageView() + + self.borderGradientView = UIImageView() + self.borderContainerView = UIView() + self.borderMaskLayer = SimpleShapeLayer() + + super.init(frame: .zero) + + self.portalSource.backgroundColor = .red + + self.portalSource.layer.addSublayer(self.hierarchyTrackingLayer) + self.hierarchyTrackingLayer.didEnterHierarchy = { [weak self] in + guard let self, self.bounds.width != 0.0 else { + return + } + self.updateAnimations(size: self.bounds.size) + } + + let generateGradient: (CGFloat) -> UIImage? = { baseAlpha in + return generateImage(CGSize(width: self.gradientWidth, height: 16.0), opaque: false, scale: 1.0, rotatedContext: { size, context in + context.clear(CGRect(origin: CGPoint(), size: size)) + + let foregroundColor = UIColor(white: 1.0, alpha: min(1.0, baseAlpha * 4.0)) + + if let shadowImage { + UIGraphicsPushContext(context) + + for i in 0 ..< 2 { + let shadowFrame = CGRect(origin: CGPoint(x: CGFloat(i) * (size.width * 0.5), y: 0.0), size: CGSize(width: size.width * 0.5, height: size.height)) + + context.saveGState() + context.translateBy(x: shadowFrame.midX, y: shadowFrame.midY) + context.rotate(by: CGFloat(i == 0 ? 1.0 : -1.0) * CGFloat.pi * 0.5) + let adjustedRect = CGRect(origin: CGPoint(x: -shadowFrame.height * 0.5, y: -shadowFrame.width * 0.5), size: CGSize(width: shadowFrame.height, height: shadowFrame.width)) + + context.clip(to: adjustedRect, mask: shadowImage.cgImage!) + context.setFillColor(foregroundColor.cgColor) + context.fill(adjustedRect) + + context.restoreGState() + } + + UIGraphicsPopContext() + } + }) + } + self.backgroundView.image = generateGradient(effectAlpha) + self.portalSource.addSubview(self.backgroundView) + + self.borderGradientView.image = generateGradient(borderAlpha) + self.borderContainerView.addSubview(self.borderGradientView) + self.portalSource.addSubview(self.borderContainerView) + self.borderContainerView.layer.mask = self.borderMaskLayer + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func updateAnimations(size: CGSize) { + if self.backgroundView.layer.animation(forKey: "shimmer") != nil || (self.playOnce && self.didPlayOnce) { + return + } + + let animation = self.backgroundView.layer.makeAnimation(from: 0.0 as NSNumber, to: (size.width + self.gradientWidth + size.width * 0.2) as NSNumber, keyPath: "position.x", timingFunction: CAMediaTimingFunctionName.easeOut.rawValue, duration: self.duration, delay: 0.0, mediaTimingFunction: nil, removeOnCompletion: true, additive: true) + animation.repeatCount = self.playOnce ? 1 : Float.infinity + self.backgroundView.layer.add(animation, forKey: "shimmer") + self.borderGradientView.layer.add(animation, forKey: "shimmer") + + self.didPlayOnce = true + } + + func update(size: CGSize, transition: ComponentTransition) { + if self.backgroundView.bounds.size != size { + self.backgroundView.layer.removeAllAnimations() + + if !self.hasCustomBorder { + self.borderMaskLayer.fillColor = nil + self.borderMaskLayer.strokeColor = UIColor.white.cgColor + let lineWidth: CGFloat = 3.0 + self.borderMaskLayer.lineWidth = lineWidth + self.borderMaskLayer.path = UIBezierPath(roundedRect: CGRect(origin: CGPoint(), size: size).insetBy(dx: lineWidth * 0.5, dy: lineWidth * 0.5), cornerRadius: 12.0).cgPath + } + } + + transition.setFrame(view: self.backgroundView, frame: CGRect(origin: CGPoint(x: -self.gradientWidth, y: 0.0), size: CGSize(width: self.gradientWidth, height: size.height))) + + transition.setFrame(view: self.borderContainerView, frame: CGRect(origin: CGPoint(), size: size)) + transition.setFrame(view: self.borderGradientView, frame: CGRect(origin: CGPoint(x: -self.gradientWidth, y: 0.0), size: CGSize(width: self.gradientWidth, height: size.height))) + + self.updateAnimations(size: size) + } +} diff --git a/submodules/TelegramCallsUI/Sources/VideoRenderingContext.swift b/submodules/TelegramCallsUI/Sources/VideoRenderingContext.swift index 59256f13e77..41169b0e428 100644 --- a/submodules/TelegramCallsUI/Sources/VideoRenderingContext.swift +++ b/submodules/TelegramCallsUI/Sources/VideoRenderingContext.swift @@ -6,8 +6,6 @@ import SwiftSignalKit import AccountContext import TelegramVoip import AVFoundation -import CallScreen -import MetalEngine protocol VideoRenderingView: UIView { func setOnFirstFrameReceived(_ f: @escaping (Float) -> Void) @@ -36,40 +34,30 @@ class VideoRenderingContext { } #endif - func makeView(input: Signal, forceSampleBufferDisplayLayer: Bool = false) -> VideoRenderingView? { - if !forceSampleBufferDisplayLayer { - return CallScreenVideoView(input: input) - } - + func makeView(input: Signal, blur: Bool, forceSampleBufferDisplayLayer: Bool = false) -> VideoRenderingView? { #if targetEnvironment(simulator) + if blur { + #if DEBUG + return SampleBufferVideoRenderingView(input: input) + #else + return nil + #endif + } return SampleBufferVideoRenderingView(input: input) #else if #available(iOS 13.0, *), !forceSampleBufferDisplayLayer { - return MetalVideoRenderingView(renderingContext: self.metalContext, input: input, blur: false) + return MetalVideoRenderingView(renderingContext: self.metalContext, input: input, blur: blur) } else { + if blur { + return nil + } return SampleBufferVideoRenderingView(input: input) } #endif } func makeBlurView(input: Signal, mainView: VideoRenderingView?, forceSampleBufferDisplayLayer: Bool = false) -> VideoRenderingView? { - if let mainView = mainView as? CallScreenVideoView { - return CallScreenVideoBlurView(mainView: mainView) - } - - #if targetEnvironment(simulator) - #if DEBUG - return SampleBufferVideoRenderingView(input: input) - #else - return nil - #endif - #else - if #available(iOS 13.0, *), !forceSampleBufferDisplayLayer { - return MetalVideoRenderingView(renderingContext: self.metalContext, input: input, blur: true) - } else { - return nil - } - #endif + return self.makeView(input: input, blur: true, forceSampleBufferDisplayLayer: forceSampleBufferDisplayLayer) } func updateVisibility(isVisible: Bool) { @@ -96,189 +84,3 @@ extension PresentationCallVideoView.Orientation { } } } - -private final class CallScreenVideoView: UIView, VideoRenderingView { - private var isEnabled: Bool = false - - private var onFirstFrameReceived: ((Float) -> Void)? - private var onOrientationUpdated: ((PresentationCallVideoView.Orientation, CGFloat) -> Void)? - private var onIsMirroredUpdated: ((Bool) -> Void)? - - private var didReportFirstFrame: Bool = false - private var currentIsMirrored: Bool = false - private var currentOrientation: PresentationCallVideoView.Orientation = .rotation0 - private var currentAspect: CGFloat = 1.0 - - fileprivate let videoSource: AdaptedCallVideoSource - private var disposable: Disposable? - - fileprivate let videoLayer: PrivateCallVideoLayer - - init(input: Signal) { - self.videoLayer = PrivateCallVideoLayer() - self.videoLayer.masksToBounds = true - - self.videoSource = AdaptedCallVideoSource(videoStreamSignal: input) - - super.init(frame: CGRect()) - - self.layer.addSublayer(self.videoLayer) - - self.disposable = self.videoSource.addOnUpdated { [weak self] in - guard let self else { - return - } - - self.videoLayer.video = self.videoSource.currentOutput - - var notifyOrientationUpdated = false - var notifyIsMirroredUpdated = false - - if !self.didReportFirstFrame { - notifyOrientationUpdated = true - notifyIsMirroredUpdated = true - } - - if let currentOutput = self.videoSource.currentOutput { - let currentAspect: CGFloat - if currentOutput.resolution.height > 0.0 { - currentAspect = currentOutput.resolution.width / currentOutput.resolution.height - } else { - currentAspect = 1.0 - } - if self.currentAspect != currentAspect { - self.currentAspect = currentAspect - notifyOrientationUpdated = true - } - - let currentOrientation: PresentationCallVideoView.Orientation - if abs(currentOutput.rotationAngle - 0.0) < .ulpOfOne { - currentOrientation = .rotation0 - } else if abs(currentOutput.rotationAngle - Float.pi * 0.5) < .ulpOfOne { - currentOrientation = .rotation90 - } else if abs(currentOutput.rotationAngle - Float.pi) < .ulpOfOne { - currentOrientation = .rotation180 - } else if abs(currentOutput.rotationAngle - Float.pi * 3.0 / 2.0) < .ulpOfOne { - currentOrientation = .rotation270 - } else { - currentOrientation = .rotation0 - } - if self.currentOrientation != currentOrientation { - self.currentOrientation = currentOrientation - notifyOrientationUpdated = true - } - - let currentIsMirrored = !currentOutput.mirrorDirection.isEmpty - if self.currentIsMirrored != currentIsMirrored { - self.currentIsMirrored = currentIsMirrored - notifyIsMirroredUpdated = true - } - } - - if !self.didReportFirstFrame { - self.didReportFirstFrame = true - self.onFirstFrameReceived?(Float(self.currentAspect)) - } - - if notifyOrientationUpdated { - self.onOrientationUpdated?(self.currentOrientation, self.currentAspect) - } - - if notifyIsMirroredUpdated { - self.onIsMirroredUpdated?(self.currentIsMirrored) - } - } - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - deinit { - self.disposable?.dispose() - } - - func setOnFirstFrameReceived(_ f: @escaping (Float) -> Void) { - self.onFirstFrameReceived = f - self.didReportFirstFrame = false - } - - func setOnOrientationUpdated(_ f: @escaping (PresentationCallVideoView.Orientation, CGFloat) -> Void) { - self.onOrientationUpdated = f - } - - func getOrientation() -> PresentationCallVideoView.Orientation { - return self.currentOrientation - } - - func getAspect() -> CGFloat { - return self.currentAspect - } - - func setOnIsMirroredUpdated(_ f: @escaping (Bool) -> Void) { - self.onIsMirroredUpdated = f - } - - func updateIsEnabled(_ isEnabled: Bool) { - self.isEnabled = isEnabled - } - - func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition) { - if let currentOutput = self.videoSource.currentOutput { - let rotatedResolution = currentOutput.resolution - let videoSize = size - - let videoResolution = rotatedResolution.aspectFittedOrSmaller(CGSize(width: 1280, height: 1280)).aspectFittedOrSmaller(CGSize(width: videoSize.width * 3.0, height: videoSize.height * 3.0)) - let rotatedVideoResolution = videoResolution - - transition.updateFrame(layer: self.videoLayer, frame: CGRect(origin: CGPoint(), size: size)) - self.videoLayer.renderSpec = RenderLayerSpec(size: RenderSize(width: Int(rotatedVideoResolution.width), height: Int(rotatedVideoResolution.height)), edgeInset: 2) - } - } -} - -private final class CallScreenVideoBlurView: UIView, VideoRenderingView { - private weak var mainView: CallScreenVideoView? - - private let blurredLayer: MetalEngineSubjectLayer - - init(mainView: CallScreenVideoView) { - self.mainView = mainView - self.blurredLayer = mainView.videoLayer.blurredLayer - - super.init(frame: CGRect()) - - self.layer.addSublayer(self.blurredLayer) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - deinit { - } - - func setOnFirstFrameReceived(_ f: @escaping (Float) -> Void) { - } - - func setOnOrientationUpdated(_ f: @escaping (PresentationCallVideoView.Orientation, CGFloat) -> Void) { - } - - func getOrientation() -> PresentationCallVideoView.Orientation { - return .rotation0 - } - - func getAspect() -> CGFloat { - return 1.0 - } - - func setOnIsMirroredUpdated(_ f: @escaping (Bool) -> Void) { - } - - func updateIsEnabled(_ isEnabled: Bool) { - } - - func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition) { - transition.updateFrame(layer: self.blurredLayer, frame: CGRect(origin: CGPoint(), size: size)) - } -} diff --git a/submodules/TelegramCallsUI/Sources/VoiceChatController.swift b/submodules/TelegramCallsUI/Sources/VoiceChatController.swift index 3f8d31e7b10..8e1a2c12bed 100644 --- a/submodules/TelegramCallsUI/Sources/VoiceChatController.swift +++ b/submodules/TelegramCallsUI/Sources/VoiceChatController.swift @@ -245,11 +245,13 @@ public protocol VoiceChatController: ViewController { var call: PresentationGroupCall { get } var currentOverlayController: VoiceChatOverlayController? { get } var parentNavigationController: NavigationController? { get set } + var onViewDidAppear: (() -> Void)? { get set } + var onViewDidDisappear: (() -> Void)? { get set } func dismiss(closing: Bool, manual: Bool) } -public final class VoiceChatControllerImpl: ViewController, VoiceChatController { +final class VoiceChatControllerImpl: ViewController, VoiceChatController { enum DisplayMode { case modal(isExpanded: Bool, isFilled: Bool) case fullscreen(controlsHidden: Bool) @@ -2416,7 +2418,7 @@ public final class VoiceChatControllerImpl: ViewController, VoiceChatController } } else { if let input = (strongSelf.call as! PresentationGroupCallImpl).video(endpointId: endpointId) { - if let videoView = strongSelf.videoRenderingContext.makeView(input: input) { + if let videoView = strongSelf.videoRenderingContext.makeView(input: input, blur: false) { completion(GroupVideoNode(videoView: videoView, backdropVideoView: strongSelf.videoRenderingContext.makeBlurView(input: input, mainView: videoView))) } } @@ -2498,7 +2500,7 @@ public final class VoiceChatControllerImpl: ViewController, VoiceChatController private func openSettingsMenu(sourceNode: ASDisplayNode, gesture: ContextGesture?) { let items: Signal<[ContextMenuItem], NoError> = self.contextMenuMainItems() if let controller = self.controller { - let contextController = ContextController(presentationData: self.presentationData.withUpdated(theme: self.darkTheme), source: .reference(VoiceChatContextReferenceContentSource(controller: controller, sourceNode: self.optionsButton.referenceNode)), items: items |> map { ContextController.Items(content: .list($0)) }, gesture: gesture) + let contextController = ContextController(presentationData: self.presentationData.withUpdated(theme: self.darkTheme), source: .reference(VoiceChatContextReferenceContentSource(controller: controller, sourceView: self.optionsButton.referenceNode.view)), items: items |> map { ContextController.Items(content: .list($0)) }, gesture: gesture) controller.presentInGlobalOverlay(contextController) } } @@ -3736,7 +3738,7 @@ public final class VoiceChatControllerImpl: ViewController, VoiceChatController var isFrontCamera = true let videoCapturer = OngoingCallVideoCapturer() let input = videoCapturer.video() - if let videoView = strongSelf.videoRenderingContext.makeView(input: input) { + if let videoView = strongSelf.videoRenderingContext.makeView(input: input, blur: false) { videoView.updateIsEnabled(true) let cameraNode = GroupVideoNode(videoView: videoView, backdropVideoView: nil) @@ -3927,7 +3929,7 @@ public final class VoiceChatControllerImpl: ViewController, VoiceChatController transition.updateAlpha(node: self.bottomGradientNode, alpha: self.isLandscape ? 0.0 : 1.0) var isTablet = false - let videoFrame: CGRect + var videoFrame: CGRect let videoContainerFrame: CGRect if case .regular = layout.metrics.widthClass { isTablet = true @@ -3941,6 +3943,11 @@ public final class VoiceChatControllerImpl: ViewController, VoiceChatController videoFrame = CGRect(x: 0.0, y: videoTopEdgeY, width: isLandscape ? max(0.0, layout.size.width - layout.safeInsets.right - 92.0) : layout.size.width, height: videoBottomEdgeY - videoTopEdgeY) videoContainerFrame = CGRect(origin: CGPoint(), size: layout.size) } + + if videoFrame.width < 0.0 || videoFrame.height < 0.0 || !videoFrame.width.isNormal || !videoFrame.height.isNormal { + videoFrame = CGRect() + } + transition.updateFrame(node: self.mainStageContainerNode, frame: videoContainerFrame) transition.updateFrame(node: self.mainStageBackgroundNode, frame: videoFrame) if !self.mainStageNode.animating { @@ -5507,7 +5514,7 @@ public final class VoiceChatControllerImpl: ViewController, VoiceChatController self.requestedVideoSources.insert(channel.endpointId) let input = (self.call as! PresentationGroupCallImpl).video(endpointId: channel.endpointId) - if let input = input, let videoView = self.videoRenderingContext.makeView(input: input) { + if let input = input, let videoView = self.videoRenderingContext.makeView(input: input, blur: false) { let videoNode = GroupVideoNode(videoView: videoView, backdropVideoView: self.videoRenderingContext.makeBlurView(input: input, mainView: videoView)) self.readyVideoDisposables.set((combineLatest(videoNode.ready, .single(false) |> then(.single(true) |> delay(10.0, queue: Queue.mainQueue()))) @@ -7076,16 +7083,48 @@ private final class VoiceChatContextExtractedContentSource: ContextExtractedCont } } -private final class VoiceChatContextReferenceContentSource: ContextReferenceContentSource { +final class VoiceChatContextReferenceContentSource: ContextReferenceContentSource { private let controller: ViewController - private let sourceNode: ContextReferenceContentNode + private let sourceView: UIView - init(controller: ViewController, sourceNode: ContextReferenceContentNode) { + init(controller: ViewController, sourceView: UIView) { self.controller = controller - self.sourceNode = sourceNode + self.sourceView = sourceView } func transitionInfo() -> ContextControllerReferenceViewInfo? { - return ContextControllerReferenceViewInfo(referenceView: self.sourceNode.view, contentAreaInScreenSpace: UIScreen.main.bounds) + return ContextControllerReferenceViewInfo(referenceView: self.sourceView, contentAreaInScreenSpace: UIScreen.main.bounds) + } +} + +private func calculateUseV2(context: AccountContext) -> Bool { + /*var useV2 = true + if context.sharedContext.immediateExperimentalUISettings.disableCallV2 { + useV2 = false + } + if let data = context.currentAppConfiguration.with({ $0 }).data, let _ = data["ios_killswitch_disable_videochatui_v2"] { + useV2 = false + } + return useV2*/ + return false +} + +public func makeVoiceChatControllerInitialData(sharedContext: SharedAccountContext, accountContext: AccountContext, call: PresentationGroupCall) -> Signal { + let useV2 = calculateUseV2(context: accountContext) + + if useV2 { + return VideoChatScreenV2Impl.initialData(call: call) |> map { $0 as Any } + } else { + return .single(Void()) + } +} + +public func makeVoiceChatController(sharedContext: SharedAccountContext, accountContext: AccountContext, call: PresentationGroupCall, initialData: Any) -> VoiceChatController { + let useV2 = calculateUseV2(context: accountContext) + + if useV2 { + return VideoChatScreenV2Impl(initialData: initialData as! VideoChatScreenV2Impl.InitialData, call: call) + } else { + return VoiceChatControllerImpl(sharedContext: sharedContext, accountContext: accountContext, call: call) } } diff --git a/submodules/TelegramCallsUI/Sources/VoiceChatMainStageNode.swift b/submodules/TelegramCallsUI/Sources/VoiceChatMainStageNode.swift index f0869b74fa6..83714a88b81 100644 --- a/submodules/TelegramCallsUI/Sources/VoiceChatMainStageNode.swift +++ b/submodules/TelegramCallsUI/Sources/VoiceChatMainStageNode.swift @@ -24,18 +24,18 @@ private let backgroundCornerRadius: CGFloat = 11.0 private let fadeColor = UIColor(rgb: 0x000000, alpha: 0.5) private let destructiveColor: UIColor = UIColor(rgb: 0xff3b30) -private class VoiceChatPinButtonNode: HighlightTrackingButtonNode { +class VoiceChatPinButtonNode: HighlightTrackingButtonNode { private let pinButtonIconNode: VoiceChatPinNode private let pinButtonClippingnode: ASDisplayNode private let pinButtonTitleNode: ImmediateTextNode - init(presentationData: PresentationData) { + init(theme: PresentationTheme, strings: PresentationStrings) { self.pinButtonIconNode = VoiceChatPinNode() self.pinButtonClippingnode = ASDisplayNode() self.pinButtonClippingnode.clipsToBounds = true self.pinButtonTitleNode = ImmediateTextNode() - self.pinButtonTitleNode.attributedText = NSAttributedString(string: presentationData.strings.VoiceChat_Unpin, font: Font.regular(17.0), textColor: .white) + self.pinButtonTitleNode.attributedText = NSAttributedString(string: strings.VoiceChat_Unpin, font: Font.regular(17.0), textColor: .white) self.pinButtonTitleNode.alpha = 0.0 super.init() @@ -209,7 +209,7 @@ final class VoiceChatMainStageNode: ASDisplayNode { let presentationData = context.sharedContext.currentPresentationData.with { $0 } - self.pinButtonNode = VoiceChatPinButtonNode(presentationData: presentationData) + self.pinButtonNode = VoiceChatPinButtonNode(theme: presentationData.theme, strings: presentationData.strings) self.backdropAvatarNode = ImageNode() self.backdropAvatarNode.contentMode = .scaleAspectFill diff --git a/submodules/TelegramCore/Sources/Account/AccountIntermediateState.swift b/submodules/TelegramCore/Sources/Account/AccountIntermediateState.swift index d79353be1e5..fbf747c2cfb 100644 --- a/submodules/TelegramCore/Sources/Account/AccountIntermediateState.swift +++ b/submodules/TelegramCore/Sources/Account/AccountIntermediateState.swift @@ -131,6 +131,7 @@ enum AccountStateMutationOperation { case UpdateRevenueBalances(peerId: PeerId, balances: RevenueStats.Balances) case UpdateStarsBalance(peerId: PeerId, balance: Int64) case UpdateStarsRevenueStatus(peerId: PeerId, status: StarsRevenueStats.Balances) + case UpdateStarsReactionsAreAnonymousByDefault(isAnonymous: Bool) } struct HoleFromPreviousState { @@ -688,9 +689,13 @@ struct AccountMutableState { self.addOperation(.UpdateStarsRevenueStatus(peerId: peerId, status: status)) } + mutating func updateStarsReactionsAreAnonymousByDefault(isAnonymous: Bool) { + self.addOperation(.UpdateStarsReactionsAreAnonymousByDefault(isAnonymous: isAnonymous)) + } + mutating func addOperation(_ operation: AccountStateMutationOperation) { switch operation { - case .DeleteMessages, .DeleteMessagesWithGlobalIds, .EditMessage, .UpdateMessagePoll, .UpdateMessageReactions, .UpdateMedia, .ReadOutbox, .ReadGroupFeedInbox, .MergePeerPresences, .UpdateSecretChat, .AddSecretMessages, .ReadSecretOutbox, .AddPeerInputActivity, .UpdateCachedPeerData, .UpdatePinnedItemIds, .UpdatePinnedSavedItemIds, .UpdatePinnedTopic, .UpdatePinnedTopicOrder, .ReadMessageContents, .UpdateMessageImpressionCount, .UpdateMessageForwardsCount, .UpdateInstalledStickerPacks, .UpdateRecentGifs, .UpdateChatInputState, .UpdateCall, .AddCallSignalingData, .UpdateLangPack, .UpdateMinAvailableMessage, .UpdatePeerChatUnreadMark, .UpdateIsContact, .UpdatePeerChatInclusion, .UpdatePeersNearby, .UpdateTheme, .UpdateWallpaper, .SyncChatListFilters, .UpdateChatListFilterOrder, .UpdateChatListFilter, .UpdateReadThread, .UpdateGroupCallParticipants, .UpdateGroupCall, .UpdateMessagesPinned, .UpdateAutoremoveTimeout, .UpdateAttachMenuBots, .UpdateAudioTranscription, .UpdateConfig, .UpdateExtendedMedia, .ResetForumTopic, .UpdateStory, .UpdateReadStories, .UpdateStoryStealthMode, .UpdateStorySentReaction, .UpdateNewAuthorization, .UpdateRevenueBalances, .UpdateStarsBalance, .UpdateStarsRevenueStatus: + case .DeleteMessages, .DeleteMessagesWithGlobalIds, .EditMessage, .UpdateMessagePoll, .UpdateMessageReactions, .UpdateMedia, .ReadOutbox, .ReadGroupFeedInbox, .MergePeerPresences, .UpdateSecretChat, .AddSecretMessages, .ReadSecretOutbox, .AddPeerInputActivity, .UpdateCachedPeerData, .UpdatePinnedItemIds, .UpdatePinnedSavedItemIds, .UpdatePinnedTopic, .UpdatePinnedTopicOrder, .ReadMessageContents, .UpdateMessageImpressionCount, .UpdateMessageForwardsCount, .UpdateInstalledStickerPacks, .UpdateRecentGifs, .UpdateChatInputState, .UpdateCall, .AddCallSignalingData, .UpdateLangPack, .UpdateMinAvailableMessage, .UpdatePeerChatUnreadMark, .UpdateIsContact, .UpdatePeerChatInclusion, .UpdatePeersNearby, .UpdateTheme, .UpdateWallpaper, .SyncChatListFilters, .UpdateChatListFilterOrder, .UpdateChatListFilter, .UpdateReadThread, .UpdateGroupCallParticipants, .UpdateGroupCall, .UpdateMessagesPinned, .UpdateAutoremoveTimeout, .UpdateAttachMenuBots, .UpdateAudioTranscription, .UpdateConfig, .UpdateExtendedMedia, .ResetForumTopic, .UpdateStory, .UpdateReadStories, .UpdateStoryStealthMode, .UpdateStorySentReaction, .UpdateNewAuthorization, .UpdateRevenueBalances, .UpdateStarsBalance, .UpdateStarsRevenueStatus, .UpdateStarsReactionsAreAnonymousByDefault: break case let .AddMessages(messages, location): for message in messages { diff --git a/submodules/TelegramCore/Sources/ApiUtils/StoreMessage_Telegram.swift b/submodules/TelegramCore/Sources/ApiUtils/StoreMessage_Telegram.swift index 859695f17a4..663cf616b52 100644 --- a/submodules/TelegramCore/Sources/ApiUtils/StoreMessage_Telegram.swift +++ b/submodules/TelegramCore/Sources/ApiUtils/StoreMessage_Telegram.swift @@ -258,6 +258,8 @@ func apiMessagePeerIds(_ message: Api.Message) -> [PeerId] { if let boostPeer = boostPeer { result.append(boostPeer.peerId) } + case let .messageActionPrizeStars(_, _, _, boostPeer, _): + result.append(boostPeer.peerId) case let .messageActionPaymentRefunded(_, peer, _, _, _, _): result.append(peer.peerId) } @@ -425,13 +427,21 @@ func textMediaAndExpirationTimerFromApiMedia(_ media: Api.MessageMedia?, _ peerI case let .messageMediaStory(flags, peerId, id, _): let isMention = (flags & (1 << 1)) != 0 return (TelegramMediaStory(storyId: StoryId(peerId: peerId.peerId, id: id), isMention: isMention), nil, nil, nil, nil) - case let .messageMediaGiveaway(apiFlags, channels, countries, prizeDescription, quantity, months, untilDate): + case let .messageMediaGiveaway(apiFlags, channels, countries, prizeDescription, quantity, months, stars, untilDate): var flags: TelegramMediaGiveaway.Flags = [] if (apiFlags & (1 << 0)) != 0 { flags.insert(.onlyNewSubscribers) } - return (TelegramMediaGiveaway(flags: flags, channelPeerIds: channels.map { PeerId(namespace: Namespaces.Peer.CloudChannel, id: PeerId.Id._internalFromInt64Value($0)) }, countries: countries ?? [], quantity: quantity, months: months, untilDate: untilDate, prizeDescription: prizeDescription), nil, nil, nil, nil) - case let .messageMediaGiveawayResults(apiFlags, channelId, additionalPeersCount, launchMsgId, winnersCount, unclaimedCount, winners, months, prizeDescription, untilDate): + let prize: TelegramMediaGiveaway.Prize + if let months { + prize = .premium(months: months) + } else if let stars { + prize = .stars(amount: stars) + } else { + return (nil, nil, nil, nil, nil) + } + return (TelegramMediaGiveaway(flags: flags, channelPeerIds: channels.map { PeerId(namespace: Namespaces.Peer.CloudChannel, id: PeerId.Id._internalFromInt64Value($0)) }, countries: countries ?? [], quantity: quantity, prize: prize, untilDate: untilDate, prizeDescription: prizeDescription), nil, nil, nil, nil) + case let .messageMediaGiveawayResults(apiFlags, channelId, additionalPeersCount, launchMsgId, winnersCount, unclaimedCount, winners, months, stars, prizeDescription, untilDate): var flags: TelegramMediaGiveawayResults.Flags = [] if (apiFlags & (1 << 0)) != 0 { flags.insert(.onlyNewSubscribers) @@ -439,7 +449,15 @@ func textMediaAndExpirationTimerFromApiMedia(_ media: Api.MessageMedia?, _ peerI if (apiFlags & (1 << 2)) != 0 { flags.insert(.refunded) } - return (TelegramMediaGiveawayResults(flags: flags, launchMessageId: MessageId(peerId: PeerId(namespace: Namespaces.Peer.CloudChannel, id: PeerId.Id._internalFromInt64Value(channelId)), namespace: Namespaces.Message.Cloud, id: launchMsgId), additionalChannelsCount: additionalPeersCount ?? 0, winnersPeerIds: winners.map { PeerId(namespace: Namespaces.Peer.CloudUser, id: PeerId.Id._internalFromInt64Value($0)) }, winnersCount: winnersCount, unclaimedCount: unclaimedCount, months: months, untilDate: untilDate, prizeDescription: prizeDescription), nil, nil, nil, nil) + let prize: TelegramMediaGiveawayResults.Prize + if let months { + prize = .premium(months: months) + } else if let stars { + prize = .stars(amount: stars) + } else { + return (nil, nil, nil, nil, nil) + } + return (TelegramMediaGiveawayResults(flags: flags, launchMessageId: MessageId(peerId: PeerId(namespace: Namespaces.Peer.CloudChannel, id: PeerId.Id._internalFromInt64Value(channelId)), namespace: Namespaces.Message.Cloud, id: launchMsgId), additionalChannelsCount: additionalPeersCount ?? 0, winnersPeerIds: winners.map { PeerId(namespace: Namespaces.Peer.CloudUser, id: PeerId.Id._internalFromInt64Value($0)) }, winnersCount: winnersCount, unclaimedCount: unclaimedCount, prize: prize, untilDate: untilDate, prizeDescription: prizeDescription), nil, nil, nil, nil) case let .messageMediaPaidMedia(starsAmount, apiExtendedMedia): return (TelegramMediaPaidContent(amount: starsAmount, extendedMedia: apiExtendedMedia.compactMap({ TelegramExtendedMedia(apiExtendedMedia: $0, peerId: peerId) })), nil, nil, nil, nil) } diff --git a/submodules/TelegramCore/Sources/ApiUtils/TelegramMediaAction.swift b/submodules/TelegramCore/Sources/ApiUtils/TelegramMediaAction.swift index b4ffe2ba57a..f1849166efb 100644 --- a/submodules/TelegramCore/Sources/ApiUtils/TelegramMediaAction.swift +++ b/submodules/TelegramCore/Sources/ApiUtils/TelegramMediaAction.swift @@ -135,10 +135,10 @@ func telegramMediaActionFromApiAction(_ action: Api.MessageAction) -> TelegramMe } case let .messageActionGiftCode(flags, boostPeer, months, slug, currency, amount, cryptoCurrency, cryptoAmount): return TelegramMediaAction(action: .giftCode(slug: slug, fromGiveaway: (flags & (1 << 0)) != 0, isUnclaimed: (flags & (1 << 2)) != 0, boostPeerId: boostPeer?.peerId, months: months, currency: currency, amount: amount, cryptoCurrency: cryptoCurrency, cryptoAmount: cryptoAmount)) - case .messageActionGiveawayLaunch: - return TelegramMediaAction(action: .giveawayLaunched) - case let .messageActionGiveawayResults(winners, unclaimed): - return TelegramMediaAction(action: .giveawayResults(winners: winners, unclaimed: unclaimed)) + case let .messageActionGiveawayLaunch(_, stars): + return TelegramMediaAction(action: .giveawayLaunched(stars: stars)) + case let .messageActionGiveawayResults(flags, winners, unclaimed): + return TelegramMediaAction(action: .giveawayResults(winners: winners, unclaimed: unclaimed, stars: (flags & (1 << 0)) != 0)) case let .messageActionBoostApply(boosts): return TelegramMediaAction(action: .boostsApplied(boosts: boosts)) case let .messageActionPaymentRefunded(_, peer, currency, totalAmount, payload, charge): @@ -148,6 +148,8 @@ func telegramMediaActionFromApiAction(_ action: Api.MessageAction) -> TelegramMe transactionId = id } return TelegramMediaAction(action: .paymentRefunded(peerId: peer.peerId, currency: currency, totalAmount: totalAmount, payload: payload?.makeData(), transactionId: transactionId)) + case let .messageActionPrizeStars(flags, stars, transactionId, boostPeer, giveawayMsgId): + return TelegramMediaAction(action: .prizeStars(amount: stars, isUnclaimed: (flags & (1 << 2)) != 0, boostPeerId: boostPeer.peerId, transactionId: transactionId, giveawayMessageId: MessageId(peerId: boostPeer.peerId, namespace: Namespaces.Message.Cloud, id: giveawayMsgId))) } } diff --git a/submodules/TelegramCore/Sources/Network/FetchHttpResource.swift b/submodules/TelegramCore/Sources/Network/FetchHttpResource.swift index e7f122d904b..6133e8798c1 100644 --- a/submodules/TelegramCore/Sources/Network/FetchHttpResource.swift +++ b/submodules/TelegramCore/Sources/Network/FetchHttpResource.swift @@ -3,8 +3,12 @@ import Postbox import SwiftSignalKit import MtProtoKit -public func fetchHttpResource(url: String) -> Signal { - if let urlString = url.addingPercentEncoding(withAllowedCharacters: CharacterSet.urlQueryAllowed), let url = URL(string: urlString) { +public func fetchHttpResource(url: String, preserveExactUrl: Bool = false) -> Signal { + var urlString: String? = url + if !preserveExactUrl { + urlString = url.addingPercentEncoding(withAllowedCharacters: CharacterSet.urlQueryAllowed) + } + if let urlString, let url = URL(string: urlString) { let signal = MTHttpRequestOperation.data(forHttpUrl: url)! return Signal { subscriber in subscriber.putNext(.reset) diff --git a/submodules/TelegramCore/Sources/PendingMessages/EnqueueMessage.swift b/submodules/TelegramCore/Sources/PendingMessages/EnqueueMessage.swift index abd071c9dbe..317cae8aa50 100644 --- a/submodules/TelegramCore/Sources/PendingMessages/EnqueueMessage.swift +++ b/submodules/TelegramCore/Sources/PendingMessages/EnqueueMessage.swift @@ -722,7 +722,15 @@ func enqueueMessages(transaction: Transaction, account: Account, peerId: PeerId, var authorId: PeerId? if let sendAsPeer = sendAsPeer { - authorId = sendAsPeer.id + if let peer = peer as? TelegramChannel, case let .broadcast(info) = peer.info { + if info.flags.contains(.messagesShouldHaveProfiles) { + authorId = sendAsPeer.id + } else { + authorId = peer.id + } + } else { + authorId = sendAsPeer.id + } } else if let peer = peer as? TelegramChannel { if case .broadcast = peer.info { authorId = peer.id @@ -754,7 +762,11 @@ func enqueueMessages(transaction: Transaction, account: Account, peerId: PeerId, } } if info.flags.contains(.messagesShouldHaveSignatures) { - if let sendAsPeer, sendAsPeer.id == peerId { + if let sendAsPeer { + if sendAsPeer.id == peerId { + } else { + attributes.append(AuthorSignatureMessageAttribute(signature: sendAsPeer.debugDisplayTitle)) + } } else { attributes.append(AuthorSignatureMessageAttribute(signature: accountPeer.debugDisplayTitle)) } diff --git a/submodules/TelegramCore/Sources/PendingMessages/PendingMessageUploadedContent.swift b/submodules/TelegramCore/Sources/PendingMessages/PendingMessageUploadedContent.swift index 6d3872c4926..63df2625db3 100644 --- a/submodules/TelegramCore/Sources/PendingMessages/PendingMessageUploadedContent.swift +++ b/submodules/TelegramCore/Sources/PendingMessages/PendingMessageUploadedContent.swift @@ -165,8 +165,10 @@ func mediaContentToUpload(accountPeerId: PeerId, network: Network, postbox: Post if media.count == results.count { return .content(PendingMessageUploadedContentAndReuploadInfo( content: .media(.inputMediaPaidMedia( + flags: 0, starsAmount: paidContent.amount, - extendedMedia: media + extendedMedia: media, + payload: nil ), text), reuploadInfo: nil, cacheReferenceKey: nil diff --git a/submodules/TelegramCore/Sources/State/AccountStateManagementUtils.swift b/submodules/TelegramCore/Sources/State/AccountStateManagementUtils.swift index a248055e93b..e5b49e557ca 100644 --- a/submodules/TelegramCore/Sources/State/AccountStateManagementUtils.swift +++ b/submodules/TelegramCore/Sources/State/AccountStateManagementUtils.swift @@ -1782,6 +1782,8 @@ private func finalStateWithUpdatesAndServerTime(accountPeerId: PeerId, postbox: updatedState.updateStarsBalance(peerId: accountPeerId, balance: balance) case let .updateStarsRevenueStatus(peer, status): updatedState.updateStarsRevenueStatus(peerId: peer.peerId, status: StarsRevenueStats.Balances(apiStarsRevenueStatus: status)) + case let .updatePaidReactionPrivacy(isPrivate): + updatedState.updateStarsReactionsAreAnonymousByDefault(isAnonymous: isPrivate == .boolTrue) default: break } @@ -3273,7 +3275,7 @@ private func optimizedOperations(_ operations: [AccountStateMutationOperation]) var currentAddQuickReplyMessages: OptimizeAddMessagesState? for operation in operations { switch operation { - case .DeleteMessages, .DeleteMessagesWithGlobalIds, .EditMessage, .UpdateMessagePoll, .UpdateMessageReactions, .UpdateMedia, .MergeApiChats, .MergeApiUsers, .MergePeerPresences, .UpdatePeer, .ReadInbox, .ReadOutbox, .ReadGroupFeedInbox, .ResetReadState, .ResetIncomingReadState, .UpdatePeerChatUnreadMark, .ResetMessageTagSummary, .UpdateNotificationSettings, .UpdateGlobalNotificationSettings, .UpdateSecretChat, .AddSecretMessages, .ReadSecretOutbox, .AddPeerInputActivity, .UpdateCachedPeerData, .UpdatePinnedItemIds, .UpdatePinnedSavedItemIds, .UpdatePinnedTopic, .UpdatePinnedTopicOrder, .ReadMessageContents, .UpdateMessageImpressionCount, .UpdateMessageForwardsCount, .UpdateInstalledStickerPacks, .UpdateRecentGifs, .UpdateChatInputState, .UpdateCall, .AddCallSignalingData, .UpdateLangPack, .UpdateMinAvailableMessage, .UpdateIsContact, .UpdatePeerChatInclusion, .UpdatePeersNearby, .UpdateTheme, .SyncChatListFilters, .UpdateChatListFilter, .UpdateChatListFilterOrder, .UpdateReadThread, .UpdateMessagesPinned, .UpdateGroupCallParticipants, .UpdateGroupCall, .UpdateAutoremoveTimeout, .UpdateAttachMenuBots, .UpdateAudioTranscription, .UpdateConfig, .UpdateExtendedMedia, .ResetForumTopic, .UpdateStory, .UpdateReadStories, .UpdateStoryStealthMode, .UpdateStorySentReaction, .UpdateNewAuthorization, .UpdateWallpaper, .UpdateRevenueBalances, .UpdateStarsBalance, .UpdateStarsRevenueStatus: + case .DeleteMessages, .DeleteMessagesWithGlobalIds, .EditMessage, .UpdateMessagePoll, .UpdateMessageReactions, .UpdateMedia, .MergeApiChats, .MergeApiUsers, .MergePeerPresences, .UpdatePeer, .ReadInbox, .ReadOutbox, .ReadGroupFeedInbox, .ResetReadState, .ResetIncomingReadState, .UpdatePeerChatUnreadMark, .ResetMessageTagSummary, .UpdateNotificationSettings, .UpdateGlobalNotificationSettings, .UpdateSecretChat, .AddSecretMessages, .ReadSecretOutbox, .AddPeerInputActivity, .UpdateCachedPeerData, .UpdatePinnedItemIds, .UpdatePinnedSavedItemIds, .UpdatePinnedTopic, .UpdatePinnedTopicOrder, .ReadMessageContents, .UpdateMessageImpressionCount, .UpdateMessageForwardsCount, .UpdateInstalledStickerPacks, .UpdateRecentGifs, .UpdateChatInputState, .UpdateCall, .AddCallSignalingData, .UpdateLangPack, .UpdateMinAvailableMessage, .UpdateIsContact, .UpdatePeerChatInclusion, .UpdatePeersNearby, .UpdateTheme, .SyncChatListFilters, .UpdateChatListFilter, .UpdateChatListFilterOrder, .UpdateReadThread, .UpdateMessagesPinned, .UpdateGroupCallParticipants, .UpdateGroupCall, .UpdateAutoremoveTimeout, .UpdateAttachMenuBots, .UpdateAudioTranscription, .UpdateConfig, .UpdateExtendedMedia, .ResetForumTopic, .UpdateStory, .UpdateReadStories, .UpdateStoryStealthMode, .UpdateStorySentReaction, .UpdateNewAuthorization, .UpdateWallpaper, .UpdateRevenueBalances, .UpdateStarsBalance, .UpdateStarsRevenueStatus, .UpdateStarsReactionsAreAnonymousByDefault: if let currentAddMessages = currentAddMessages, !currentAddMessages.messages.isEmpty { result.append(.AddMessages(currentAddMessages.messages, currentAddMessages.location)) } @@ -3409,6 +3411,7 @@ func replayFinalState( var updatedRevenueBalances: [PeerId: RevenueStats.Balances] = [:] var updatedStarsBalance: [PeerId: Int64] = [:] var updatedStarsRevenueStatus: [PeerId: StarsRevenueStats.Balances] = [:] + var updatedStarsReactionsAreAnonymousByDefault: Bool? var holesFromPreviousStateMessageIds: [MessageId] = [] var clearHolesFromPreviousStateForChannelMessagesWithPts: [PeerIdAndMessageNamespace: Int32] = [:] @@ -4837,7 +4840,8 @@ func replayFinalState( updatedStarsBalance[peerId] = balance case let .UpdateStarsRevenueStatus(peerId, status): updatedStarsRevenueStatus[peerId] = status - + case let .UpdateStarsReactionsAreAnonymousByDefault(value): + updatedStarsReactionsAreAnonymousByDefault = value } } @@ -5332,5 +5336,9 @@ func replayFinalState( } } + if let updatedStarsReactionsAreAnonymousByDefault { + _internal_setStarsReactionDefaultToPrivate(isPrivate: updatedStarsReactionsAreAnonymousByDefault, transaction: transaction) + } + return AccountReplayedFinalState(state: finalState, addedIncomingMessageIds: addedIncomingMessageIds, addedReactionEvents: addedReactionEvents, wasScheduledMessageIds: wasScheduledMessageIds, addedSecretMessageIds: addedSecretMessageIds, deletedMessageIds: deletedMessageIds, updatedTypingActivities: updatedTypingActivities, updatedWebpages: updatedWebpages, updatedCalls: updatedCalls, addedCallSignalingData: addedCallSignalingData, updatedGroupCallParticipants: updatedGroupCallParticipants, storyUpdates: storyUpdates, updatedPeersNearby: updatedPeersNearby, isContactUpdates: isContactUpdates, delayNotificatonsUntil: delayNotificatonsUntil, updatedIncomingThreadReadStates: updatedIncomingThreadReadStates, updatedOutgoingThreadReadStates: updatedOutgoingThreadReadStates, updateConfig: updateConfig, isPremiumUpdated: isPremiumUpdated, updatedRevenueBalances: updatedRevenueBalances, updatedStarsBalance: updatedStarsBalance, updatedStarsRevenueStatus: updatedStarsRevenueStatus) } diff --git a/submodules/TelegramCore/Sources/State/ChannelBoost.swift b/submodules/TelegramCore/Sources/State/ChannelBoost.swift index 0972678fa8a..62847354088 100644 --- a/submodules/TelegramCore/Sources/State/ChannelBoost.swift +++ b/submodules/TelegramCore/Sources/State/ChannelBoost.swift @@ -200,7 +200,7 @@ private final class ChannelBoostersContextImpl { var result: [ChannelBoostersContext.State.Boost] = [] for boost in cachedResult.boosts { let peer = boost.peerId.flatMap { transaction.getPeer($0) } - result.append(ChannelBoostersContext.State.Boost(flags: ChannelBoostersContext.State.Boost.Flags(rawValue: boost.flags), id: boost.id, peer: peer.flatMap { EnginePeer($0) }, date: boost.date, expires: boost.expires, multiplier: boost.multiplier, slug: boost.slug)) + result.append(ChannelBoostersContext.State.Boost(flags: ChannelBoostersContext.State.Boost.Flags(rawValue: boost.flags), id: boost.id, peer: peer.flatMap { EnginePeer($0) }, date: boost.date, expires: boost.expires, multiplier: boost.multiplier, stars: boost.stars, slug: boost.slug, giveawayMessageId: boost.giveawayMessageId)) } return (result, cachedResult.count, true) } else { @@ -279,7 +279,7 @@ private final class ChannelBoostersContextImpl { var resultBoosts: [ChannelBoostersContext.State.Boost] = [] for boost in boosts { switch boost { - case let .boost(flags, id, userId, giveawayMessageId, date, expires, usedGiftSlug, multiplier): + case let .boost(flags, id, userId, giveawayMessageId, date, expires, usedGiftSlug, multiplier, stars): var boostFlags: ChannelBoostersContext.State.Boost.Flags = [] var boostPeer: EnginePeer? if let userId = userId { @@ -297,7 +297,7 @@ private final class ChannelBoostersContextImpl { if (flags & (1 << 3)) != 0 { boostFlags.insert(.isUnclaimed) } - resultBoosts.append(ChannelBoostersContext.State.Boost(flags: boostFlags, id: id, peer: boostPeer, date: date, expires: expires, multiplier: multiplier ?? 1, slug: usedGiftSlug, giveawayMessageId: giveawayMessageId.flatMap { EngineMessage.Id(peerId: peerId, namespace: Namespaces.Message.Cloud, id: $0) })) + resultBoosts.append(ChannelBoostersContext.State.Boost(flags: boostFlags, id: id, peer: boostPeer, date: date, expires: expires, multiplier: multiplier ?? 1, stars: stars, slug: usedGiftSlug, giveawayMessageId: giveawayMessageId.flatMap { EngineMessage.Id(peerId: peerId, namespace: Namespaces.Message.Cloud, id: $0) })) } } if populateCache { @@ -388,6 +388,7 @@ public final class ChannelBoostersContext { public var date: Int32 public var expires: Int32 public var multiplier: Int32 + public var stars: Int64? public var slug: String? public var giveawayMessageId: EngineMessage.Id? } @@ -451,6 +452,7 @@ private final class CachedChannelBoosters: Codable { case date case expires case multiplier + case stars case slug case channelPeerId case giveawayMessageId @@ -462,17 +464,19 @@ private final class CachedChannelBoosters: Codable { var date: Int32 var expires: Int32 var multiplier: Int32 + var stars: Int64? var slug: String? var channelPeerId: EnginePeer.Id var giveawayMessageId: EngineMessage.Id? - init(flags: Int32, id: String, peerId: EnginePeer.Id?, date: Int32, expires: Int32, multiplier: Int32, slug: String?, channelPeerId: EnginePeer.Id, giveawayMessageId: EngineMessage.Id?) { + init(flags: Int32, id: String, peerId: EnginePeer.Id?, date: Int32, expires: Int32, multiplier: Int32, stars: Int64?, slug: String?, channelPeerId: EnginePeer.Id, giveawayMessageId: EngineMessage.Id?) { self.flags = flags self.id = id self.peerId = peerId self.date = date self.expires = expires self.multiplier = multiplier + self.stars = stars self.slug = slug self.channelPeerId = channelPeerId self.giveawayMessageId = giveawayMessageId @@ -487,6 +491,7 @@ private final class CachedChannelBoosters: Codable { self.date = try container.decode(Int32.self, forKey: .date) self.expires = try container.decode(Int32.self, forKey: .expires) self.multiplier = try container.decode(Int32.self, forKey: .multiplier) + self.stars = try container.decodeIfPresent(Int64.self, forKey: .stars) self.slug = try container.decodeIfPresent(String.self, forKey: .slug) self.channelPeerId = EnginePeer.Id(try container.decode(Int64.self, forKey: .channelPeerId)) self.giveawayMessageId = try container.decodeIfPresent(Int32.self, forKey: .giveawayMessageId).flatMap { EngineMessage.Id(peerId: self.channelPeerId, namespace: Namespaces.Message.Cloud, id: $0) } @@ -501,6 +506,7 @@ private final class CachedChannelBoosters: Codable { try container.encode(self.date, forKey: .date) try container.encode(self.expires, forKey: .expires) try container.encode(self.multiplier, forKey: .multiplier) + try container.encodeIfPresent(self.stars, forKey: .stars) try container.encodeIfPresent(self.slug, forKey: .slug) try container.encode(self.channelPeerId.toInt64(), forKey: .channelPeerId) try container.encodeIfPresent(self.giveawayMessageId?.id, forKey: .giveawayMessageId) @@ -517,7 +523,7 @@ private final class CachedChannelBoosters: Codable { } init(channelPeerId: EnginePeer.Id, boosts: [ChannelBoostersContext.State.Boost], count: Int32) { - self.boosts = boosts.map { CachedBoost(flags: $0.flags.rawValue, id: $0.id, peerId: $0.peer?.id, date: $0.date, expires: $0.expires, multiplier: $0.multiplier, slug: $0.slug, channelPeerId: channelPeerId, giveawayMessageId: $0.giveawayMessageId) } + self.boosts = boosts.map { CachedBoost(flags: $0.flags.rawValue, id: $0.id, peerId: $0.peer?.id, date: $0.date, expires: $0.expires, multiplier: $0.multiplier, stars: $0.stars, slug: $0.slug, channelPeerId: channelPeerId, giveawayMessageId: $0.giveawayMessageId) } self.count = count } diff --git a/submodules/TelegramCore/Sources/State/MessageReactions.swift b/submodules/TelegramCore/Sources/State/MessageReactions.swift index 55b47ce2120..01dfcfbc5f8 100644 --- a/submodules/TelegramCore/Sources/State/MessageReactions.swift +++ b/submodules/TelegramCore/Sources/State/MessageReactions.swift @@ -172,9 +172,10 @@ public func updateMessageReactionsInteractively(account: Account, messageIds: [M |> ignoreValues } -public func sendStarsReactionsInteractively(account: Account, messageId: MessageId, count: Int, isAnonymous: Bool) -> Signal { - return account.postbox.transaction { transaction -> Void in +func _internal_sendStarsReactionsInteractively(account: Account, messageId: MessageId, count: Int, isAnonymous: Bool?) -> Signal { + return account.postbox.transaction { transaction -> Bool in transaction.setPendingMessageAction(type: .sendStarsReaction, id: messageId, action: SendStarsReactionsAction(randomId: Int64.random(in: Int64.min ... Int64.max))) + var resolvedIsAnonymousValue = false transaction.updateMessage(messageId, update: { currentMessage in var storeForwardInfo: StoreMessageForwardInfo? if let forwardInfo = currentMessage.forwardInfo { @@ -182,20 +183,37 @@ public func sendStarsReactionsInteractively(account: Account, messageId: Message } var mappedCount = Int32(count) var attributes = currentMessage.attributes + var resolvedIsAnonymous = _internal_getStarsReactionDefaultToPrivate(transaction: transaction) + for attribute in attributes { + if let attribute = attribute as? ReactionsMessageAttribute { + if let myReaction = attribute.topPeers.first(where: { $0.isMy }) { + resolvedIsAnonymous = myReaction.isAnonymous + } + } + } loop: for j in 0 ..< attributes.count { if let current = attributes[j] as? PendingStarsReactionsMessageAttribute { mappedCount += current.count + resolvedIsAnonymous = current.isAnonymous attributes.remove(at: j) break loop } } + + if let isAnonymous { + resolvedIsAnonymous = isAnonymous + _internal_setStarsReactionDefaultToPrivate(isPrivate: isAnonymous, transaction: transaction) + } - attributes.append(PendingStarsReactionsMessageAttribute(accountPeerId: account.peerId, count: mappedCount, isAnonymous: isAnonymous)) + attributes.append(PendingStarsReactionsMessageAttribute(accountPeerId: account.peerId, count: mappedCount, isAnonymous: resolvedIsAnonymous)) + + resolvedIsAnonymousValue = resolvedIsAnonymous return .update(StoreMessage(id: currentMessage.id, globallyUniqueId: currentMessage.globallyUniqueId, groupingKey: currentMessage.groupingKey, threadId: currentMessage.threadId, timestamp: currentMessage.timestamp, flags: StoreMessageFlags(currentMessage.flags), tags: currentMessage.tags, globalTags: currentMessage.globalTags, localTags: currentMessage.localTags, forwardInfo: storeForwardInfo, authorId: currentMessage.author?.id, text: currentMessage.text, attributes: attributes, media: currentMessage.media)) }) + + return resolvedIsAnonymousValue } - |> ignoreValues } func cancelPendingSendStarsReactionInteractively(account: Account, messageId: MessageId) -> Signal { @@ -228,6 +246,8 @@ func _internal_forceSendPendingSendStarsReaction(account: Account, messageId: Me func _internal_updateStarsReactionIsAnonymous(account: Account, messageId: MessageId, isAnonymous: Bool) -> Signal { return account.postbox.transaction { transaction -> Api.InputPeer? in + _internal_setStarsReactionDefaultToPrivate(isPrivate: isAnonymous, transaction: transaction) + transaction.updateMessage(messageId, update: { currentMessage in var storeForwardInfo: StoreMessageForwardInfo? if let forwardInfo = currentMessage.forwardInfo { @@ -385,17 +405,14 @@ private func requestSendStarsReaction(postbox: Postbox, network: Network, stateM let randomId = (timestampPart << 32) | randomPartId var flags: Int32 = 0 - if isAnonymous { - flags |= 1 << 0 - } + flags |= 1 << 0 - let signal: Signal = network.request(Api.functions.messages.sendPaidReaction(flags: flags, peer: inputPeer, msgId: messageId.id, count: count, randomId: Int64(bitPattern: randomId))) + let signal: Signal = network.request(Api.functions.messages.sendPaidReaction(flags: flags, peer: inputPeer, msgId: messageId.id, count: count, randomId: Int64(bitPattern: randomId), private: isAnonymous ? .boolTrue : .boolFalse)) |> mapError { _ -> RequestUpdateMessageReactionError in return .generic } |> mapToSignal { result -> Signal in stateManager.starsContext?.add(balance: Int64(-count), addTransaction: false) - //stateManager.starsContext?.load(force: true) return postbox.transaction { transaction -> Void in transaction.setPendingMessageAction(type: .sendStarsReaction, id: messageId, action: UpdateMessageReactionsAction()) @@ -1012,3 +1029,31 @@ func _internal_updateDefaultReaction(account: Account, reaction: MessageReaction } |> ignoreValues } + +struct StarsReactionDefaultToPrivateData: Codable { + var isPrivate: Bool + + init(isPrivate: Bool) { + self.isPrivate = isPrivate + } + + static func key() -> ValueBoxKey { + let value = ValueBoxKey(length: 8) + value.setInt64(0, value: 0) + return value + } +} + +func _internal_getStarsReactionDefaultToPrivate(transaction: Transaction) -> Bool { + guard let value = transaction.retrieveItemCacheEntry(id: ItemCacheEntryId(collectionId: Namespaces.CachedItemCollection.starsReactionDefaultToPrivate, key: StarsReactionDefaultToPrivateData.key()))?.get(StarsReactionDefaultToPrivateData.self) else { + return false + } + return value.isPrivate +} + +func _internal_setStarsReactionDefaultToPrivate(isPrivate: Bool, transaction: Transaction) { + guard let entry = CodableEntry(StarsReactionDefaultToPrivateData(isPrivate: isPrivate)) else { + return + } + transaction.putItemCacheEntry(id: ItemCacheEntryId(collectionId: Namespaces.CachedItemCollection.starsReactionDefaultToPrivate, key: StarsReactionDefaultToPrivateData.key()), entry: entry) +} diff --git a/submodules/TelegramCore/Sources/State/Serialization.swift b/submodules/TelegramCore/Sources/State/Serialization.swift index 9ca64a5e9e9..46d4d1b6fe6 100644 --- a/submodules/TelegramCore/Sources/State/Serialization.swift +++ b/submodules/TelegramCore/Sources/State/Serialization.swift @@ -210,7 +210,7 @@ public class BoxedMessage: NSObject { public class Serialization: NSObject, MTSerialization { public func currentLayer() -> UInt { - return 186 + return 187 } public func parseMessage(_ data: Data!) -> Any! { diff --git a/submodules/TelegramCore/Sources/State/StickerManagement.swift b/submodules/TelegramCore/Sources/State/StickerManagement.swift index 2812f370f3e..42c8bf5adb4 100644 --- a/submodules/TelegramCore/Sources/State/StickerManagement.swift +++ b/submodules/TelegramCore/Sources/State/StickerManagement.swift @@ -65,7 +65,7 @@ func resolveMissingStickerSets(network: Network, postbox: Postbox, stickerSets: var missingSignals: [Signal<(Int, Api.StickerSetCovered)?, NoError>] = [] for i in 0 ..< stickerSets.count { switch stickerSets[i] { - case let .stickerSetNoCovered(value): + case let .stickerSetNoCovered(value), let .stickerSetCovered(value, _): switch value { case let .stickerSet(_, _, id, accessHash, _, _, _, _, _, _, _, hash): if ignorePacksWithHashes[id] == hash { @@ -143,7 +143,7 @@ func updatedFeaturedStickerPacks(network: Network, postbox: Postbox, category: F case .featuredStickersNotModified: return .single(.notModified) case let .featuredStickers(flags, _, _, sets, unread): - return resolveMissingStickerSets(network: network, postbox: postbox, stickerSets: sets, ignorePacksWithHashes: initialPackMap.mapValues({ item in + return resolveMissingStickerSets(network: network, postbox: postbox, stickerSets: sets, ignorePacksWithHashes: initialPackMap.filter { $0.value.topItems.count > 1 }.mapValues({ item in item.info.hash })) |> castError(MTRpcError.self) @@ -153,8 +153,10 @@ func updatedFeaturedStickerPacks(network: Network, postbox: Postbox, category: F for set in sets { var (info, items) = parsePreviewStickerSet(set, namespace: category.collectionIdNamespace) if let previousPack = initialPackMap[info.id.id] { - if previousPack.info.hash == info.hash { + if previousPack.info.hash == info.hash, previousPack.topItems.count > 1 { items = previousPack.topItems + } else { + items = Array(items.prefix(5)) } } updatedPacks.append(FeaturedStickerPackItem(info: info, topItems: items, unread: unreadIds.contains(info.id.id))) diff --git a/submodules/TelegramCore/Sources/Statistics/RevenueStatistics.swift b/submodules/TelegramCore/Sources/Statistics/RevenueStatistics.swift index 784164b5432..90ad669f1c0 100644 --- a/submodules/TelegramCore/Sources/Statistics/RevenueStatistics.swift +++ b/submodules/TelegramCore/Sources/Statistics/RevenueStatistics.swift @@ -23,20 +23,24 @@ public struct RevenueStats: Equatable, Codable { case currentBalance case availableBalance case overallRevenue + case withdrawEnabled } public let currentBalance: Int64 public let availableBalance: Int64 public let overallRevenue: Int64 + public let withdrawEnabled: Bool init( currentBalance: Int64, availableBalance: Int64, - overallRevenue: Int64 + overallRevenue: Int64, + withdrawEnabled: Bool ) { self.currentBalance = currentBalance self.availableBalance = availableBalance self.overallRevenue = overallRevenue + self.withdrawEnabled = withdrawEnabled } public init(from decoder: Decoder) throws { @@ -44,6 +48,7 @@ public struct RevenueStats: Equatable, Codable { self.currentBalance = try container.decode(Int64.self, forKey: .currentBalance) self.availableBalance = try container.decode(Int64.self, forKey: .availableBalance) self.overallRevenue = try container.decode(Int64.self, forKey: .overallRevenue) + self.withdrawEnabled = try container.decode(Bool.self, forKey: .withdrawEnabled) } public func encode(to encoder: Encoder) throws { @@ -51,6 +56,7 @@ public struct RevenueStats: Equatable, Codable { try container.encode(self.currentBalance, forKey: .currentBalance) try container.encode(self.availableBalance, forKey: .availableBalance) try container.encode(self.overallRevenue, forKey: .overallRevenue) + try container.encode(self.withdrawEnabled, forKey: .withdrawEnabled) } } @@ -122,8 +128,8 @@ extension RevenueStats { extension RevenueStats.Balances { init(apiRevenueBalances: Api.BroadcastRevenueBalances) { switch apiRevenueBalances { - case let .broadcastRevenueBalances(currentBalance, availableBalance, overallRevenue): - self.init(currentBalance: currentBalance, availableBalance: availableBalance, overallRevenue: overallRevenue) + case let .broadcastRevenueBalances(flags, currentBalance, availableBalance, overallRevenue): + self.init(currentBalance: currentBalance, availableBalance: availableBalance, overallRevenue: overallRevenue, withdrawEnabled: ((flags & (1 << 0)) != 0)) } } } diff --git a/submodules/TelegramCore/Sources/SyncCore/SyncCore_Namespaces.swift b/submodules/TelegramCore/Sources/SyncCore/SyncCore_Namespaces.swift index a9bde8238dc..ce6b31cf536 100644 --- a/submodules/TelegramCore/Sources/SyncCore/SyncCore_Namespaces.swift +++ b/submodules/TelegramCore/Sources/SyncCore/SyncCore_Namespaces.swift @@ -135,6 +135,7 @@ public struct Namespaces { public static let cachedStarsRevenueStats: Int8 = 38 public static let cachedRevenueStats: Int8 = 39 public static let recommendedApps: Int8 = 40 + public static let starsReactionDefaultToPrivate: Int8 = 41 } public struct UnorderedItemList { diff --git a/submodules/TelegramCore/Sources/SyncCore/SyncCore_TelegramMediaAction.swift b/submodules/TelegramCore/Sources/SyncCore/SyncCore_TelegramMediaAction.swift index 8f6bd1cd658..8bee1013148 100644 --- a/submodules/TelegramCore/Sources/SyncCore/SyncCore_TelegramMediaAction.swift +++ b/submodules/TelegramCore/Sources/SyncCore/SyncCore_TelegramMediaAction.swift @@ -123,12 +123,13 @@ public enum TelegramMediaActionType: PostboxCoding, Equatable { case setChatWallpaper(wallpaper: TelegramWallpaper, forBoth: Bool) case setSameChatWallpaper(wallpaper: TelegramWallpaper) case giftCode(slug: String, fromGiveaway: Bool, isUnclaimed: Bool, boostPeerId: PeerId?, months: Int32, currency: String?, amount: Int64?, cryptoCurrency: String?, cryptoAmount: Int64?) - case giveawayLaunched + case giveawayLaunched(stars: Int64?) case joinedChannel - case giveawayResults(winners: Int32, unclaimed: Int32) + case giveawayResults(winners: Int32, unclaimed: Int32, stars: Bool) case boostsApplied(boosts: Int32) case paymentRefunded(peerId: PeerId, currency: String, totalAmount: Int64, payload: Data?, transactionId: String) case giftStars(currency: String, amount: Int64, count: Int64, cryptoCurrency: String?, cryptoAmount: Int64?, transactionId: String?) + case prizeStars(amount: Int64, isUnclaimed: Bool, boostPeerId: PeerId?, transactionId: String?, giveawayMessageId: MessageId?) public init(decoder: PostboxDecoder) { let rawValue: Int32 = decoder.decodeInt32ForKey("_rawValue", orElse: 0) @@ -230,17 +231,25 @@ public enum TelegramMediaActionType: PostboxCoding, Equatable { case 36: self = .giftCode(slug: decoder.decodeStringForKey("slug", orElse: ""), fromGiveaway: decoder.decodeBoolForKey("give", orElse: false), isUnclaimed: decoder.decodeBoolForKey("unclaimed", orElse: false), boostPeerId: decoder.decodeOptionalInt64ForKey("pi").flatMap { PeerId($0) }, months: decoder.decodeInt32ForKey("months", orElse: 0), currency: decoder.decodeOptionalStringForKey("currency"), amount: decoder.decodeOptionalInt64ForKey("amount"), cryptoCurrency: decoder.decodeOptionalStringForKey("cryptoCurrency"), cryptoAmount: decoder.decodeOptionalInt64ForKey("cryptoAmount")) case 37: - self = .giveawayLaunched + self = .giveawayLaunched(stars: decoder.decodeOptionalInt64ForKey("stars")) case 38: self = .joinedChannel case 39: - self = .giveawayResults(winners: decoder.decodeInt32ForKey("winners", orElse: 0), unclaimed: decoder.decodeInt32ForKey("unclaimed", orElse: 0)) + self = .giveawayResults(winners: decoder.decodeInt32ForKey("winners", orElse: 0), unclaimed: decoder.decodeInt32ForKey("unclaimed", orElse: 0), stars: decoder.decodeBoolForKey("stars", orElse: false)) case 40: self = .boostsApplied(boosts: decoder.decodeInt32ForKey("boosts", orElse: 0)) case 41: self = .paymentRefunded(peerId: PeerId(decoder.decodeInt64ForKey("pi", orElse: 0)), currency: decoder.decodeStringForKey("currency", orElse: ""), totalAmount: decoder.decodeInt64ForKey("amount", orElse: 0), payload: decoder.decodeDataForKey("payload"), transactionId: decoder.decodeStringForKey("transactionId", orElse: "")) case 42: self = .giftStars(currency: decoder.decodeStringForKey("currency", orElse: ""), amount: decoder.decodeInt64ForKey("amount", orElse: 0), count: decoder.decodeInt64ForKey("count", orElse: 0), cryptoCurrency: decoder.decodeOptionalStringForKey("cryptoCurrency"), cryptoAmount: decoder.decodeOptionalInt64ForKey("cryptoAmount"), transactionId: decoder.decodeOptionalStringForKey("transactionId")) + case 43: + let boostPeerId = decoder.decodeOptionalInt64ForKey("pi").flatMap { PeerId($0) } + let giveawayMsgId = decoder.decodeOptionalInt32ForKey("giveawayMsgId") + var giveawayMessageId: MessageId? + if let boostPeerId, let giveawayMsgId { + giveawayMessageId = MessageId(peerId: boostPeerId, namespace: Namespaces.Message.Cloud, id: giveawayMsgId) + } + self = .prizeStars(amount: decoder.decodeInt64ForKey("amount", orElse: 0), isUnclaimed: decoder.decodeBoolForKey("unclaimed", orElse: false), boostPeerId: boostPeerId, transactionId: decoder.decodeOptionalStringForKey("transactionId"), giveawayMessageId: giveawayMessageId) default: self = .unknown } @@ -452,14 +461,20 @@ public enum TelegramMediaActionType: PostboxCoding, Equatable { } else { encoder.encodeNil(forKey: "cryptoAmount") } - case .giveawayLaunched: + case let .giveawayLaunched(stars): encoder.encodeInt32(37, forKey: "_rawValue") + if let stars = stars { + encoder.encodeInt64(stars, forKey: "stars") + } else { + encoder.encodeNil(forKey: "stars") + } case .joinedChannel: encoder.encodeInt32(38, forKey: "_rawValue") - case let .giveawayResults(winners, unclaimed): + case let .giveawayResults(winners, unclaimed, stars): encoder.encodeInt32(39, forKey: "_rawValue") encoder.encodeInt32(winners, forKey: "winners") encoder.encodeInt32(unclaimed, forKey: "unclaimed") + encoder.encodeBool(stars, forKey: "stars") case let .boostsApplied(boosts): encoder.encodeInt32(40, forKey: "_rawValue") encoder.encodeInt32(boosts, forKey: "boosts") @@ -491,6 +506,25 @@ public enum TelegramMediaActionType: PostboxCoding, Equatable { } else { encoder.encodeNil(forKey: "transactionId") } + case let .prizeStars(amount, isUnclaimed, boostPeerId, transactionId, giveawayMessageId): + encoder.encodeInt32(43, forKey: "_rawValue") + encoder.encodeInt64(amount, forKey: "amount") + encoder.encodeBool(isUnclaimed, forKey: "unclaimed") + if let boostPeerId = boostPeerId { + encoder.encodeInt64(boostPeerId.toInt64(), forKey: "pi") + } else { + encoder.encodeNil(forKey: "pi") + } + if let transactionId { + encoder.encodeString(transactionId, forKey: "transactionId") + } else { + encoder.encodeNil(forKey: "transactionId") + } + if let giveawayMessageId { + encoder.encodeInt32(giveawayMessageId.id, forKey: "giveawayMsgId") + } else { + encoder.encodeNil(forKey: "giveawayMsgId") + } } } @@ -516,6 +550,8 @@ public enum TelegramMediaActionType: PostboxCoding, Equatable { return boostPeerId.flatMap { [$0] } ?? [] case let .paymentRefunded(peerId, _, _, _, _): return [peerId] + case let .prizeStars(_, _, boostPeerId, _, _): + return boostPeerId.flatMap { [$0] } ?? [] default: return [] } diff --git a/submodules/TelegramCore/Sources/SyncCore/SyncCore_TelegramMediaGiveaway.swift b/submodules/TelegramCore/Sources/SyncCore/SyncCore_TelegramMediaGiveaway.swift index 46723467ff5..a722aa5e0c9 100644 --- a/submodules/TelegramCore/Sources/SyncCore/SyncCore_TelegramMediaGiveaway.swift +++ b/submodules/TelegramCore/Sources/SyncCore/SyncCore_TelegramMediaGiveaway.swift @@ -12,6 +12,11 @@ public final class TelegramMediaGiveaway: Media, Equatable { public static let showWinners = Flags(rawValue: 1 << 1) } + public enum Prize: Equatable { + case premium(months: Int32) + case stars(amount: Int64) + } + public var id: MediaId? { return nil } @@ -23,16 +28,16 @@ public final class TelegramMediaGiveaway: Media, Equatable { public let channelPeerIds: [PeerId] public let countries: [String] public let quantity: Int32 - public let months: Int32 + public let prize: Prize public let untilDate: Int32 public let prizeDescription: String? - public init(flags: Flags, channelPeerIds: [PeerId], countries: [String], quantity: Int32, months: Int32, untilDate: Int32, prizeDescription: String?) { + public init(flags: Flags, channelPeerIds: [PeerId], countries: [String], quantity: Int32, prize: Prize, untilDate: Int32, prizeDescription: String?) { self.flags = flags self.channelPeerIds = channelPeerIds self.countries = countries self.quantity = quantity - self.months = months + self.prize = prize self.untilDate = untilDate self.prizeDescription = prizeDescription } @@ -42,7 +47,13 @@ public final class TelegramMediaGiveaway: Media, Equatable { self.channelPeerIds = decoder.decodeInt64ArrayForKey("cns").map { PeerId($0) } self.countries = decoder.decodeStringArrayForKey("cnt") self.quantity = decoder.decodeInt32ForKey("qty", orElse: 0) - self.months = decoder.decodeInt32ForKey("mts", orElse: 0) + if let months = decoder.decodeOptionalInt32ForKey("mts") { + self.prize = .premium(months: months) + } else if let stars = decoder.decodeOptionalInt64ForKey("str") { + self.prize = .stars(amount: stars) + } else { + self.prize = .premium(months: 0) + } self.untilDate = decoder.decodeInt32ForKey("unt", orElse: 0) self.prizeDescription = decoder.decodeOptionalStringForKey("des") } @@ -52,7 +63,12 @@ public final class TelegramMediaGiveaway: Media, Equatable { encoder.encodeInt64Array(self.channelPeerIds.map { $0.toInt64() }, forKey: "cns") encoder.encodeStringArray(self.countries, forKey: "cnt") encoder.encodeInt32(self.quantity, forKey: "qty") - encoder.encodeInt32(self.months, forKey: "mts") + switch self.prize { + case let .premium(months): + encoder.encodeInt32(months, forKey: "mts") + case let .stars(amount): + encoder.encodeInt64(amount, forKey: "str") + } encoder.encodeInt32(self.untilDate, forKey: "unt") if let prizeDescription = self.prizeDescription { encoder.encodeString(prizeDescription, forKey: "des") @@ -81,7 +97,7 @@ public final class TelegramMediaGiveaway: Media, Equatable { if self.quantity != other.quantity { return false } - if self.months != other.months { + if self.prize != other.prize { return false } if self.untilDate != other.untilDate { diff --git a/submodules/TelegramCore/Sources/SyncCore/SyncCore_TelegramMediaGiveawayResults.swift b/submodules/TelegramCore/Sources/SyncCore/SyncCore_TelegramMediaGiveawayResults.swift index 1e00f83c1b9..a697e33ef64 100644 --- a/submodules/TelegramCore/Sources/SyncCore/SyncCore_TelegramMediaGiveawayResults.swift +++ b/submodules/TelegramCore/Sources/SyncCore/SyncCore_TelegramMediaGiveawayResults.swift @@ -12,6 +12,11 @@ public final class TelegramMediaGiveawayResults: Media, Equatable { public static let onlyNewSubscribers = Flags(rawValue: 1 << 1) } + public enum Prize: Equatable { + case premium(months: Int32) + case stars(amount: Int64) + } + public var id: MediaId? { return nil } @@ -25,18 +30,18 @@ public final class TelegramMediaGiveawayResults: Media, Equatable { public let winnersPeerIds: [PeerId] public let winnersCount: Int32 public let unclaimedCount: Int32 - public let months: Int32 + public let prize: Prize public let untilDate: Int32 public let prizeDescription: String? - public init(flags: Flags, launchMessageId: MessageId, additionalChannelsCount: Int32, winnersPeerIds: [PeerId], winnersCount: Int32, unclaimedCount: Int32, months: Int32, untilDate: Int32, prizeDescription: String?) { + public init(flags: Flags, launchMessageId: MessageId, additionalChannelsCount: Int32, winnersPeerIds: [PeerId], winnersCount: Int32, unclaimedCount: Int32, prize: Prize, untilDate: Int32, prizeDescription: String?) { self.flags = flags self.launchMessageId = launchMessageId self.additionalChannelsCount = additionalChannelsCount self.winnersPeerIds = winnersPeerIds self.winnersCount = winnersCount self.unclaimedCount = unclaimedCount - self.months = months + self.prize = prize self.untilDate = untilDate self.prizeDescription = prizeDescription } @@ -48,7 +53,13 @@ public final class TelegramMediaGiveawayResults: Media, Equatable { self.winnersPeerIds = decoder.decodeInt64ArrayForKey("wnr").map { PeerId($0) } self.winnersCount = decoder.decodeInt32ForKey("wnc", orElse: 0) self.unclaimedCount = decoder.decodeInt32ForKey("unc", orElse: 0) - self.months = decoder.decodeInt32ForKey("mts", orElse: 0) + if let months = decoder.decodeOptionalInt32ForKey("mts") { + self.prize = .premium(months: months) + } else if let stars = decoder.decodeOptionalInt64ForKey("str") { + self.prize = .stars(amount: stars) + } else { + self.prize = .premium(months: 0) + } self.untilDate = decoder.decodeInt32ForKey("unt", orElse: 0) self.prizeDescription = decoder.decodeOptionalStringForKey("des") } @@ -61,7 +72,12 @@ public final class TelegramMediaGiveawayResults: Media, Equatable { encoder.encodeInt64Array(self.winnersPeerIds.map { $0.toInt64() }, forKey: "wnr") encoder.encodeInt32(self.winnersCount, forKey: "wnc") encoder.encodeInt32(self.unclaimedCount, forKey: "unc") - encoder.encodeInt32(self.months, forKey: "mts") + switch self.prize { + case let .premium(months): + encoder.encodeInt32(months, forKey: "mts") + case let .stars(amount): + encoder.encodeInt64(amount, forKey: "str") + } encoder.encodeInt32(self.untilDate, forKey: "unt") if let prizeDescription = self.prizeDescription { encoder.encodeString(prizeDescription, forKey: "des") @@ -96,7 +112,7 @@ public final class TelegramMediaGiveawayResults: Media, Equatable { if self.unclaimedCount != other.unclaimedCount { return false } - if self.months != other.months { + if self.prize != other.prize { return false } if self.untilDate != other.untilDate { diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Data/PeersData.swift b/submodules/TelegramCore/Sources/TelegramEngine/Data/PeersData.swift index b41f29d01ef..fdac6337f09 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Data/PeersData.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Data/PeersData.swift @@ -2159,5 +2159,24 @@ public extension TelegramEngine.EngineData.Item { } } } + + public struct StarsReactionDefaultToPrivate: TelegramEngineDataItem, PostboxViewDataItem { + public typealias Result = Bool + + public init() { + } + + var key: PostboxViewKey { + return .cachedItem(ItemCacheEntryId(collectionId: Namespaces.CachedItemCollection.starsReactionDefaultToPrivate, key: StarsReactionDefaultToPrivateData.key())) + } + + func extract(view: PostboxView) -> Result { + if let value = (view as? CachedItemView)?.value?.get(StarsReactionDefaultToPrivateData.self) { + return value.isPrivate + } else { + return false + } + } + } } } diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Messages/AdMessages.swift b/submodules/TelegramCore/Sources/TelegramEngine/Messages/AdMessages.swift index c558333abcd..acc75aa7dbc 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Messages/AdMessages.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Messages/AdMessages.swift @@ -532,21 +532,7 @@ private class AdMessagesHistoryContextImpl { } func markAction(opaqueId: Data) { - let account = self.account - let signal: Signal = account.postbox.transaction { transaction -> Api.InputChannel? in - return transaction.getPeer(self.peerId).flatMap(apiInputChannel) - } - |> mapToSignal { inputChannel -> Signal in - guard let inputChannel = inputChannel else { - return .complete() - } - return account.network.request(Api.functions.channels.clickSponsoredMessage(channel: inputChannel, randomId: Buffer(data: opaqueId))) - |> `catch` { _ -> Signal in - return .single(.boolFalse) - } - |> ignoreValues - } - let _ = signal.start() + _internal_markAdAction(account: self.account, peerId: self.peerId, opaqueId: opaqueId) } func remove(opaqueId: Data) { @@ -619,3 +605,21 @@ public class AdMessagesHistoryContext { } } } + + +func _internal_markAdAction(account: Account, peerId: EnginePeer.Id, opaqueId: Data) { + let signal: Signal = account.postbox.transaction { transaction -> Api.InputChannel? in + return transaction.getPeer(peerId).flatMap(apiInputChannel) + } + |> mapToSignal { inputChannel -> Signal in + guard let inputChannel = inputChannel else { + return .complete() + } + return account.network.request(Api.functions.channels.clickSponsoredMessage(channel: inputChannel, randomId: Buffer(data: opaqueId))) + |> `catch` { _ -> Signal in + return .single(.boolFalse) + } + |> ignoreValues + } + let _ = signal.start() +} diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Messages/SendAsPeers.swift b/submodules/TelegramCore/Sources/TelegramEngine/Messages/SendAsPeers.swift index d50eb68079b..f531c260a00 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Messages/SendAsPeers.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Messages/SendAsPeers.swift @@ -109,13 +109,7 @@ func _internal_peerSendAsAvailablePeers(accountPeerId: PeerId, network: Network, return .single([]) } - if let channel = peer as? TelegramChannel { - if case .group = channel.info { - } else if case let .broadcast(info) = channel.info { - if !info.flags.contains(.messagesShouldHaveProfiles) { - return .single([]) - } - } + if let _ = peer as? TelegramChannel { } else { return .single([]) } diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift b/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift index 42735b6e07f..9c2249435df 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift @@ -348,8 +348,8 @@ public extension TelegramEngine { ).startStandalone() } - public func sendStarsReaction(id: EngineMessage.Id, count: Int, isAnonymous: Bool) { - let _ = sendStarsReactionsInteractively(account: self.account, messageId: id, count: count, isAnonymous: isAnonymous).startStandalone() + public func sendStarsReaction(id: EngineMessage.Id, count: Int, isAnonymous: Bool?) -> Signal { + return _internal_sendStarsReactionsInteractively(account: self.account, messageId: id, count: count, isAnonymous: isAnonymous) } public func cancelPendingSendStarsReaction(id: EngineMessage.Id) { @@ -1480,6 +1480,9 @@ public extension TelegramEngine { public func updateExtendedMedia(messageIds: [EngineMessage.Id]) -> Signal { return _internal_updateExtendedMedia(account: self.account, messageIds: messageIds) } + public func markAdAction(peerId: EnginePeer.Id, opaqueId: Data) { + _internal_markAdAction(account: self.account, peerId: peerId, opaqueId: opaqueId) + } public func getAllLocalChannels(count: Int) -> Signal<[EnginePeer.Id], NoError> { return self.account.postbox.transaction { transaction -> [EnginePeer.Id] in diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Payments/AppStore.swift b/submodules/TelegramCore/Sources/TelegramEngine/Payments/AppStore.swift index f802d15ce9d..95a4ae2dfc6 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Payments/AppStore.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Payments/AppStore.swift @@ -19,6 +19,7 @@ public enum AppStoreTransactionPurpose { case giveaway(boostPeer: EnginePeer.Id, additionalPeerIds: [EnginePeer.Id], countries: [String], onlyNewSubscribers: Bool, showWinners: Bool, prizeDescription: String?, randomId: Int64, untilDate: Int32, currency: String, amount: Int64) case stars(count: Int64, currency: String, amount: Int64) case starsGift(peerId: EnginePeer.Id, count: Int64, currency: String, amount: Int64) + case starsGiveaway(stars: Int64, boostPeer: EnginePeer.Id, additionalPeerIds: [EnginePeer.Id], countries: [String], onlyNewSubscribers: Bool, showWinners: Bool, prizeDescription: String?, randomId: Int64, untilDate: Int32, currency: String, amount: Int64, users: Int32) } private func apiInputStorePaymentPurpose(account: Account, purpose: AppStoreTransactionPurpose) -> Signal { @@ -101,6 +102,36 @@ private func apiInputStorePaymentPurpose(account: Account, purpose: AppStoreTran } return .single(.inputStorePaymentStarsGift(userId: inputUser, stars: count, currency: currency, amount: amount)) } + case let .starsGiveaway(stars, boostPeerId, additionalPeerIds, countries, onlyNewSubscribers, showWinners, prizeDescription, randomId, untilDate, currency, amount, users): + return account.postbox.transaction { transaction -> Signal in + guard let peer = transaction.getPeer(boostPeerId), let apiBoostPeer = apiInputPeer(peer) else { + return .complete() + } + var flags: Int32 = 0 + if onlyNewSubscribers { + flags |= (1 << 0) + } + if showWinners { + flags |= (1 << 3) + } + var additionalPeers: [Api.InputPeer] = [] + if !additionalPeerIds.isEmpty { + flags |= (1 << 1) + for peerId in additionalPeerIds { + if let peer = transaction.getPeer(peerId), let inputPeer = apiInputPeer(peer) { + additionalPeers.append(inputPeer) + } + } + } + if !countries.isEmpty { + flags |= (1 << 2) + } + if let _ = prizeDescription { + flags |= (1 << 4) + } + return .single(.inputStorePaymentStarsGiveaway(flags: flags, stars: stars, boostPeer: apiBoostPeer, additionalPeers: additionalPeers, countriesIso2: countries, prizeDescription: prizeDescription, randomId: randomId, untilDate: untilDate, currency: currency, amount: amount, users: users)) + } + |> switchToLatest } } diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Payments/BotPaymentForm.swift b/submodules/TelegramCore/Sources/TelegramEngine/Payments/BotPaymentForm.swift index 5be3cc803b4..6adeaca770d 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Payments/BotPaymentForm.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Payments/BotPaymentForm.swift @@ -12,6 +12,7 @@ public enum BotPaymentInvoiceSource { case stars(option: StarsTopUpOption) case starsGift(peerId: EnginePeer.Id, count: Int64, currency: String, amount: Int64) case starsChatSubscription(hash: String) + case starsGiveaway(stars: Int64, boostPeer: EnginePeer.Id, additionalPeerIds: [EnginePeer.Id], countries: [String], onlyNewSubscribers: Bool, showWinners: Bool, prizeDescription: String?, randomId: Int64, untilDate: Int32, currency: String, amount: Int64, users: Int32) } public struct BotPaymentInvoiceFields: OptionSet { @@ -317,6 +318,33 @@ func _internal_parseInputInvoice(transaction: Transaction, source: BotPaymentInv return .inputInvoiceStars(purpose: .inputStorePaymentStarsGift(userId: inputUser, stars: count, currency: currency, amount: amount)) case let .starsChatSubscription(hash): return .inputInvoiceChatInviteSubscription(hash: hash) + case let .starsGiveaway(stars, boostPeerId, additionalPeerIds, countries, onlyNewSubscribers, showWinners, prizeDescription, randomId, untilDate, currency, amount, users): + guard let peer = transaction.getPeer(boostPeerId), let apiBoostPeer = apiInputPeer(peer) else { + return nil + } + var flags: Int32 = 0 + if onlyNewSubscribers { + flags |= (1 << 0) + } + if showWinners { + flags |= (1 << 3) + } + var additionalPeers: [Api.InputPeer] = [] + if !additionalPeerIds.isEmpty { + flags |= (1 << 1) + for peerId in additionalPeerIds { + if let peer = transaction.getPeer(peerId), let inputPeer = apiInputPeer(peer) { + additionalPeers.append(inputPeer) + } + } + } + if !countries.isEmpty { + flags |= (1 << 2) + } + if let _ = prizeDescription { + flags |= (1 << 4) + } + return .inputInvoiceStars(purpose: .inputStorePaymentStarsGiveaway(flags: flags, stars: stars, boostPeer: apiBoostPeer, additionalPeers: additionalPeers, countriesIso2: countries, prizeDescription: prizeDescription, randomId: randomId, untilDate: untilDate, currency: currency, amount: amount, users: users)) } } @@ -626,6 +654,12 @@ func _internal_sendBotPaymentForm(account: Account, formId: Int64, source: BotPa receiptMessageId = id } } + case let .starsGiveaway(_, _, _, _, _, _, _, randomId, _, _, _, _): + if message.globallyUniqueId == randomId { + if case let .Id(id) = message.id { + receiptMessageId = id + } + } case .giftCode, .stars, .starsGift, .starsChatSubscription: receiptMessageId = nil } diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Payments/GiftCodes.swift b/submodules/TelegramCore/Sources/TelegramEngine/Payments/GiftCodes.swift index b3b21209d68..65bff012cc4 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Payments/GiftCodes.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Payments/GiftCodes.swift @@ -79,17 +79,23 @@ public enum PremiumGiveawayInfo: Equatable { public enum ResultStatus: Equatable { case notWon - case won(slug: String) + case wonPremium(slug: String) + case wonStars(stars: Int64) case refunded } case ongoing(startDate: Int32, status: OngoingStatus) - case finished(status: ResultStatus, startDate: Int32, finishDate: Int32, winnersCount: Int32, activatedCount: Int32) + case finished(status: ResultStatus, startDate: Int32, finishDate: Int32, winnersCount: Int32, activatedCount: Int32?) } public struct PrepaidGiveaway: Equatable { + public enum Prize: Equatable { + case premium(months: Int32) + case stars(stars: Int64, boosts: Int32) + } + public let id: Int64 - public let months: Int32 + public let prize: Prize public let quantity: Int32 public let date: Int32 } @@ -122,12 +128,14 @@ func _internal_getPremiumGiveawayInfo(account: Account, peerId: EnginePeer.Id, m } else { return .ongoing(startDate: startDate, status: .notQualified) } - case let .giveawayInfoResults(flags, startDate, giftCodeSlug, finishDate, winnersCount, activatedCount): + case let .giveawayInfoResults(flags, startDate, giftCodeSlug, stars, finishDate, winnersCount, activatedCount): let status: PremiumGiveawayInfo.ResultStatus if (flags & (1 << 1)) != 0 { status = .refunded + } else if let stars { + status = .wonStars(stars: stars) } else if let giftCodeSlug = giftCodeSlug { - status = .won(slug: giftCodeSlug) + status = .wonPremium(slug: giftCodeSlug) } else { status = .notWon } @@ -216,8 +224,12 @@ func _internal_applyPremiumGiftCode(account: Account, slug: String) -> Signal Signal { +func _internal_launchPrepaidGiveaway(account: Account, peerId: EnginePeer.Id, purpose: LaunchGiveawayPurpose, id: Int64, additionalPeerIds: [EnginePeer.Id], countries: [String], onlyNewSubscribers: Bool, showWinners: Bool, prizeDescription: String?, randomId: Int64, untilDate: Int32) -> Signal { return account.postbox.transaction { transaction -> Signal in var flags: Int32 = 0 if onlyNewSubscribers { @@ -248,7 +260,16 @@ func _internal_launchPrepaidGiveaway(account: Account, peerId: EnginePeer.Id, id guard let inputPeer = inputPeer else { return .complete() } - return account.network.request(Api.functions.payments.launchPrepaidGiveaway(peer: inputPeer, giveawayId: id, purpose: .inputStorePaymentPremiumGiveaway(flags: flags, boostPeer: inputPeer, additionalPeers: additionalPeers, countriesIso2: countries, prizeDescription: prizeDescription, randomId: randomId, untilDate: untilDate, currency: "", amount: 0))) + + let inputPurpose: Api.InputStorePaymentPurpose + switch purpose { + case let .stars(stars, users): + inputPurpose = .inputStorePaymentStarsGiveaway(flags: flags, stars: stars, boostPeer: inputPeer, additionalPeers: additionalPeers, countriesIso2: countries, prizeDescription: prizeDescription, randomId: randomId, untilDate: untilDate, currency: "", amount: 0, users: users) + case .premium: + inputPurpose = .inputStorePaymentPremiumGiveaway(flags: flags, boostPeer: inputPeer, additionalPeers: additionalPeers, countriesIso2: countries, prizeDescription: prizeDescription, randomId: randomId, untilDate: untilDate, currency: "", amount: 0) + } + + return account.network.request(Api.functions.payments.launchPrepaidGiveaway(peer: inputPeer, giveawayId: id, purpose: inputPurpose)) |> mapError { _ -> LaunchPrepaidGiveawayError in return .generic } @@ -301,7 +322,12 @@ extension PrepaidGiveaway { switch apiPrepaidGiveaway { case let .prepaidGiveaway(id, months, quantity, date): self.id = id - self.months = months + self.prize = .premium(months: months) + self.quantity = quantity + self.date = date + case let .prepaidStarsGiveaway(id, stars, quantity, boosts, date): + self.id = id + self.prize = .stars(stars: stars, boosts: boosts) self.quantity = quantity self.date = date } diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Payments/Stars.swift b/submodules/TelegramCore/Sources/TelegramEngine/Payments/Stars.swift index f93551a46ec..4036abb1e67 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Payments/Stars.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Payments/Stars.swift @@ -145,6 +145,130 @@ func _internal_starsGiftOptions(account: Account, peerId: EnginePeer.Id?) -> Sig } } + + +public struct StarsGiveawayOption: Equatable, Codable { + enum CodingKeys: String, CodingKey { + case count + case currency + case amount + case yearlyBoosts + case storeProductId + case winners + case isExtended + case isDefault + } + + public struct Winners: Equatable, Codable { + enum CodingKeys: String, CodingKey { + case users + case starsPerUser + case isDefault + } + + public let users: Int32 + public let starsPerUser: Int64 + public let isDefault: Bool + + public init(users: Int32, starsPerUser: Int64, isDefault: Bool) { + self.users = users + self.starsPerUser = starsPerUser + self.isDefault = isDefault + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.users = try container.decode(Int32.self, forKey: .users) + self.starsPerUser = try container.decode(Int64.self, forKey: .starsPerUser) + self.isDefault = try container.decodeIfPresent(Bool.self, forKey: .isDefault) ?? false + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.users, forKey: .users) + try container.encode(self.starsPerUser, forKey: .starsPerUser) + try container.encode(self.isDefault, forKey: .isDefault) + } + } + + public let count: Int64 + public let yearlyBoosts: Int32 + public let currency: String + public let amount: Int64 + public let storeProductId: String? + public let winners: [Winners] + public let isExtended: Bool + public let isDefault: Bool + + public init(count: Int64, yearlyBoosts: Int32, storeProductId: String?, currency: String, amount: Int64, winners: [Winners], isExtended: Bool, isDefault: Bool) { + self.count = count + self.yearlyBoosts = yearlyBoosts + self.currency = currency + self.amount = amount + self.storeProductId = storeProductId?.replacingOccurrences(of: "telegram_stars.topup", with: "org.telegram.telegramStars.topup") + self.winners = winners + self.isExtended = isExtended + self.isDefault = isDefault + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.count = try container.decode(Int64.self, forKey: .count) + self.yearlyBoosts = try container.decode(Int32.self, forKey: .yearlyBoosts) + self.storeProductId = try container.decodeIfPresent(String.self, forKey: .storeProductId) + self.currency = try container.decode(String.self, forKey: .currency) + self.amount = try container.decode(Int64.self, forKey: .amount) + self.winners = try container.decode([StarsGiveawayOption.Winners].self, forKey: .winners) + self.isExtended = try container.decodeIfPresent(Bool.self, forKey: .isExtended) ?? false + self.isDefault = try container.decodeIfPresent(Bool.self, forKey: .isDefault) ?? false + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.count, forKey: .count) + try container.encode(self.yearlyBoosts, forKey: .yearlyBoosts) + try container.encodeIfPresent(self.storeProductId, forKey: .storeProductId) + try container.encode(self.currency, forKey: .currency) + try container.encode(self.amount, forKey: .amount) + try container.encode(self.winners, forKey: .winners) + try container.encode(self.isExtended, forKey: .isExtended) + try container.encode(self.isDefault, forKey: .isDefault) + } +} + +extension StarsGiveawayOption.Winners { + init(apiStarsGiveawayWinnersOption: Api.StarsGiveawayWinnersOption) { + switch apiStarsGiveawayWinnersOption { + case let .starsGiveawayWinnersOption(flags, users, starsPerUser): + self.init(users: users, starsPerUser: starsPerUser, isDefault: (flags & (1 << 0)) != 0) + } + } +} + +extension StarsGiveawayOption { + init(apiStarsGiveawayOption: Api.StarsGiveawayOption) { + switch apiStarsGiveawayOption { + case let .starsGiveawayOption(flags, stars, yearlyBoosts, storeProduct, currency, amount, winners): + self.init(count: stars, yearlyBoosts: yearlyBoosts, storeProductId: storeProduct, currency: currency, amount: amount, winners: winners.map { StarsGiveawayOption.Winners(apiStarsGiveawayWinnersOption: $0) }, isExtended: (flags & (1 << 0)) != 0, isDefault: (flags & (1 << 1)) != 0) + } + } +} + +func _internal_starsGiveawayOptions(account: Account) -> Signal<[StarsGiveawayOption], NoError> { + return account.network.request(Api.functions.payments.getStarsGiveawayOptions()) + |> map(Optional.init) + |> `catch` { _ -> Signal<[Api.StarsGiveawayOption]?, NoError> in + return .single(nil) + } + |> mapToSignal { results -> Signal<[StarsGiveawayOption], NoError> in + if let results = results { + return .single(results.map { StarsGiveawayOption(apiStarsGiveawayOption: $0) }) + } else { + return .single([]) + } + } +} + struct InternalStarsStatus { let balance: Int64 let subscriptionsMissingBalance: Int64? @@ -230,7 +354,7 @@ private func _internal_requestStarsSubscriptions(account: Account, peerId: Engin } |> castError(RequestStarsSubscriptionsError.self) |> mapToSignal { peer -> Signal in - guard let peer, let inputPeer = apiInputPeer(peer) else { + guard let peer, let inputPeer = apiInputPeerOrSelf(peer, accountPeerId: peerId) else { return .fail(.generic) } var flags: Int32 = 0 @@ -344,7 +468,7 @@ private final class StarsContextImpl { } var transactions = state.transactions if addTransaction { - transactions.insert(.init(flags: [.isLocal], id: "\(arc4random())", count: balance, date: Int32(Date().timeIntervalSince1970), peer: .appStore, title: nil, description: nil, photo: nil, transactionDate: nil, transactionUrl: nil, paidMessageId: nil, media: [], subscriptionPeriod: nil), at: 0) + transactions.insert(.init(flags: [.isLocal], id: "\(arc4random())", count: balance, date: Int32(Date().timeIntervalSince1970), peer: .appStore, title: nil, description: nil, photo: nil, transactionDate: nil, transactionUrl: nil, paidMessageId: nil, giveawayMessageId: nil, media: [], subscriptionPeriod: nil), at: 0) } self.updateState(StarsContext.State(flags: [.isPendingBalance], balance: max(0, state.balance + balance), subscriptions: state.subscriptions, canLoadMoreSubscriptions: state.canLoadMoreSubscriptions, transactions: transactions, canLoadMoreTransactions: state.canLoadMoreTransactions, isLoading: state.isLoading)) @@ -366,9 +490,11 @@ private final class StarsContextImpl { private extension StarsContext.State.Transaction { init?(apiTransaction: Api.StarsTransaction, peerId: EnginePeer.Id?, transaction: Transaction) { switch apiTransaction { - case let .starsTransaction(apiFlags, id, stars, date, transactionPeer, title, description, photo, transactionDate, transactionUrl, _, messageId, extendedMedia, subscriptionPeriod): + case let .starsTransaction(apiFlags, id, stars, date, transactionPeer, title, description, photo, transactionDate, transactionUrl, _, messageId, extendedMedia, subscriptionPeriod, giveawayPostId): let parsedPeer: StarsContext.State.Transaction.Peer var paidMessageId: MessageId? + var giveawayMessageId: MessageId? + switch transactionPeer { case .starsTransactionPeerAppStore: parsedPeer = .appStore @@ -394,6 +520,9 @@ private extension StarsContext.State.Transaction { paidMessageId = MessageId(peerId: peer.id, namespace: Namespaces.Message.Cloud, id: messageId) } } + if let giveawayPostId { + giveawayMessageId = MessageId(peerId: peer.id, namespace: Namespaces.Message.Cloud, id: giveawayPostId) + } } var flags: Flags = [] @@ -415,7 +544,7 @@ private extension StarsContext.State.Transaction { let media = extendedMedia.flatMap({ $0.compactMap { textMediaAndExpirationTimerFromApiMedia($0, PeerId(0)).media } }) ?? [] let _ = subscriptionPeriod - self.init(flags: flags, id: id, count: stars, date: date, peer: parsedPeer, title: title, description: description, photo: photo.flatMap(TelegramMediaWebFile.init), transactionDate: transactionDate, transactionUrl: transactionUrl, paidMessageId: paidMessageId, media: media, subscriptionPeriod: subscriptionPeriod) + self.init(flags: flags, id: id, count: stars, date: date, peer: parsedPeer, title: title, description: description, photo: photo.flatMap(TelegramMediaWebFile.init), transactionDate: transactionDate, transactionUrl: transactionUrl, paidMessageId: paidMessageId, giveawayMessageId: giveawayMessageId, media: media, subscriptionPeriod: subscriptionPeriod) } } } @@ -481,6 +610,7 @@ public final class StarsContext { public let transactionDate: Int32? public let transactionUrl: String? public let paidMessageId: MessageId? + public let giveawayMessageId: MessageId? public let media: [Media] public let subscriptionPeriod: Int32? @@ -496,6 +626,7 @@ public final class StarsContext { transactionDate: Int32?, transactionUrl: String?, paidMessageId: MessageId?, + giveawayMessageId: MessageId?, media: [Media], subscriptionPeriod: Int32? ) { @@ -510,6 +641,7 @@ public final class StarsContext { self.transactionDate = transactionDate self.transactionUrl = transactionUrl self.paidMessageId = paidMessageId + self.giveawayMessageId = giveawayMessageId self.media = media self.subscriptionPeriod = subscriptionPeriod } @@ -548,6 +680,9 @@ public final class StarsContext { if lhs.paidMessageId != rhs.paidMessageId { return false } + if lhs.giveawayMessageId != rhs.giveawayMessageId { + return false + } if !areMediaArraysEqual(lhs.media, rhs.media) { return false } @@ -965,6 +1100,7 @@ private final class StarsSubscriptionsContextImpl { guard let self else { return } + self.nextOffset = status.nextSubscriptionsOffset var updatedState = self._state @@ -1002,13 +1138,18 @@ private final class StarsSubscriptionsContextImpl { func load(force: Bool) { assert(Queue.mainQueue().isCurrent()) + guard !self._state.isLoading else { + return + } + let currentTimestamp = CFAbsoluteTimeGetCurrent() if let previousLoadTimestamp = self.previousLoadTimestamp, currentTimestamp - previousLoadTimestamp < 60 && !force { return } self.previousLoadTimestamp = currentTimestamp + self._state.isLoading = true - self.disposable.set((_internal_requestStarsSubscriptions(account: self.account, peerId: self.account.peerId, offset: "", missingBalance: false) + self.disposable.set((_internal_requestStarsSubscriptions(account: self.account, peerId: self.account.peerId, offset: "", missingBalance: self.missingBalance) |> deliverOnMainQueue).start(next: { [weak self] status in guard let self else { return @@ -1137,6 +1278,12 @@ func _internal_sendStarsPaymentForm(account: Account, formId: Int64, source: Bot receiptMessageId = id } } + case let .starsGiveaway(_, _, _, _, _, _, _, randomId, _, _, _, _): + if message.globallyUniqueId == randomId { + if case let .Id(id) = message.id { + receiptMessageId = id + } + } case .giftCode, .stars, .starsGift: receiptMessageId = nil case .starsChatSubscription: diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Payments/TelegramEnginePayments.swift b/submodules/TelegramCore/Sources/TelegramEngine/Payments/TelegramEnginePayments.swift index 43b9079f7e0..a36471d4c8c 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Payments/TelegramEnginePayments.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Payments/TelegramEnginePayments.swift @@ -62,8 +62,8 @@ public extension TelegramEngine { return _internal_getPremiumGiveawayInfo(account: self.account, peerId: peerId, messageId: messageId) } - public func launchPrepaidGiveaway(peerId: EnginePeer.Id, id: Int64, additionalPeerIds: [EnginePeer.Id], countries: [String], onlyNewSubscribers: Bool, showWinners: Bool, prizeDescription: String?, randomId: Int64, untilDate: Int32) -> Signal { - return _internal_launchPrepaidGiveaway(account: self.account, peerId: peerId, id: id, additionalPeerIds: additionalPeerIds, countries: countries, onlyNewSubscribers: onlyNewSubscribers, showWinners: showWinners, prizeDescription: prizeDescription, randomId: randomId, untilDate: untilDate) + public func launchPrepaidGiveaway(peerId: EnginePeer.Id, id: Int64, purpose: LaunchGiveawayPurpose, additionalPeerIds: [EnginePeer.Id], countries: [String], onlyNewSubscribers: Bool, showWinners: Bool, prizeDescription: String?, randomId: Int64, untilDate: Int32) -> Signal { + return _internal_launchPrepaidGiveaway(account: self.account, peerId: peerId, purpose: purpose, id: id, additionalPeerIds: additionalPeerIds, countries: countries, onlyNewSubscribers: onlyNewSubscribers, showWinners: showWinners, prizeDescription: prizeDescription, randomId: randomId, untilDate: untilDate) } public func starsTopUpOptions() -> Signal<[StarsTopUpOption], NoError> { @@ -74,6 +74,10 @@ public extension TelegramEngine { return _internal_starsGiftOptions(account: self.account, peerId: peerId) } + public func starsGiveawayOptions() -> Signal<[StarsGiveawayOption], NoError> { + return _internal_starsGiveawayOptions(account: self.account) + } + public func peerStarsContext() -> StarsContext { return StarsContext(account: self.account) } diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelAdminEventLogs.swift b/submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelAdminEventLogs.swift index bc455b62093..39e07ac7409 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelAdminEventLogs.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelAdminEventLogs.swift @@ -93,6 +93,7 @@ public enum AdminLogEventAction { case changeWallpaper(prev: TelegramWallpaper?, new: TelegramWallpaper?) case changeStatus(prev: PeerEmojiStatus?, new: PeerEmojiStatus?) case changeEmojiPack(prev: StickerPackReference?, new: StickerPackReference?) + case participantSubscriptionExtended(prev: RenderedChannelParticipant, new: RenderedChannelParticipant) } public enum ChannelAdminLogEventError { @@ -449,6 +450,13 @@ func channelAdminLogEvents(accountPeerId: PeerId, postbox: Postbox, network: Net action = .changeEmojiPack(prev: StickerPackReference(apiInputSet: prevStickerset), new: StickerPackReference(apiInputSet: newStickerset)) case let .channelAdminLogEventActionToggleSignatureProfiles(newValue): action = .toggleSignatureProfiles(boolFromApiValue(newValue)) + case let .channelAdminLogEventActionParticipantSubExtend(prev, new): + let prevParticipant = ChannelParticipant(apiParticipant: prev) + let newParticipant = ChannelParticipant(apiParticipant: new) + + if let prevPeer = peers[prevParticipant.peerId], let newPeer = peers[newParticipant.peerId] { + action = .participantSubscriptionExtended(prev: RenderedChannelParticipant(participant: prevParticipant, peer: prevPeer), new: RenderedChannelParticipant(participant: newParticipant, peer: newPeer)) + } } let peerId = PeerId(namespace: Namespaces.Peer.CloudUser, id: PeerId.Id._internalFromInt64Value(userId)) if let action = action { diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Peers/TelegramEnginePeers.swift b/submodules/TelegramCore/Sources/TelegramEngine/Peers/TelegramEnginePeers.swift index 18577aff3e0..8736e95596b 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Peers/TelegramEnginePeers.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Peers/TelegramEnginePeers.swift @@ -1622,6 +1622,12 @@ public extension TelegramEngine { } } } + + public func setStarsReactionDefaultToPrivate(isPrivate: Bool) { + let _ = self.account.postbox.transaction({ transaction in + _internal_setStarsReactionDefaultToPrivate(isPrivate: isPrivate, transaction: transaction) + }).startStandalone() + } } } diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Resources/TelegramEngineResources.swift b/submodules/TelegramCore/Sources/TelegramEngine/Resources/TelegramEngineResources.swift index ded57ff0d6f..9423e2c5825 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Resources/TelegramEngineResources.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Resources/TelegramEngineResources.swift @@ -341,8 +341,8 @@ public extension TelegramEngine { } } - public func httpData(url: String) -> Signal { - return fetchHttpResource(url: url) + public func httpData(url: String, preserveExactUrl: Bool = false) -> Signal { + return fetchHttpResource(url: url, preserveExactUrl: preserveExactUrl) |> mapError { _ -> EngineMediaResource.Fetch.Error in return .generic } diff --git a/submodules/TelegramCore/Sources/WebpagePreview.swift b/submodules/TelegramCore/Sources/WebpagePreview.swift index 21f7e460327..d1c60643706 100644 --- a/submodules/TelegramCore/Sources/WebpagePreview.swift +++ b/submodules/TelegramCore/Sources/WebpagePreview.swift @@ -262,7 +262,7 @@ public func actualizedWebpage(account: Account, webpage: TelegramMediaWebpage) - } } -func updatedRemoteWebpage(postbox: Postbox, network: Network, accountPeerId: EnginePeer.Id, webPage: WebpageReference) -> Signal { +public func updatedRemoteWebpage(postbox: Postbox, network: Network, accountPeerId: EnginePeer.Id, webPage: WebpageReference) -> Signal { if case let .webPage(id, url) = webPage.content { return network.request(Api.functions.messages.getWebPage(url: url, hash: 0)) |> map(Optional.init) @@ -270,14 +270,20 @@ func updatedRemoteWebpage(postbox: Postbox, network: Network, accountPeerId: Eng return .single(nil) } |> mapToSignal { result -> Signal in - if let result = result, case let .webPage(webpage, chats, users) = result, let updatedWebpage = telegramMediaWebpageFromApiWebpage(webpage), case .Loaded = updatedWebpage.content, updatedWebpage.webpageId.id == id { - return postbox.transaction { transaction -> TelegramMediaWebpage? in - let parsedPeers = AccumulatedPeers(transaction: transaction, chats: chats, users: users) - updatePeers(transaction: transaction, accountPeerId: accountPeerId, peers: parsedPeers) - if transaction.getMedia(updatedWebpage.webpageId) != nil { - updateMessageMedia(transaction: transaction, id: updatedWebpage.webpageId, media: updatedWebpage) + if let result = result, case let .webPage(webpage, chats, users) = result, let updatedWebpage = telegramMediaWebpageFromApiWebpage(webpage), case .Loaded = updatedWebpage.content { + if updatedWebpage.webpageId.id == id { + return postbox.transaction { transaction -> TelegramMediaWebpage? in + let parsedPeers = AccumulatedPeers(transaction: transaction, chats: chats, users: users) + updatePeers(transaction: transaction, accountPeerId: accountPeerId, peers: parsedPeers) + if transaction.getMedia(updatedWebpage.webpageId) != nil { + updateMessageMedia(transaction: transaction, id: updatedWebpage.webpageId, media: updatedWebpage) + } + return updatedWebpage } - return updatedWebpage + } else if id == 0 { + return .single(updatedWebpage) + } else { + return .single(nil) } } else { return .single(nil) diff --git a/submodules/TelegramPresentationData/Sources/DefaultDarkPresentationTheme.swift b/submodules/TelegramPresentationData/Sources/DefaultDarkPresentationTheme.swift index d80705ed74f..361c840421b 100644 --- a/submodules/TelegramPresentationData/Sources/DefaultDarkPresentationTheme.swift +++ b/submodules/TelegramPresentationData/Sources/DefaultDarkPresentationTheme.swift @@ -481,8 +481,8 @@ public func makeDefaultDarkPresentationTheme(extendingThemeReference: Presentati itemSeparatorColor: UIColor(rgb: 0x545458, alpha: 0.55), itemBackgroundColor: UIColor(rgb: 0x000000), pinnedItemBackgroundColor: UIColor(rgb: 0x1c1c1d), - itemHighlightedBackgroundColor: UIColor(rgb: 0xffffff, alpha: 0.07), - pinnedItemHighlightedBackgroundColor: UIColor(rgb: 0xffffff, alpha: 0.07), + itemHighlightedBackgroundColor: UIColor(rgb: 0x121212), + pinnedItemHighlightedBackgroundColor: UIColor(rgb: 0x2b2b2c), itemSelectedBackgroundColor: UIColor(rgb: 0x191919), titleColor: UIColor(rgb: 0xffffff), secretTitleColor: UIColor(rgb: 0x00b12c), @@ -497,7 +497,7 @@ public func makeDefaultDarkPresentationTheme(extendingThemeReference: Presentati failedForegroundColor: UIColor(rgb: 0xffffff), muteIconColor: UIColor(rgb: 0x8d8e93), unreadBadgeActiveBackgroundColor: UIColor(rgb: 0xffffff), - unreadBadgeActiveTextColor: UIColor(rgb: 0x000000), + unreadBadgeActiveTextColor: UIColor(rgb: 0x000000), unreadBadgeInactiveBackgroundColor: UIColor(rgb: 0x666666), unreadBadgeInactiveTextColor:UIColor(rgb: 0x000000), reactionBadgeActiveBackgroundColor: UIColor(rgb: 0xFF2D55), @@ -531,7 +531,7 @@ public func makeDefaultDarkPresentationTheme(extendingThemeReference: Presentati reactionInactiveForeground: UIColor(rgb: 0xffffff), reactionActiveBackground: UIColor(rgb: 0xffffff, alpha: 1.0), reactionActiveForeground: .clear, - reactionStarsInactiveBackground: UIColor(rgb: 0xFEF1D4, alpha: 1.0), + reactionStarsInactiveBackground: UIColor(rgb: 0xD3720A, alpha: 0.2), reactionStarsInactiveForeground: UIColor(rgb: 0xD3720A), reactionStarsActiveBackground: UIColor(rgb: 0xD3720A, alpha: 1.0), reactionStarsActiveForeground: .white, @@ -547,7 +547,7 @@ public func makeDefaultDarkPresentationTheme(extendingThemeReference: Presentati reactionInactiveForeground: UIColor(rgb: 0xffffff), reactionActiveBackground: UIColor(rgb: 0xffffff, alpha: 1.0), reactionActiveForeground: .clear, - reactionStarsInactiveBackground: UIColor(rgb: 0xFEF1D4, alpha: 1.0), + reactionStarsInactiveBackground: UIColor(rgb: 0xD3720A, alpha: 0.2), reactionStarsInactiveForeground: UIColor(rgb: 0xD3720A), reactionStarsActiveBackground: UIColor(rgb: 0xD3720A, alpha: 1.0), reactionStarsActiveForeground: .white, @@ -569,7 +569,7 @@ public func makeDefaultDarkPresentationTheme(extendingThemeReference: Presentati reactionInactiveForeground: UIColor(rgb: 0xffffff), reactionActiveBackground: UIColor(rgb: 0xffffff, alpha: 1.0), reactionActiveForeground: .clear, - reactionStarsInactiveBackground: UIColor(rgb: 0xFEF1D4, alpha: 1.0), + reactionStarsInactiveBackground: UIColor(rgb: 0xD3720A, alpha: 0.2), reactionStarsInactiveForeground: UIColor(rgb: 0xD3720A), reactionStarsActiveBackground: UIColor(rgb: 0xD3720A, alpha: 1.0), reactionStarsActiveForeground: .white, @@ -585,7 +585,7 @@ public func makeDefaultDarkPresentationTheme(extendingThemeReference: Presentati reactionInactiveForeground: UIColor(rgb: 0xffffff), reactionActiveBackground: UIColor(rgb: 0xffffff, alpha: 1.0), reactionActiveForeground: .clear, - reactionStarsInactiveBackground: UIColor(rgb: 0xFEF1D4, alpha: 1.0), + reactionStarsInactiveBackground: UIColor(rgb: 0xD3720A, alpha: 0.2), reactionStarsInactiveForeground: UIColor(rgb: 0xD3720A), reactionStarsActiveBackground: UIColor(rgb: 0xD3720A, alpha: 1.0), reactionStarsActiveForeground: .white, @@ -604,7 +604,7 @@ public func makeDefaultDarkPresentationTheme(extendingThemeReference: Presentati reactionInactiveForeground: UIColor(rgb: 0xffffff), reactionActiveBackground: UIColor(rgb: 0xffffff, alpha: 1.0), reactionActiveForeground: .clear, - reactionStarsInactiveBackground: UIColor(rgb: 0xFEF1D4, alpha: 1.0), + reactionStarsInactiveBackground: UIColor(rgb: 0xD3720A, alpha: 0.2), reactionStarsInactiveForeground: UIColor(rgb: 0xD3720A), reactionStarsActiveBackground: UIColor(rgb: 0xD3720A, alpha: 1.0), reactionStarsActiveForeground: .white, @@ -620,7 +620,7 @@ public func makeDefaultDarkPresentationTheme(extendingThemeReference: Presentati reactionInactiveForeground: UIColor(rgb: 0xffffff), reactionActiveBackground: UIColor(rgb: 0xffffff, alpha: 1.0), reactionActiveForeground: .clear, - reactionStarsInactiveBackground: UIColor(rgb: 0xFEF1D4, alpha: 1.0), + reactionStarsInactiveBackground: UIColor(rgb: 0xD3720A, alpha: 0.2), reactionStarsInactiveForeground: UIColor(rgb: 0xD3720A), reactionStarsActiveBackground: UIColor(rgb: 0xD3720A, alpha: 1.0), reactionStarsActiveForeground: .white, diff --git a/submodules/TelegramPresentationData/Sources/DefaultDayPresentationTheme.swift b/submodules/TelegramPresentationData/Sources/DefaultDayPresentationTheme.swift index 69b9a6813a8..d32cae1ad7d 100644 --- a/submodules/TelegramPresentationData/Sources/DefaultDayPresentationTheme.swift +++ b/submodules/TelegramPresentationData/Sources/DefaultDayPresentationTheme.swift @@ -600,7 +600,7 @@ public func makeDefaultDayPresentationTheme(extendingThemeReference: Presentatio reactionActiveForeground: .clear, reactionStarsInactiveBackground: UIColor(rgb: 0xFEF1D4, alpha: 1.0), reactionStarsInactiveForeground: UIColor(rgb: 0xD3720A), - reactionStarsActiveBackground: UIColor(rgb: 0xD3720A, alpha: 1.0), + reactionStarsActiveBackground: UIColor(rgb: 0xFFBC2E, alpha: 1.0), reactionStarsActiveForeground: .white, reactionInactiveMediaPlaceholder: UIColor(rgb: 0xffffff, alpha: 0.2), reactionActiveMediaPlaceholder: UIColor(rgb: 0xffffff, alpha: 0.2) @@ -616,7 +616,7 @@ public func makeDefaultDayPresentationTheme(extendingThemeReference: Presentatio reactionActiveForeground: .clear, reactionStarsInactiveBackground: UIColor(rgb: 0xFEF1D4, alpha: 1.0), reactionStarsInactiveForeground: UIColor(rgb: 0xD3720A), - reactionStarsActiveBackground: UIColor(rgb: 0xD3720A, alpha: 1.0), + reactionStarsActiveBackground: UIColor(rgb: 0xFFBC2E, alpha: 1.0), reactionStarsActiveForeground: .white, reactionInactiveMediaPlaceholder: UIColor(rgb: 0xffffff, alpha: 0.2), reactionActiveMediaPlaceholder: UIColor(rgb: 0xffffff, alpha: 0.2) @@ -655,7 +655,7 @@ public func makeDefaultDayPresentationTheme(extendingThemeReference: Presentatio reactionActiveForeground: .clear, reactionStarsInactiveBackground: UIColor(rgb: 0xFEF1D4, alpha: 1.0), reactionStarsInactiveForeground: UIColor(rgb: 0xD3720A), - reactionStarsActiveBackground: UIColor(rgb: 0xD3720A, alpha: 1.0), + reactionStarsActiveBackground: UIColor(rgb: 0xFFBC2E, alpha: 1.0), reactionStarsActiveForeground: .white, reactionInactiveMediaPlaceholder: UIColor(rgb: 0xffffff, alpha: 0.2), reactionActiveMediaPlaceholder: UIColor(rgb: 0xffffff, alpha: 0.2) @@ -671,7 +671,7 @@ public func makeDefaultDayPresentationTheme(extendingThemeReference: Presentatio reactionActiveForeground: .clear, reactionStarsInactiveBackground: UIColor(rgb: 0xFEF1D4, alpha: 1.0), reactionStarsInactiveForeground: UIColor(rgb: 0xD3720A), - reactionStarsActiveBackground: UIColor(rgb: 0xD3720A, alpha: 1.0), + reactionStarsActiveBackground: UIColor(rgb: 0xFFBC2E, alpha: 1.0), reactionStarsActiveForeground: .white, reactionInactiveMediaPlaceholder: UIColor(rgb: 0xffffff, alpha: 0.2), reactionActiveMediaPlaceholder: UIColor(rgb: 0xffffff, alpha: 0.2) @@ -712,7 +712,7 @@ public func makeDefaultDayPresentationTheme(extendingThemeReference: Presentatio reactionActiveForeground: UIColor(white: 0.0, alpha: 0.1), reactionStarsInactiveBackground: UIColor(rgb: 0xFEF1D4, alpha: 1.0), reactionStarsInactiveForeground: UIColor(rgb: 0xD3720A), - reactionStarsActiveBackground: UIColor(rgb: 0xD3720A, alpha: 1.0), + reactionStarsActiveBackground: UIColor(rgb: 0xFFBC2E, alpha: 1.0), reactionStarsActiveForeground: .white, reactionInactiveMediaPlaceholder: UIColor(rgb: 0xffffff, alpha: 0.2), reactionActiveMediaPlaceholder: UIColor(rgb: 0xffffff, alpha: 0.2) @@ -728,7 +728,7 @@ public func makeDefaultDayPresentationTheme(extendingThemeReference: Presentatio reactionActiveForeground: UIColor(white: 0.0, alpha: 0.1), reactionStarsInactiveBackground: UIColor(rgb: 0xFEF1D4, alpha: 1.0), reactionStarsInactiveForeground: UIColor(rgb: 0xD3720A), - reactionStarsActiveBackground: UIColor(rgb: 0xD3720A, alpha: 1.0), + reactionStarsActiveBackground: UIColor(rgb: 0xFFBC2E, alpha: 1.0), reactionStarsActiveForeground: .white, reactionInactiveMediaPlaceholder: UIColor(rgb: 0xffffff, alpha: 0.2), reactionActiveMediaPlaceholder: UIColor(rgb: 0xffffff, alpha: 0.2) @@ -764,7 +764,7 @@ public func makeDefaultDayPresentationTheme(extendingThemeReference: Presentatio reactionActiveForeground: .clear, reactionStarsInactiveBackground: UIColor(rgb: 0xFEF1D4, alpha: 1.0), reactionStarsInactiveForeground: UIColor(rgb: 0xD3720A), - reactionStarsActiveBackground: UIColor(rgb: 0xD3720A, alpha: 1.0), + reactionStarsActiveBackground: UIColor(rgb: 0xFFBC2E, alpha: 1.0), reactionStarsActiveForeground: .white, reactionInactiveMediaPlaceholder: UIColor(rgb: 0xffffff, alpha: 0.2), reactionActiveMediaPlaceholder: UIColor(rgb: 0xffffff, alpha: 0.2) @@ -780,7 +780,7 @@ public func makeDefaultDayPresentationTheme(extendingThemeReference: Presentatio reactionActiveForeground: .clear, reactionStarsInactiveBackground: UIColor(rgb: 0xFEF1D4, alpha: 1.0), reactionStarsInactiveForeground: UIColor(rgb: 0xD3720A), - reactionStarsActiveBackground: UIColor(rgb: 0xD3720A, alpha: 1.0), + reactionStarsActiveBackground: UIColor(rgb: 0xFFBC2E, alpha: 1.0), reactionStarsActiveForeground: .white, reactionInactiveMediaPlaceholder: UIColor(rgb: 0xffffff, alpha: 0.2), reactionActiveMediaPlaceholder: UIColor(rgb: 0xffffff, alpha: 0.2) @@ -822,7 +822,7 @@ public func makeDefaultDayPresentationTheme(extendingThemeReference: Presentatio reactionActiveForeground: .clear, reactionStarsInactiveBackground: UIColor(rgb: 0xFEF1D4, alpha: 1.0), reactionStarsInactiveForeground: UIColor(rgb: 0xD3720A), - reactionStarsActiveBackground: UIColor(rgb: 0xD3720A, alpha: 1.0), + reactionStarsActiveBackground: UIColor(rgb: 0xFFBC2E, alpha: 1.0), reactionStarsActiveForeground: .white, reactionInactiveMediaPlaceholder: UIColor(rgb: 0xffffff, alpha: 0.2), reactionActiveMediaPlaceholder: UIColor(rgb: 0xffffff, alpha: 0.2) @@ -838,7 +838,7 @@ public func makeDefaultDayPresentationTheme(extendingThemeReference: Presentatio reactionActiveForeground: .clear, reactionStarsInactiveBackground: UIColor(rgb: 0xFEF1D4, alpha: 1.0), reactionStarsInactiveForeground: UIColor(rgb: 0xD3720A), - reactionStarsActiveBackground: UIColor(rgb: 0xD3720A, alpha: 1.0), + reactionStarsActiveBackground: UIColor(rgb: 0xFFBC2E, alpha: 1.0), reactionStarsActiveForeground: .white, reactionInactiveMediaPlaceholder: UIColor(rgb: 0xffffff, alpha: 0.2), reactionActiveMediaPlaceholder: UIColor(rgb: 0xffffff, alpha: 0.2) @@ -879,7 +879,7 @@ public func makeDefaultDayPresentationTheme(extendingThemeReference: Presentatio reactionActiveForeground: .clear, reactionStarsInactiveBackground: UIColor(rgb: 0xFEF1D4, alpha: 1.0), reactionStarsInactiveForeground: UIColor(rgb: 0xD3720A), - reactionStarsActiveBackground: UIColor(rgb: 0xD3720A, alpha: 1.0), + reactionStarsActiveBackground: UIColor(rgb: 0xFFBC2E, alpha: 1.0), reactionStarsActiveForeground: .white, reactionInactiveMediaPlaceholder: UIColor(rgb: 0xffffff, alpha: 0.2), reactionActiveMediaPlaceholder: UIColor(rgb: 0xffffff, alpha: 0.2) @@ -895,7 +895,7 @@ public func makeDefaultDayPresentationTheme(extendingThemeReference: Presentatio reactionActiveForeground: .clear, reactionStarsInactiveBackground: UIColor(rgb: 0xFEF1D4, alpha: 1.0), reactionStarsInactiveForeground: UIColor(rgb: 0xD3720A), - reactionStarsActiveBackground: UIColor(rgb: 0xD3720A, alpha: 1.0), + reactionStarsActiveBackground: UIColor(rgb: 0xFFBC2E, alpha: 1.0), reactionStarsActiveForeground: .white, reactionInactiveMediaPlaceholder: UIColor(rgb: 0xffffff, alpha: 0.2), reactionActiveMediaPlaceholder: UIColor(rgb: 0xffffff, alpha: 0.2) diff --git a/submodules/TelegramStringFormatting/Sources/ServiceMessageStrings.swift b/submodules/TelegramStringFormatting/Sources/ServiceMessageStrings.swift index 4bdc13490f1..6e8bc0a8989 100644 --- a/submodules/TelegramStringFormatting/Sources/ServiceMessageStrings.swift +++ b/submodules/TelegramStringFormatting/Sources/ServiceMessageStrings.swift @@ -241,7 +241,6 @@ public func universalServiceMessageString(presentationData: (PresentationTheme, } else { type = .video } - break inner case let .Audio(isVoice, _, _, _, _): if isVoice { type = .audio @@ -966,22 +965,37 @@ public func universalServiceMessageString(presentationData: (PresentationTheme, } else { attributedString = NSAttributedString(string: strings.Notification_GiftLink, font: titleFont, textColor: primaryTextColor) } - case .giveawayLaunched: + case let .giveawayLaunched(stars): var isGroup = false let messagePeer = message.peers[message.id.peerId] if let channel = messagePeer as? TelegramChannel, case .group = channel.info { isGroup = true } - let resultTitleString = isGroup ? strings.Notification_GiveawayStartedGroup(compactAuthorName) : strings.Notification_GiveawayStarted(compactAuthorName) + let resultTitleString: PresentationStrings.FormattedString + if let stars { + let starsString = strings.Notification_StarsGiveawayStarted_Stars(Int32(stars)) + resultTitleString = isGroup ? strings.Notification_StarsGiveawayStartedGroup(compactAuthorName, starsString) : strings.Notification_StarsGiveawayStarted(compactAuthorName, starsString) + } else { + resultTitleString = isGroup ? strings.Notification_GiveawayStartedGroup(compactAuthorName) : strings.Notification_GiveawayStarted(compactAuthorName) + } attributedString = addAttributesToStringWithRanges(resultTitleString._tuple, body: bodyAttributes, argumentAttributes: [0: boldAttributes]) case .joinedChannel: attributedString = NSAttributedString(string: strings.Notification_ChannelJoinedByYou, font: titleBoldFont, textColor: primaryTextColor) - case let .giveawayResults(winners, unclaimed): + case let .giveawayResults(winners, unclaimed, stars): + var isGroup = false + let messagePeer = message.peers[message.id.peerId] + if let channel = messagePeer as? TelegramChannel, case .group = channel.info { + isGroup = true + } if winners == 0 { - attributedString = parseMarkdownIntoAttributedString(strings.Notification_GiveawayResultsNoWinners(unclaimed), attributes: MarkdownAttributes(body: bodyAttributes, bold: boldAttributes, link: bodyAttributes, linkAttribute: { _ in return nil })) + if stars { + attributedString = parseMarkdownIntoAttributedString(isGroup ? strings.Notification_StarsGiveawayResultsNoWinners_Group : strings.Notification_StarsGiveawayResultsNoWinners, attributes: MarkdownAttributes(body: bodyAttributes, bold: boldAttributes, link: bodyAttributes, linkAttribute: { _ in return nil })) + } else { + attributedString = parseMarkdownIntoAttributedString(isGroup ? strings.Notification_GiveawayResultsNoWinners_Group(unclaimed) : strings.Notification_GiveawayResultsNoWinners(unclaimed), attributes: MarkdownAttributes(body: bodyAttributes, bold: boldAttributes, link: bodyAttributes, linkAttribute: { _ in return nil })) + } } else if unclaimed > 0 { let winnersString = parseMarkdownIntoAttributedString(strings.Notification_GiveawayResultsMixedWinners(winners), attributes: MarkdownAttributes(body: bodyAttributes, bold: boldAttributes, link: bodyAttributes, linkAttribute: { _ in return nil })) - let unclaimedString = parseMarkdownIntoAttributedString(strings.Notification_GiveawayResultsMixedUnclaimed(unclaimed), attributes: MarkdownAttributes(body: bodyAttributes, bold: boldAttributes, link: bodyAttributes, linkAttribute: { _ in return nil })) + let unclaimedString = parseMarkdownIntoAttributedString(isGroup ? strings.Notification_GiveawayResultsMixedUnclaimed_Group(unclaimed) : strings.Notification_GiveawayResultsMixedUnclaimed(unclaimed), attributes: MarkdownAttributes(body: bodyAttributes, bold: boldAttributes, link: bodyAttributes, linkAttribute: { _ in return nil })) let combinedString = NSMutableAttributedString(attributedString: winnersString) combinedString.append(NSAttributedString(string: "\n")) combinedString.append(unclaimedString) @@ -1035,6 +1049,8 @@ public func universalServiceMessageString(presentationData: (PresentationTheme, mutableString.addAttribute(NSAttributedString.Key(TelegramTextAttributes.PeerMention), value: TelegramPeerMention(peerId: peerId, mention: ""), range: NSMakeRange(range.location, (peerName as NSString).length)) } attributedString = mutableString + case .prizeStars: + attributedString = NSAttributedString(string: strings.Notification_StarsPrize, font: titleFont, textColor: primaryTextColor) case .unknown: attributedString = nil } diff --git a/submodules/TelegramUI/Components/BackButtonComponent/BUILD b/submodules/TelegramUI/Components/BackButtonComponent/BUILD index 76fc24d3b64..ae748df9f96 100644 --- a/submodules/TelegramUI/Components/BackButtonComponent/BUILD +++ b/submodules/TelegramUI/Components/BackButtonComponent/BUILD @@ -11,7 +11,7 @@ swift_library( ], deps = [ "//submodules/ComponentFlow", - "//submodules/Components/BundleIconComponent", + "//submodules/Components/MultilineTextComponent", "//submodules/Display", ], visibility = [ diff --git a/submodules/TelegramUI/Components/BackButtonComponent/Sources/BackButtonComponent.swift b/submodules/TelegramUI/Components/BackButtonComponent/Sources/BackButtonComponent.swift index 2838a44c68b..d31c4d1ea3d 100644 --- a/submodules/TelegramUI/Components/BackButtonComponent/Sources/BackButtonComponent.swift +++ b/submodules/TelegramUI/Components/BackButtonComponent/Sources/BackButtonComponent.swift @@ -1,8 +1,8 @@ import Foundation import UIKit import ComponentFlow -import BundleIconComponent import Display +import MultilineTextComponent public final class BackButtonComponent: Component { public let title: String @@ -30,22 +30,31 @@ public final class BackButtonComponent: Component { } public final class View: HighlightTrackingButton { - private let arrow = ComponentView() + private let arrowView: UIImageView private let title = ComponentView() private var component: BackButtonComponent? public override init(frame: CGRect) { + self.arrowView = UIImageView() + super.init(frame: frame) + self.addSubview(self.arrowView) + self.highligthedChanged = { [weak self] highlighted in if let self { + let transition: ComponentTransition = highlighted ? .immediate : .easeInOut(duration: 0.2) if highlighted { - self.layer.removeAnimation(forKey: "opacity") - self.alpha = 0.65 + transition.setAlpha(view: self.arrowView, alpha: 0.65) + if let titleView = self.title.view { + transition.setAlpha(view: titleView, alpha: 0.65) + } } else { - self.alpha = 1.0 - self.layer.animateAlpha(from: 0.65, to: 1.0, duration: 0.2) + transition.setAlpha(view: self.arrowView, alpha: 1.0) + if let titleView = self.title.view { + transition.setAlpha(view: titleView, alpha: 1.0) + } } } } @@ -64,22 +73,44 @@ public final class BackButtonComponent: Component { } override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { - return super.hitTest(point, with: event) + if self.isHidden || self.alpha.isZero || self.isUserInteractionEnabled == false { + return nil + } + + if self.bounds.insetBy(dx: -8.0, dy: -8.0).contains(point) { + return self + } + + return nil } func update(component: BackButtonComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { - let sideInset: CGFloat = 4.0 + self.component = component + + if self.arrowView.image == nil { + self.arrowView.image = NavigationBar.backArrowImage(color: .white)?.withRenderingMode(.alwaysTemplate) + } + self.arrowView.tintColor = component.color let titleSize = self.title.update( transition: .immediate, - component: AnyComponent(Text(text: component.title, font: Font.regular(17.0), color: component.color)), + component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString(string: component.title, font: Font.regular(17.0), textColor: component.color)) + )), environment: {}, containerSize: CGSize(width: availableSize.width - 4.0, height: availableSize.height) ) + + let arrowInset: CGFloat = 15.0 - let size = CGSize(width: sideInset * 2.0 + titleSize.width, height: availableSize.height) + let size = CGSize(width: arrowInset + titleSize.width, height: titleSize.height) + + if let arrowImage = self.arrowView.image { + let arrowFrame = CGRect(origin: CGPoint(x: -4.0, y: floor((size.height - arrowImage.size.height) * 0.5)), size: arrowImage.size) + transition.setFrame(view: self.arrowView, frame: arrowFrame) + } - let titleFrame = titleSize.centered(in: CGRect(origin: CGPoint(), size: size)) + let titleFrame = CGRect(origin: CGPoint(x: arrowInset, y: floor((size.height - titleSize.height) * 0.5)), size: titleSize) if let titleView = self.title.view { if titleView.superview == nil { titleView.layer.anchorPoint = CGPoint() diff --git a/submodules/TelegramUI/Components/Calls/CallScreen/Sources/Components/PrivateCallPictureInPictureView.swift b/submodules/TelegramUI/Components/Calls/CallScreen/Sources/Components/PrivateCallPictureInPictureView.swift index 8973b2cc14e..819af62dcf6 100644 --- a/submodules/TelegramUI/Components/Calls/CallScreen/Sources/Components/PrivateCallPictureInPictureView.swift +++ b/submodules/TelegramUI/Components/Calls/CallScreen/Sources/Components/PrivateCallPictureInPictureView.swift @@ -191,7 +191,7 @@ final class PrivateCallPictureInPictureView: UIView { } if let videoMetrics = self.videoMetrics { - let resolvedRotationAngle = resolveVideoRotationAngle(angle: videoMetrics.rotationAngle, followsDeviceOrientation: videoMetrics.followsDeviceOrientation, interfaceOrientation: UIApplication.shared.statusBarOrientation) + let resolvedRotationAngle = resolveCallVideoRotationAngle(angle: videoMetrics.rotationAngle, followsDeviceOrientation: videoMetrics.followsDeviceOrientation, interfaceOrientation: UIApplication.shared.statusBarOrientation) var rotatedResolution = videoMetrics.resolution var videoIsRotated = false diff --git a/submodules/TelegramUI/Components/Calls/CallScreen/Sources/Components/VideoContainerView.swift b/submodules/TelegramUI/Components/Calls/CallScreen/Sources/Components/VideoContainerView.swift index 8a18633c710..68addda2d28 100644 --- a/submodules/TelegramUI/Components/Calls/CallScreen/Sources/Components/VideoContainerView.swift +++ b/submodules/TelegramUI/Components/Calls/CallScreen/Sources/Components/VideoContainerView.swift @@ -9,7 +9,7 @@ private let shadowImage: UIImage? = { UIImage(named: "Call/VideoGradient")?.precomposed() }() -func resolveVideoRotationAngle(angle: Float, followsDeviceOrientation: Bool, interfaceOrientation: UIInterfaceOrientation) -> Float { +public func resolveCallVideoRotationAngle(angle: Float, followsDeviceOrientation: Bool, interfaceOrientation: UIInterfaceOrientation) -> Float { if !followsDeviceOrientation { return angle } @@ -408,7 +408,7 @@ final class VideoContainerView: HighlightTrackingButton { self.dragPositionAnimatorLink = nil return } - let videoLayout = self.calculateMinimizedLayout(params: params, videoMetrics: videoMetrics, resolvedRotationAngle: resolveVideoRotationAngle(angle: videoMetrics.rotationAngle, followsDeviceOrientation: videoMetrics.followsDeviceOrientation, interfaceOrientation: params.interfaceOrientation), applyDragPosition: false) + let videoLayout = self.calculateMinimizedLayout(params: params, videoMetrics: videoMetrics, resolvedRotationAngle: resolveCallVideoRotationAngle(angle: videoMetrics.rotationAngle, followsDeviceOrientation: videoMetrics.followsDeviceOrientation, interfaceOrientation: params.interfaceOrientation), applyDragPosition: false) let targetPosition = videoLayout.rotatedVideoFrame.center self.dragVelocity = self.updateVelocityUsingSpring( @@ -558,7 +558,7 @@ final class VideoContainerView: HighlightTrackingButton { } self.appliedVideoMetrics = videoMetrics - let resolvedRotationAngle = resolveVideoRotationAngle(angle: videoMetrics.rotationAngle, followsDeviceOrientation: videoMetrics.followsDeviceOrientation, interfaceOrientation: params.interfaceOrientation) + let resolvedRotationAngle = resolveCallVideoRotationAngle(angle: videoMetrics.rotationAngle, followsDeviceOrientation: videoMetrics.followsDeviceOrientation, interfaceOrientation: params.interfaceOrientation) if params.isMinimized { self.isFillingBounds = false @@ -588,7 +588,7 @@ final class VideoContainerView: HighlightTrackingButton { if let disappearingVideoLayer = self.disappearingVideoLayer { self.disappearingVideoLayer = nil - let disappearingVideoLayout = self.calculateMinimizedLayout(params: params, videoMetrics: disappearingVideoLayer.videoMetrics, resolvedRotationAngle: resolveVideoRotationAngle(angle: disappearingVideoLayer.videoMetrics.rotationAngle, followsDeviceOrientation: disappearingVideoLayer.videoMetrics.followsDeviceOrientation, interfaceOrientation: params.interfaceOrientation), applyDragPosition: true) + let disappearingVideoLayout = self.calculateMinimizedLayout(params: params, videoMetrics: disappearingVideoLayer.videoMetrics, resolvedRotationAngle: resolveCallVideoRotationAngle(angle: disappearingVideoLayer.videoMetrics.rotationAngle, followsDeviceOrientation: disappearingVideoLayer.videoMetrics.followsDeviceOrientation, interfaceOrientation: params.interfaceOrientation), applyDragPosition: true) let initialDisappearingVideoSize = disappearingVideoLayout.effectiveVideoFrame.size if !disappearingVideoLayer.isAlphaAnimationInitiated { diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageAnimatedStickerItemNode/Sources/ChatMessageAnimatedStickerItemNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageAnimatedStickerItemNode/Sources/ChatMessageAnimatedStickerItemNode.swift index ab309b8b745..090041cb5ca 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageAnimatedStickerItemNode/Sources/ChatMessageAnimatedStickerItemNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageAnimatedStickerItemNode/Sources/ChatMessageAnimatedStickerItemNode.swift @@ -842,12 +842,11 @@ public class ChatMessageAnimatedStickerItemNode: ChatMessageItemView { case let .peer(peerId): if peerId != item.context.account.peerId { if peerId.isGroupOrChannel && item.message.author != nil { - var isBroadcastChannel = false - if let peer = item.message.peers[item.message.id.peerId] as? TelegramChannel, case .broadcast = peer.info { - isBroadcastChannel = true - } - - if !isBroadcastChannel { + if let peer = item.message.peers[item.message.id.peerId] as? TelegramChannel, case let .broadcast(info) = peer.info { + if info.flags.contains(.messagesShouldHaveProfiles) { + hasAvatar = incoming + } + } else { hasAvatar = true } } diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift index a604a7e798e..8be65d72247 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift @@ -234,6 +234,8 @@ private func contentNodeMessagesAndClassesForItem(_ item: ChatMessageItem) -> ([ result.append((message, ChatMessageWallpaperBubbleContentNode.self, itemAttributes, BubbleItemAttributes(isAttachment: false, neighborType: .text, neighborSpacing: .default))) } else if case .giftCode = action.action { result.append((message, ChatMessageGiftBubbleContentNode.self, itemAttributes, BubbleItemAttributes(isAttachment: false, neighborType: .text, neighborSpacing: .default))) + } else if case .prizeStars = action.action { + result.append((message, ChatMessageGiftBubbleContentNode.self, itemAttributes, BubbleItemAttributes(isAttachment: false, neighborType: .text, neighborSpacing: .default))) } else if case .joinedChannel = action.action { result.append((message, ChatMessageJoinedChannelBubbleContentNode.self, itemAttributes, BubbleItemAttributes(isAttachment: false, neighborType: .text, neighborSpacing: .default))) } else { @@ -1582,7 +1584,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } } - if let channel = firstMessage.peers[firstMessage.id.peerId] as? TelegramChannel, case let .broadcast(info) = channel.info, firstMessage.author?.id != channel.id { + if let channel = firstMessage.peers[firstMessage.id.peerId] as? TelegramChannel, case let .broadcast(info) = channel.info { if info.flags.contains(.messagesShouldHaveProfiles) { var allowAuthor = incoming overrideEffectiveAuthor = true diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageGiftBubbleContentNode/Sources/ChatMessageGiftBubbleContentNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageGiftBubbleContentNode/Sources/ChatMessageGiftBubbleContentNode.swift index 43f5fe28b21..69497821c0e 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageGiftBubbleContentNode/Sources/ChatMessageGiftBubbleContentNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageGiftBubbleContentNode/Sources/ChatMessageGiftBubbleContentNode.swift @@ -268,6 +268,20 @@ public class ChatMessageGiftBubbleContentNode: ChatMessageBubbleContentNode { } title = item.presentationData.strings.Notification_StarsGift_Title(Int32(count)) text = incoming ? item.presentationData.strings.Notification_StarsGift_Subtitle : item.presentationData.strings.Notification_StarsGift_SubtitleYou(peerName).string + case let .prizeStars(count, _, channelId, _, _): + if count <= 1000 { + months = 3 + } else if count < 2500 { + months = 6 + } else { + months = 12 + } + var peerName = "" + if let channelId, let channel = item.message.peers[channelId] { + peerName = EnginePeer(channel).compactDisplayTitle + } + title = item.presentationData.strings.Notification_StarsGiveaway_Title + text = item.presentationData.strings.Notification_StarsGiveaway_Subtitle(peerName, item.presentationData.strings.Notification_StarsGiveaway_Subtitle_Stars(Int32(count))).string case let .giftCode(_, fromGiveaway, unclaimed, channelId, monthsValue, _, _, _, _): if channelId == nil { months = monthsValue diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageGiveawayBubbleContentNode/BUILD b/submodules/TelegramUI/Components/Chat/ChatMessageGiveawayBubbleContentNode/BUILD index ad720ca79ed..10dfd5ea7ce 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageGiveawayBubbleContentNode/BUILD +++ b/submodules/TelegramUI/Components/Chat/ChatMessageGiveawayBubbleContentNode/BUILD @@ -29,6 +29,8 @@ swift_library( "//submodules/TelegramUI/Components/Chat/ChatMessageItemCommon", "//submodules/TelegramUI/Components/Chat/ChatMessageAttachedContentButtonNode", "//submodules/TelegramUI/Components/ChatControllerInteraction", + "//submodules/TelegramUI/Components/TextNodeWithEntities", + "//submodules/TextFormat", ], visibility = [ "//visibility:public", diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageGiveawayBubbleContentNode/Sources/ChatMessageGiveawayBubbleContentNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageGiveawayBubbleContentNode/Sources/ChatMessageGiveawayBubbleContentNode.swift index 20bbc99a732..e939dc18e6f 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageGiveawayBubbleContentNode/Sources/ChatMessageGiveawayBubbleContentNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageGiveawayBubbleContentNode/Sources/ChatMessageGiveawayBubbleContentNode.swift @@ -19,6 +19,8 @@ import ChatMessageBubbleContentNode import ChatMessageItemCommon import ChatMessageAttachedContentButtonNode import ChatControllerInteraction +import TextNodeWithEntities +import TextFormat private let titleFont = Font.medium(15.0) private let textFont = Font.regular(13.0) @@ -48,7 +50,7 @@ public class ChatMessageGiveawayBubbleContentNode: ChatMessageBubbleContentNode, private let dateTextNode: TextNode private let badgeBackgroundNode: ASImageNode - private let badgeTextNode: TextNode + private let badgeTextNode: TextNodeWithEntities private var giveaway: TelegramMediaGiveaway? @@ -104,7 +106,7 @@ public class ChatMessageGiveawayBubbleContentNode: ChatMessageBubbleContentNode, self.badgeBackgroundNode = ASImageNode() self.badgeBackgroundNode.displaysAsynchronously = false - self.badgeTextNode = TextNode() + self.badgeTextNode = TextNodeWithEntities() self.buttonNode = ChatMessageAttachedContentButtonNode() self.channelButtons = PeerButtonsStackNode() @@ -126,7 +128,7 @@ public class ChatMessageGiveawayBubbleContentNode: ChatMessageBubbleContentNode, self.addSubnode(self.channelButtons) self.addSubnode(self.animationNode) self.addSubnode(self.badgeBackgroundNode) - self.addSubnode(self.badgeTextNode) + self.addSubnode(self.badgeTextNode.textNode) self.buttonNode.addTarget(self, action: #selector(self.buttonPressed), forControlEvents: .touchUpInside) @@ -221,7 +223,7 @@ public class ChatMessageGiveawayBubbleContentNode: ChatMessageBubbleContentNode, let makeDateTitleLayout = TextNode.asyncLayout(self.dateTitleNode) let makeDateTextLayout = TextNode.asyncLayout(self.dateTextNode) - let makeBadgeTextLayout = TextNode.asyncLayout(self.badgeTextNode) + let makeBadgeTextLayout = TextNodeWithEntities.asyncLayout(self.badgeTextNode) let makeButtonLayout = ChatMessageAttachedContentButtonNode.asyncLayout(self.buttonNode) @@ -259,22 +261,40 @@ public class ChatMessageGiveawayBubbleContentNode: ChatMessageBubbleContentNode, if badgeTextColor.distance(to: accentColor) < 1 { badgeTextColor = incoming ? item.presentationData.theme.theme.chat.message.incoming.bubble.withoutWallpaper.fill.first! : item.presentationData.theme.theme.chat.message.outgoing.bubble.withoutWallpaper.fill.first! } - - var updatedBadgeImage: UIImage? - if themeUpdated { - updatedBadgeImage = generateStretchableFilledCircleImage(diameter: 21.0, color: accentColor, strokeColor: backgroundColor, strokeWidth: 1.0 + UIScreenPixel, backgroundColor: nil) - } - + + var isStars = false let badgeText: String if let giveaway { - badgeText = "X\(giveaway.quantity)" + switch giveaway.prize { + case .premium: + badgeText = "X\(giveaway.quantity)" + case let .stars(amount): + badgeText = "⭐️\(presentationStringsFormattedNumber(Int32(amount), item.presentationData.dateTimeFormat.groupingSeparator)) " + isStars = true + } } else if let giveawayResults { - badgeText = "X\(giveawayResults.winnersCount)" + switch giveawayResults.prize { + case .premium: + badgeText = "X\(giveawayResults.winnersCount)" + case let .stars(amount): + badgeText = "⭐️\(presentationStringsFormattedNumber(Int32(amount), item.presentationData.dateTimeFormat.groupingSeparator)) " + isStars = true + } } else { badgeText = "" } - let badgeString = NSAttributedString(string: badgeText, font: Font.with(size: 10.0, design: .round , weight: .bold, traits: .monospacedNumbers), textColor: badgeTextColor) - + let badgeString = NSMutableAttributedString(string: badgeText, font: Font.with(size: 10.0, design: .round , weight: .bold, traits: .monospacedNumbers), textColor: badgeTextColor) + if let range = badgeString.string.range(of: "⭐️") { + badgeString.addAttribute(.attachment, value: UIImage(bundleImageName: "Chat/Message/Stars")!, range: NSRange(range, in: badgeString.string)) + badgeString.addAttribute(.baselineOffset, value: 1.5, range: NSRange(range, in: badgeString.string)) + } + + let badgeBackgroundColor = !incoming || !isStars ? accentColor : UIColor(rgb: 0xffaf0a) + var updatedBadgeImage: UIImage? + if themeUpdated { + updatedBadgeImage = generateStretchableFilledCircleImage(diameter: 21.0, color: badgeBackgroundColor, strokeColor: backgroundColor, strokeWidth: 1.0 + UIScreenPixel, backgroundColor: nil) + } + let prizeTitleText: String if let giveawayResults { if giveawayResults.winnersCount > 1 { @@ -316,17 +336,33 @@ public class ChatMessageGiveawayBubbleContentNode: ChatMessageBubbleContentNode, subscriptionsString = subscriptionsString.replacingOccurrences(of: "**\(giveaway.quantity)** ", with: "") } - prizeTextString = parseMarkdownIntoAttributedString(item.presentationData.strings.Chat_Giveaway_Message_PrizeText( - subscriptionsString, - item.presentationData.strings.Chat_Giveaway_Message_Months(giveaway.months) - ).string, attributes: MarkdownAttributes( - body: MarkdownAttributeSet(font: textFont, textColor: textColor), - bold: MarkdownAttributeSet(font: boldTextFont, textColor: textColor), - link: MarkdownAttributeSet(font: textFont, textColor: textColor), - linkAttribute: { url in - return ("URL", url) - } - ), textAlignment: .center) + switch giveaway.prize { + case let .premium(months): + prizeTextString = parseMarkdownIntoAttributedString(item.presentationData.strings.Chat_Giveaway_Message_PrizeText( + subscriptionsString, + item.presentationData.strings.Chat_Giveaway_Message_Months(months) + ).string, attributes: MarkdownAttributes( + body: MarkdownAttributeSet(font: textFont, textColor: textColor), + bold: MarkdownAttributeSet(font: boldTextFont, textColor: textColor), + link: MarkdownAttributeSet(font: textFont, textColor: textColor), + linkAttribute: { url in + return ("URL", url) + } + ), textAlignment: .center) + case let .stars(amount): + let starsString = item.presentationData.strings.Chat_Giveaway_Message_Stars_Stars(Int32(amount)) + prizeTextString = parseMarkdownIntoAttributedString(item.presentationData.strings.Chat_Giveaway_Message_Stars_PrizeText( + starsString, + item.presentationData.strings.Chat_Giveaway_Message_Stars_Winners(giveaway.quantity) + ).string, attributes: MarkdownAttributes( + body: MarkdownAttributeSet(font: textFont, textColor: textColor), + bold: MarkdownAttributeSet(font: boldTextFont, textColor: textColor), + link: MarkdownAttributeSet(font: textFont, textColor: textColor), + linkAttribute: { url in + return ("URL", url) + } + ), textAlignment: .center) + } } else if let giveawayResults { prizeTextString = parseMarkdownIntoAttributedString(item.presentationData.strings.Chat_Giveaway_Message_WinnersSelectedText(giveawayResults.winnersCount), attributes: MarkdownAttributes( body: MarkdownAttributeSet(font: textFont, textColor: textColor), @@ -431,7 +467,19 @@ public class ChatMessageGiveawayBubbleContentNode: ChatMessageBubbleContentNode, if let giveaway { dateTextString = NSAttributedString(string: stringForFullDate(timestamp: giveaway.untilDate, strings: item.presentationData.strings, dateTimeFormat: item.presentationData.dateTimeFormat), font: textFont, textColor: textColor) } else if let giveawayResults { - dateTextString = NSAttributedString(string: giveawayResults.winnersCount > 1 ? item.presentationData.strings.Chat_Giveaway_Message_WinnersInfo_Many : item.presentationData.strings.Chat_Giveaway_Message_WinnersInfo_One, font: textFont, textColor: textColor) + if case let .stars(stars) = giveawayResults.prize { + let starsString = item.presentationData.strings.Chat_Giveaway_Message_WinnersInfo_Stars(Int32(stars)) + dateTextString = parseMarkdownIntoAttributedString(giveawayResults.winnersCount > 1 ? item.presentationData.strings.Chat_Giveaway_Message_WinnersInfo_Stars_Many(starsString).string : item.presentationData.strings.Chat_Giveaway_Message_WinnersInfo_Stars_One(starsString).string, attributes: MarkdownAttributes( + body: MarkdownAttributeSet(font: textFont, textColor: textColor), + bold: MarkdownAttributeSet(font: boldTextFont, textColor: textColor), + link: MarkdownAttributeSet(font: textFont, textColor: accentColor), + linkAttribute: { url in + return ("URL", url) + } + ), textAlignment: .center) + } else { + dateTextString = NSAttributedString(string: giveawayResults.winnersCount > 1 ? item.presentationData.strings.Chat_Giveaway_Message_WinnersInfo_Many : item.presentationData.strings.Chat_Giveaway_Message_WinnersInfo_One, font: textFont, textColor: textColor) + } } let hideHeaders = item.message.forwardInfo == nil let contentProperties = ChatMessageBubbleContentProperties(hidesSimpleAuthorHeader: hideHeaders, headerSpacing: 0.0, hidesBackground: .never, forceFullCorners: false, forceAlignment: .none, hidesHeaders: hideHeaders) @@ -548,7 +596,21 @@ public class ChatMessageGiveawayBubbleContentNode: ChatMessageBubbleContentNode, let (buttonWidth, continueLayout) = makeButtonLayout(constrainedSize.width, nil, nil, false, item.presentationData.strings.Chat_Giveaway_Message_LearnMore.uppercased(), titleColor, false, true) let animationName: String - let months = giveaway?.months ?? 0 + var months: Int32 = 0 + if let giveaway { + switch giveaway.prize { + case let .premium(monthsValue): + months = monthsValue + case let .stars(amount): + if amount <= 1000 { + months = 3 + } else if amount < 2500 { + months = 6 + } else { + months = 12 + } + } + } if let _ = giveaway { switch months { case 12: @@ -647,7 +709,7 @@ public class ChatMessageGiveawayBubbleContentNode: ChatMessageBubbleContentNode, strongSelf.giveaway = giveaway let displaysAsynchronously = !item.presentationData.isPreview - strongSelf.badgeTextNode.displaysAsynchronously = displaysAsynchronously + strongSelf.badgeTextNode.textNode.displaysAsynchronously = displaysAsynchronously strongSelf.prizeTitleNode.displaysAsynchronously = displaysAsynchronously strongSelf.prizeTextNode.displaysAsynchronously = displaysAsynchronously strongSelf.additionalPrizeTextNode.displaysAsynchronously = displaysAsynchronously @@ -662,7 +724,7 @@ public class ChatMessageGiveawayBubbleContentNode: ChatMessageBubbleContentNode, strongSelf.updateVisibility() - let _ = badgeTextApply() + let _ = badgeTextApply(TextNodeWithEntities.Arguments(context: item.context, cache: item.context.animationCache, renderer: item.context.animationRenderer, placeholderColor: .clear, attemptSynchronous: true)) let _ = prizeTitleApply() let _ = prizeTextApply() let _ = additionalPrizeSeparatorApply() @@ -685,7 +747,7 @@ public class ChatMessageGiveawayBubbleContentNode: ChatMessageBubbleContentNode, strongSelf.animationNode.updateLayout(size: iconSize) let badgeTextFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((layoutSize.width - badgeTextLayout.size.width) / 2.0) + 1.0, y: originY + 88.0), size: badgeTextLayout.size) - strongSelf.badgeTextNode.frame = badgeTextFrame + strongSelf.badgeTextNode.textNode.frame = badgeTextFrame strongSelf.badgeBackgroundNode.frame = badgeTextFrame.insetBy(dx: -6.0, dy: -5.0).offsetBy(dx: -1.0, dy: 0.0) if let updatedBadgeImage { strongSelf.badgeBackgroundNode.image = updatedBadgeImage diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageItemImpl/Sources/ChatMessageDateHeader.swift b/submodules/TelegramUI/Components/Chat/ChatMessageItemImpl/Sources/ChatMessageDateHeader.swift index 4ba983ce0b0..5ec79904943 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageItemImpl/Sources/ChatMessageDateHeader.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageItemImpl/Sources/ChatMessageDateHeader.swift @@ -551,7 +551,7 @@ public final class ChatMessageAvatarHeaderNodeImpl: ListViewItemHeaderNode, Chat } public func setPeer(context: AccountContext, theme: PresentationTheme, synchronousLoad: Bool, peer: Peer, authorOfMessage: MessageReference?, emptyColor: UIColor) { - self.containerNode.isGestureEnabled = peer.smallProfileImage != nil + self.containerNode.isGestureEnabled = true var overrideImage: AvatarNodeImageOverride? if peer.isDeleted { diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageItemImpl/Sources/ChatMessageItemImpl.swift b/submodules/TelegramUI/Components/Chat/ChatMessageItemImpl/Sources/ChatMessageItemImpl.swift index 7d92e117b03..0aeb544c623 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageItemImpl/Sources/ChatMessageItemImpl.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageItemImpl/Sources/ChatMessageItemImpl.swift @@ -75,6 +75,13 @@ private func messagesShouldBeMerged(accountPeerId: PeerId, _ lhs: Message, _ rhs } } + if let channel = lhs.peers[lhs.id.peerId] as? TelegramChannel, case let .broadcast(info) = channel.info { + if info.flags.contains(.messagesShouldHaveProfiles) { + lhsEffectiveAuthor = lhs.author + rhsEffectiveAuthor = rhs.author + } + } + var sameChat = true if lhs.id.peerId != rhs.id.peerId { sameChat = false @@ -351,7 +358,7 @@ public final class ChatMessageItemImpl: ChatMessageItem, CustomStringConvertible if !hasActionMedia { if !isBroadcastChannel { hasAvatar = true - } else if let channel = message.peers[message.id.peerId] as? TelegramChannel, case let .broadcast(info) = channel.info, message.author?.id != channel.id { + } else if let channel = message.peers[message.id.peerId] as? TelegramChannel, case let .broadcast(info) = channel.info { if info.flags.contains(.messagesShouldHaveProfiles) { hasAvatar = true effectiveAuthor = message.author diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageSelectionInputPanelNode/Sources/ChatMessageSelectionInputPanelNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageSelectionInputPanelNode/Sources/ChatMessageSelectionInputPanelNode.swift index ec3f7e17c19..dc4441a3f72 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageSelectionInputPanelNode/Sources/ChatMessageSelectionInputPanelNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageSelectionInputPanelNode/Sources/ChatMessageSelectionInputPanelNode.swift @@ -413,10 +413,14 @@ public final class ChatMessageSelectionInputPanelNode: ChatInputPanelNode { } if let selectionState = presentationInterfaceState.interfaceState.selectionState { context.engine.messages.setMessageReactions(ids: Array(selectionState.selectedIds), reactions: mappedUpdatedReactions) + } else { + context.engine.messages.setMessageReactions(ids: Array(self.selectedMessages), reactions: mappedUpdatedReactions) } } else { if let selectionState = presentationInterfaceState.interfaceState.selectionState { context.engine.messages.addMessageReactions(ids: Array(selectionState.selectedIds), reactions: [updateReaction]) + } else { + context.engine.messages.addMessageReactions(ids: Array(self.selectedMessages), reactions: [updateReaction]) } } diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageStickerItemNode/Sources/ChatMessageStickerItemNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageStickerItemNode/Sources/ChatMessageStickerItemNode.swift index cdcaf4d6007..5a77f740bd1 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageStickerItemNode/Sources/ChatMessageStickerItemNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageStickerItemNode/Sources/ChatMessageStickerItemNode.swift @@ -464,14 +464,15 @@ public class ChatMessageStickerItemNode: ChatMessageItemView { case let .peer(peerId): if !peerId.isRepliesOrSavedMessages(accountPeerId: item.context.account.peerId) { if peerId.isGroupOrChannel && item.message.author != nil { - var isBroadcastChannel = false - if let peer = item.message.peers[item.message.id.peerId] as? TelegramChannel, case .broadcast = peer.info { - isBroadcastChannel = true + if let peer = item.message.peers[item.message.id.peerId] as? TelegramChannel, case let .broadcast(info) = peer.info { + if info.flags.contains(.messagesShouldHaveProfiles) { + hasAvatar = incoming + } + } else { + hasAvatar = true } - if !isBroadcastChannel { - hasAvatar = true - } else if case .customChatContents = item.chatLocation { + if case .customChatContents = item.chatLocation { hasAvatar = false } } diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageTextBubbleContentNode/Sources/ChatMessageTextBubbleContentNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageTextBubbleContentNode/Sources/ChatMessageTextBubbleContentNode.swift index 2be9d0c8c51..d41a6440f99 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageTextBubbleContentNode/Sources/ChatMessageTextBubbleContentNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageTextBubbleContentNode/Sources/ChatMessageTextBubbleContentNode.swift @@ -628,7 +628,7 @@ public class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { } let dateLayoutInput: ChatMessageDateAndStatusNode.LayoutInput - dateLayoutInput = .trailingContent(contentWidth: trailingWidthToMeasure, reactionSettings: item.presentationData.isPreview ? nil : ChatMessageDateAndStatusNode.TrailingReactionSettings(displayInline: shouldDisplayInlineDateReactions(message: item.message, isPremium: item.associatedData.isPremium, forceInline: item.associatedData.forceInlineReactions), preferAdditionalInset: false)) + dateLayoutInput = .trailingContent(contentWidth: trailingWidthToMeasure, reactionSettings: ChatMessageDateAndStatusNode.TrailingReactionSettings(displayInline: shouldDisplayInlineDateReactions(message: item.message, isPremium: item.associatedData.isPremium, forceInline: item.associatedData.forceInlineReactions), preferAdditionalInset: false)) statusSuggestedWidthAndContinue = statusLayout(ChatMessageDateAndStatusNode.Arguments( context: item.context, @@ -641,7 +641,7 @@ public class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { constrainedSize: textConstrainedSize, availableReactions: item.associatedData.availableReactions, savedMessageTags: item.associatedData.savedMessageTags, - reactions: dateReactionsAndPeers.reactions, + reactions: item.presentationData.isPreview ? [] : dateReactionsAndPeers.reactions, reactionPeers: dateReactionsAndPeers.peers, displayAllReactionPeers: item.message.id.peerId.namespace == Namespaces.Peer.CloudUser, areReactionsTags: item.topMessage.areReactionsTags(accountPeerId: item.context.account.peerId), @@ -995,7 +995,7 @@ public class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { return ChatMessageBubbleContentTapAction(content: .none) } else { if let text = self.textNode.textNode.attributeSubstring(name: "Attribute__Blockquote", index: index) { - return ChatMessageBubbleContentTapAction(content: .copy(text.1)) + return ChatMessageBubbleContentTapAction(content: .copy(text.0)) } else { return ChatMessageBubbleContentTapAction(content: .none) } @@ -1514,9 +1514,10 @@ public class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { guard let item = self.item else { return nil } - guard let string = self.textNode.textNode.cachedLayout?.attributedString else { + guard let string = self.textNode.attributedString else { return nil } + let nsString = string.string as NSString let substring = nsString.substring(with: range) let offset = range.location diff --git a/submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsControllerNode.swift b/submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsControllerNode.swift index 335f76f486a..6c39fe96db9 100644 --- a/submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsControllerNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsControllerNode.swift @@ -188,27 +188,29 @@ final class ChatRecentActionsControllerNode: ViewControllerTracingNode { return true } case let .editExportedInvitation(_, invite), let .revokeExportedInvitation(invite), let .deleteExportedInvitation(invite), let .participantJoinedViaInvite(invite, _), let .participantJoinByRequest(invite, _): - if let inviteLink = invite.link, !inviteLink.hasSuffix("...") { + if let inviteLink = invite.link { if invite.isPermanent { - let actionSheet = ActionSheetController(presentationData: strongSelf.presentationData) - - var items: [ActionSheetItem] = [] - items.append(ActionSheetTextItem(title: inviteLink)) - items.append(ActionSheetButtonItem(title: strongSelf.presentationData.strings.InviteLink_ContextRevoke, color: .destructive, action: { [weak actionSheet] in - actionSheet?.dismissAnimated() - if let strongSelf = self { - let _ = (strongSelf.context.engine.peers.revokePeerExportedInvitation(peerId: peer.id, link: inviteLink) - |> deliverOnMainQueue).startStandalone(completed: { [weak self] in - self?.eventLogContext.reload() - }) - } - })) - actionSheet.setItemGroups([ActionSheetItemGroup(items: items), ActionSheetItemGroup(items: [ - ActionSheetButtonItem(title: strongSelf.presentationData.strings.Common_Cancel, color: .accent, font: .bold, action: { [weak actionSheet] in + if !inviteLink.hasSuffix("...") { + let actionSheet = ActionSheetController(presentationData: strongSelf.presentationData) + + var items: [ActionSheetItem] = [] + items.append(ActionSheetTextItem(title: inviteLink)) + items.append(ActionSheetButtonItem(title: strongSelf.presentationData.strings.InviteLink_ContextRevoke, color: .destructive, action: { [weak actionSheet] in actionSheet?.dismissAnimated() - }) - ])]) - strongSelf.presentController(actionSheet, .window(.root), nil) + if let strongSelf = self { + let _ = (strongSelf.context.engine.peers.revokePeerExportedInvitation(peerId: peer.id, link: inviteLink) + |> deliverOnMainQueue).startStandalone(completed: { [weak self] in + self?.eventLogContext.reload() + }) + } + })) + actionSheet.setItemGroups([ActionSheetItemGroup(items: items), ActionSheetItemGroup(items: [ + ActionSheetButtonItem(title: strongSelf.presentationData.strings.Common_Cancel, color: .accent, font: .bold, action: { [weak actionSheet] in + actionSheet?.dismissAnimated() + }) + ])]) + strongSelf.presentController(actionSheet, .window(.root), nil) + } } else { let controller = InviteLinkViewController( context: strongSelf.context, @@ -322,7 +324,7 @@ final class ChatRecentActionsControllerNode: ViewControllerTracingNode { self.expandedDeletedMessages.insert(messageId) } }, requestMessageActionUrlAuth: { _, _ in }, activateSwitchInline: { _, _, _ in }, openUrl: { [weak self] url in - self?.openUrl(url.url) + self?.openUrl(url.url, progress: url.progress) }, shareCurrentLocation: {}, shareAccountContact: {}, sendBotCommand: { _, _ in }, openInstantPage: { [weak self] message, associatedData in if let strongSelf = self, let navigationController = strongSelf.getNavigationController() { if let controller = strongSelf.context.sharedContext.makeInstantPageController(context: strongSelf.context, message: message, sourcePeerType: associatedData?.automaticDownloadPeerType) { @@ -628,6 +630,7 @@ final class ChatRecentActionsControllerNode: ViewControllerTracingNode { }, playMessageEffect: { _ in }, editMessageFactCheck: { _ in }, requestMessageUpdate: { _, _ in + }, cancelInteractiveKeyboardGestures: { }, dismissTextInput: { }, scrollToMessageId: { _ in @@ -1159,7 +1162,7 @@ final class ChatRecentActionsControllerNode: ViewControllerTracingNode { } } - private func openUrl(_ url: String) { + private func openUrl(_ url: String, progress: Promise? = nil) { self.navigationActionDisposable.set((self.context.sharedContext.resolveUrl(context: self.context, peerId: nil, url: url, skipUrlAuth: true) |> deliverOnMainQueue).startStrict(next: { [weak self] result in if let strongSelf = self { switch result { @@ -1235,11 +1238,104 @@ final class ChatRecentActionsControllerNode: ViewControllerTracingNode { let browserController = strongSelf.context.sharedContext.makeInstantPageController(context: strongSelf.context, webPage: webPage, anchor: anchor, sourceLocation: InstantPageSourceLocation(userLocation: .peer(strongSelf.peer.id), peerType: .channel)) strongSelf.pushController(browserController) case let .join(link): - strongSelf.presentController(JoinLinkPreviewController(context: strongSelf.context, link: link, navigateToPeer: { peer, peekData in - if let strongSelf = self { - strongSelf.openPeer(peer: peer, peekData: peekData) + let context = strongSelf.context + let navigationController = strongSelf.getNavigationController() + let openPeer: (EnginePeer, ChatPeekTimeout?) -> Void = { [weak self] peer, peekData in + self?.openPeer(peer: peer, peekData: peekData) + } + + if let progress { + let progressSignal = Signal { subscriber in + progress.set(.single(true)) + return ActionDisposable { + Queue.mainQueue().async() { + progress.set(.single(false)) + } + } + } + |> runOn(Queue.mainQueue()) + |> delay(0.1, queue: Queue.mainQueue()) + let progressDisposable = progressSignal.startStrict() + + var signal = context.engine.peers.joinLinkInformation(link) + signal = signal + |> afterDisposed { + Queue.mainQueue().async { + progressDisposable.dispose() + } } - }, parentNavigationController: strongSelf.getNavigationController()), .window(.root), nil) + + let _ = (signal + |> deliverOnMainQueue).startStandalone(next: { [weak navigationController] resolvedState in + switch resolvedState { + case let .alreadyJoined(peer): + openPeer(peer, nil) + case let .peek(peer, deadline): + openPeer(peer, ChatPeekTimeout(deadline: deadline, linkData: link)) + case let .invite(invite): + if let subscriptionPricing = invite.subscriptionPricing, let subscriptionFormId = invite.subscriptionFormId, let starsContext = context.starsContext { + let inputData = Promise() + var photo: [TelegramMediaImageRepresentation] = [] + if let photoRepresentation = invite.photoRepresentation { + photo.append(photoRepresentation) + } + let channel = TelegramChannel(id: PeerId(namespace: Namespaces.Peer.CloudChannel, id: PeerId.Id._internalFromInt64Value(0)), accessHash: .genericPublic(0), title: invite.title, username: nil, photo: photo, creationDate: 0, version: 0, participationStatus: .left, info: .broadcast(TelegramChannelBroadcastInfo(flags: [])), flags: [], restrictionInfo: nil, adminRights: nil, bannedRights: nil, defaultBannedRights: nil, usernames: [], storiesHidden: nil, nameColor: invite.nameColor, backgroundEmojiId: nil, profileColor: nil, profileBackgroundEmojiId: nil, emojiStatus: nil, approximateBoostLevel: nil, subscriptionUntilDate: nil) + let invoice = TelegramMediaInvoice(title: "", description: "", photo: nil, receiptMessageId: nil, currency: "XTR", totalAmount: subscriptionPricing.amount, startParam: "", extendedMedia: nil, flags: [], version: 0) + + inputData.set(.single(BotCheckoutController.InputData( + form: BotPaymentForm( + id: subscriptionFormId, + canSaveCredentials: false, + passwordMissing: false, + invoice: BotPaymentInvoice(isTest: false, requestedFields: [], currency: "XTR", prices: [BotPaymentPrice(label: "", amount: subscriptionPricing.amount)], tip: nil, termsInfo: nil), + paymentBotId: channel.id, + providerId: nil, + url: nil, + nativeProvider: nil, + savedInfo: nil, + savedCredentials: [], + additionalPaymentMethods: [] + ), + validatedFormInfo: nil, + botPeer: EnginePeer(channel) + ))) + + let starsInputData = combineLatest( + inputData.get(), + starsContext.state + ) + |> map { data, state -> (StarsContext.State, BotPaymentForm, EnginePeer?, EnginePeer?)? in + if let data, let state { + return (state, data.form, data.botPeer, nil) + } else { + return nil + } + } + let _ = (starsInputData + |> SwiftSignalKit.filter { $0 != nil } + |> take(1) + |> deliverOnMainQueue).start(next: { _ in + let controller = context.sharedContext.makeStarsSubscriptionTransferScreen(context: context, starsContext: starsContext, invoice: invoice, link: link, inputData: starsInputData, navigateToPeer: { peer in + openPeer(peer, nil) + }) + navigationController?.pushViewController(controller) + }) + } else { + strongSelf.presentController(JoinLinkPreviewController(context: context, link: link, navigateToPeer: { peer, peekData in + openPeer(peer, peekData) + }, parentNavigationController: navigationController, resolvedState: resolvedState), .window(.root), nil) + } + default: + strongSelf.presentController(JoinLinkPreviewController(context: context, link: link, navigateToPeer: { peer, peekData in + openPeer(peer, peekData) + }, parentNavigationController: navigationController, resolvedState: resolvedState), .window(.root), nil) + } + }) + } else { + strongSelf.presentController(JoinLinkPreviewController(context: context, link: link, navigateToPeer: { peer, peekData in + openPeer(peer, peekData) + }, parentNavigationController: navigationController), .window(.root), nil) + } case let .localization(identifier): strongSelf.presentController(LanguageLinkPreviewController(context: strongSelf.context, identifier: identifier), .window(.root), nil) case .proxy, .confirmationCode, .cancelAccountReset, .share: diff --git a/submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsHistoryTransition.swift b/submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsHistoryTransition.swift index ea5afffba04..df6606d88ac 100644 --- a/submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsHistoryTransition.swift +++ b/submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsHistoryTransition.swift @@ -2254,6 +2254,32 @@ struct ChatRecentActionsEntry: Comparable, Identifiable { } let action = TelegramMediaActionType.customText(text: text, entities: entities, additionalAttributes: nil) + let message = Message(stableId: self.entry.stableId, stableVersion: 0, id: MessageId(peerId: peer.id, namespace: Namespaces.Message.Cloud, id: Int32(bitPattern: self.entry.stableId)), globallyUniqueId: self.entry.event.id, groupingKey: nil, groupInfo: nil, threadId: nil, timestamp: self.entry.event.date, flags: [.Incoming], tags: [], globalTags: [], localTags: [], customTags: [], forwardInfo: nil, author: author, text: "", attributes: [], media: [TelegramMediaAction(action: action)], peers: peers, associatedMessages: SimpleDictionary(), associatedMessageIds: [], associatedMedia: [:], associatedThreadInfo: nil, associatedStories: [:]) + return ChatMessageItemImpl(presentationData: self.presentationData, context: context, chatLocation: .peer(id: peer.id), associatedData: ChatMessageItemAssociatedData(automaticDownloadPeerType: .channel, automaticDownloadPeerId: nil, automaticDownloadNetworkType: .cellular, isRecentActions: true, availableReactions: availableReactions, availableMessageEffects: availableMessageEffects, savedMessageTags: nil, defaultReaction: nil, isPremium: false, accountPeer: nil), controllerInteraction: controllerInteraction, content: .message(message: message, read: true, selection: .none, attributes: ChatMessageEntryAttributes(), location: nil)) + case let .participantSubscriptionExtended(_, new): + var peers = SimpleDictionary() + var author: Peer? + if let peer = self.entry.peers[self.entry.event.peerId] { + author = peer + peers[peer.id] = peer + } + peers[peer.id] = peer + for (_, peer) in new.peers { + peers[peer.id] = peer + } + peers[new.peer.id] = new.peer + + var text: String = "" + var entities: [MessageTextEntity] = [] + appendAttributedText(text: self.presentationData.strings.Channel_AdminLog_MessageParticipantSubscriptionExtended(author.flatMap(EnginePeer.init)?.displayTitle(strings: self.presentationData.strings, displayOrder: self.presentationData.nameDisplayOrder) ?? ""), generateEntities: { index in + if index == 0, let author = author { + return [.TextMention(peerId: author.id)] + } + return [] + }, to: &text, entities: &entities) + + let action = TelegramMediaActionType.customText(text: text, entities: entities, additionalAttributes: nil) + let message = Message(stableId: self.entry.stableId, stableVersion: 0, id: MessageId(peerId: peer.id, namespace: Namespaces.Message.Cloud, id: Int32(bitPattern: self.entry.stableId)), globallyUniqueId: self.entry.event.id, groupingKey: nil, groupInfo: nil, threadId: nil, timestamp: self.entry.event.date, flags: [.Incoming], tags: [], globalTags: [], localTags: [], customTags: [], forwardInfo: nil, author: author, text: "", attributes: [], media: [TelegramMediaAction(action: action)], peers: peers, associatedMessages: SimpleDictionary(), associatedMessageIds: [], associatedMedia: [:], associatedThreadInfo: nil, associatedStories: [:]) return ChatMessageItemImpl(presentationData: self.presentationData, context: context, chatLocation: .peer(id: peer.id), associatedData: ChatMessageItemAssociatedData(automaticDownloadPeerType: .channel, automaticDownloadPeerId: nil, automaticDownloadNetworkType: .cellular, isRecentActions: true, availableReactions: availableReactions, availableMessageEffects: availableMessageEffects, savedMessageTags: nil, defaultReaction: nil, isPremium: false, accountPeer: nil), controllerInteraction: controllerInteraction, content: .message(message: message, read: true, selection: .none, attributes: ChatMessageEntryAttributes(), location: nil)) } diff --git a/submodules/TelegramUI/Components/Chat/ChatSendStarsScreen/Sources/ChatSendStarsScreen.swift b/submodules/TelegramUI/Components/Chat/ChatSendStarsScreen/Sources/ChatSendStarsScreen.swift index 2085326af06..a7b50740e53 100644 --- a/submodules/TelegramUI/Components/Chat/ChatSendStarsScreen/Sources/ChatSendStarsScreen.swift +++ b/submodules/TelegramUI/Components/Chat/ChatSendStarsScreen/Sources/ChatSendStarsScreen.swift @@ -128,22 +128,15 @@ private final class BalanceComponent: CombinedComponent { } private final class BadgeComponent: Component { - enum Direction { - case left - case right - } let theme: PresentationTheme let title: String - let inertiaDirection: Direction? init( theme: PresentationTheme, - title: String, - inertiaDirection: Direction? + title: String ) { self.theme = theme self.title = title - self.inertiaDirection = inertiaDirection } static func ==(lhs: BadgeComponent, rhs: BadgeComponent) -> Bool { @@ -153,9 +146,6 @@ private final class BadgeComponent: Component { if lhs.title != rhs.title { return false } - if lhs.inertiaDirection != rhs.inertiaDirection { - return false - } return true } @@ -175,7 +165,6 @@ private final class BadgeComponent: Component { private var component: BadgeComponent? private var previousAvailableSize: CGSize? - private var previousInertiaDirection: BadgeComponent.Direction? override init(frame: CGRect) { self.badgeView = UIView() @@ -189,6 +178,7 @@ private final class BadgeComponent: Component { self.badgeView.mask = self.badgeMaskView self.badgeForeground = SimpleLayer() + self.badgeForeground.anchorPoint = CGPoint() self.badgeIcon = UIImageView() self.badgeIcon.contentMode = .center @@ -227,6 +217,14 @@ private final class BadgeComponent: Component { required init(coder: NSCoder) { preconditionFailure() } + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + if self.badgeView.frame.contains(point) { + return self + } else { + return nil + } + } func update(component: BadgeComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { if self.component == nil { @@ -249,31 +247,7 @@ private final class BadgeComponent: Component { self.badgeView.bounds = CGRect(origin: .zero, size: badgeFullSize) - transition.setAnchorPoint(layer: self.badgeView.layer, anchorPoint: CGPoint(x: 0.5, y: 1.0)) - - if component.inertiaDirection != self.previousInertiaDirection { - self.previousInertiaDirection = component.inertiaDirection - - var angle: CGFloat = 0.0 - let transition: ContainedViewLayoutTransition - if let inertiaDirection = component.inertiaDirection { - switch inertiaDirection { - case .left: - angle = 0.22 - case .right: - angle = -0.22 - } - transition = .animated(duration: 0.45, curve: .spring) - } else { - transition = .animated(duration: 0.45, curve: .customSpring(damping: 65.0, initialVelocity: 0.0)) - } - transition.updateTransformRotation(view: self.badgeView, angle: angle) - } - - self.badgeForeground.bounds = CGRect(origin: CGPoint(), size: CGSize(width: badgeFullSize.width * 3.0, height: badgeFullSize.height)) - if self.badgeForeground.animation(forKey: "movement") == nil { - self.badgeForeground.position = CGPoint(x: badgeSize.width * 3.0 / 2.0 - self.badgeForeground.frame.width * 0.35, y: badgeFullSize.height / 2.0) - } + self.badgeForeground.bounds = CGRect(origin: CGPoint(), size: CGSize(width: 600.0, height: badgeFullSize.height + 10.0)) self.badgeIcon.frame = CGRect(x: 10.0, y: 9.0, width: 30.0, height: 30.0) self.badgeLabelMaskView.frame = CGRect(x: 0.0, y: 0.0, width: 100.0, height: 36.0) @@ -312,7 +286,17 @@ private final class BadgeComponent: Component { tailPosition += overflowWidth tailPosition = max(0.0, min(size.width, tailPosition)) - self.badgeShapeLayer.path = generateRoundedRectWithTailPath(rectSize: size, tailPosition: tailPosition / size.width).cgPath + let tailPositionFraction = tailPosition / size.width + self.badgeShapeLayer.path = generateRoundedRectWithTailPath(rectSize: size, tailPosition: tailPositionFraction).cgPath + + let transition: ContainedViewLayoutTransition = .immediate + transition.updateAnchorPoint(layer: self.badgeView.layer, anchorPoint: CGPoint(x: tailPositionFraction, y: 1.0)) + transition.updatePosition(layer: self.badgeView.layer, position: CGPoint(x: (tailPositionFraction - 0.5) * size.width, y: 0.0)) + } + + func updateBadgeAngle(angle: CGFloat) { + let transition: ContainedViewLayoutTransition = .immediate + transition.updateTransformRotation(view: self.badgeView, angle: angle) } private func setupGradientAnimations() { @@ -323,13 +307,14 @@ private final class BadgeComponent: Component { } else { CATransaction.begin() - let badgeOffset = (self.badgeForeground.frame.width - self.badgeView.bounds.width) / 2.0 let badgePreviousValue = self.badgeForeground.position.x - var badgeNewValue: CGFloat = badgeOffset - if badgeOffset - badgePreviousValue < self.badgeForeground.frame.width * 0.25 { - badgeNewValue -= self.badgeForeground.frame.width * 0.35 + let badgeNewValue: CGFloat + if self.badgeForeground.position.x == -300.0 { + badgeNewValue = 0.0 + } else { + badgeNewValue = -300.0 } - self.badgeForeground.position = CGPoint(x: badgeNewValue, y: self.badgeForeground.bounds.size.height / 2.0) + self.badgeForeground.position = CGPoint(x: badgeNewValue, y: 0.0) let badgeAnimation = CABasicAnimation(keyPath: "position.x") badgeAnimation.duration = 4.5 @@ -471,14 +456,14 @@ private final class PeerComponent: Component { let theme: PresentationTheme let strings: PresentationStrings let peer: EnginePeer? - let count: Int + let count: String init( context: AccountContext, theme: PresentationTheme, strings: PresentationStrings, peer: EnginePeer?, - count: Int + count: String ) { self.context = context self.theme = theme @@ -547,7 +532,7 @@ private final class PeerComponent: Component { transition: .immediate, component: AnyComponent(PeerBadgeComponent( theme: component.theme, - title: "\(component.count)" + title: component.count )), environment: {}, containerSize: CGSize(width: 200.0, height: 200.0) @@ -806,7 +791,7 @@ private final class SliderBackgroundComponent: Component { topBackgroundTextView.layer.animateSpring(from: NSValue(cgPoint: CGPoint(x: animateTopTextAdditionalX, y: 0.0)), to: NSValue(cgPoint: CGPoint()), keyPath: "position", duration: 0.3, damping: 100.0, additive: true) } - topForegroundTextView.isHidden = component.topCutoff == nil + topForegroundTextView.isHidden = component.topCutoff == nil || topLineFrame.maxX + topTextSize.width + 20.0 > availableSize.width topBackgroundTextView.isHidden = topForegroundTextView.isHidden self.topBackgroundLine.isHidden = topX < 10.0 self.topForegroundLine.isHidden = self.topBackgroundLine.isHidden @@ -915,6 +900,83 @@ private final class ChatSendStarsScreenComponent: Component { } } + private struct Amount: Equatable { + private let sliderSteps: [Int] + private let maxRealValue: Int + let maxSliderValue: Int + private let isLogarithmic: Bool + + private(set) var realValue: Int + private(set) var sliderValue: Int + + private static func makeSliderSteps(maxRealValue: Int, isLogarithmic: Bool) -> [Int] { + if isLogarithmic { + var sliderSteps: [Int] = [ 1, 10, 50, 100, 500, 1_000, 2_000, 5_000, 7_500, 10_000 ] + sliderSteps.removeAll(where: { $0 >= maxRealValue }) + sliderSteps.append(maxRealValue) + return sliderSteps + } else { + return [1, maxRealValue] + } + } + + private static func remapValueToSlider(realValue: Int, maxSliderValue: Int, steps: [Int]) -> Int { + guard realValue >= steps.first!, realValue <= steps.last! else { return 0 } + + for i in 0 ..< steps.count - 1 { + if realValue >= steps[i] && realValue <= steps[i + 1] { + let range = steps[i + 1] - steps[i] + let relativeValue = realValue - steps[i] + let stepFraction = Float(relativeValue) / Float(range) + return Int(Float(i) * Float(maxSliderValue) / Float(steps.count - 1)) + Int(stepFraction * Float(maxSliderValue) / Float(steps.count - 1)) + } + } + return maxSliderValue // Return max slider position if value equals the last step + } + + private static func remapSliderToValue(sliderValue: Int, maxSliderValue: Int, steps: [Int]) -> Int { + guard sliderValue >= 0, sliderValue <= maxSliderValue else { return steps.first! } + + let stepIndex = Int(Float(sliderValue) / Float(maxSliderValue) * Float(steps.count - 1)) + let fraction = Float(sliderValue) / Float(maxSliderValue) * Float(steps.count - 1) - Float(stepIndex) + + if stepIndex >= steps.count - 1 { + return steps.last! + } else { + let range = steps[stepIndex + 1] - steps[stepIndex] + return steps[stepIndex] + Int(fraction * Float(range)) + } + } + + init(realValue: Int, maxRealValue: Int, maxSliderValue: Int, isLogarithmic: Bool) { + self.sliderSteps = Amount.makeSliderSteps(maxRealValue: maxRealValue, isLogarithmic: isLogarithmic) + self.maxRealValue = maxRealValue + self.maxSliderValue = maxSliderValue + self.isLogarithmic = isLogarithmic + + self.realValue = realValue + self.sliderValue = Amount.remapValueToSlider(realValue: self.realValue, maxSliderValue: self.maxSliderValue, steps: self.sliderSteps) + } + + init(sliderValue: Int, maxRealValue: Int, maxSliderValue: Int, isLogarithmic: Bool) { + self.sliderSteps = Amount.makeSliderSteps(maxRealValue: maxRealValue, isLogarithmic: isLogarithmic) + self.maxRealValue = maxRealValue + self.maxSliderValue = maxSliderValue + self.isLogarithmic = isLogarithmic + + self.sliderValue = sliderValue + self.realValue = Amount.remapSliderToValue(sliderValue: self.sliderValue, maxSliderValue: self.maxSliderValue, steps: self.sliderSteps) + } + + func withRealValue(_ realValue: Int) -> Amount { + return Amount(realValue: realValue, maxRealValue: self.maxRealValue, maxSliderValue: self.maxSliderValue, isLogarithmic: self.isLogarithmic) + } + + func withSliderValue(_ sliderValue: Int) -> Amount { + return Amount(sliderValue: sliderValue, maxRealValue: self.maxRealValue, maxSliderValue: self.maxSliderValue, isLogarithmic: self.isLogarithmic) + } + } + final class View: UIView, UIScrollViewDelegate { private let dimView: UIView private let backgroundLayer: SimpleLayer @@ -922,6 +984,7 @@ private final class ChatSendStarsScreenComponent: Component { private let scrollView: ScrollView private let scrollContentClippingView: SparseContainerView private let scrollContentView: UIView + private let hierarchyTrackingNode: HierarchyTrackingNode private let leftButton = ComponentView() private let closeButton = ComponentView() @@ -959,7 +1022,10 @@ private final class ChatSendStarsScreenComponent: Component { private var topOffsetDistance: CGFloat? private var balance: Int64? - private var amount: Int64 = 1 + + private var amount: Amount = Amount(realValue: 1, maxRealValue: 1000, maxSliderValue: 1000, isLogarithmic: true) + private var didChangeAmount: Bool = false + private var isAnonymous: Bool = false private var cachedStarImage: (UIImage, PresentationTheme)? private var cachedCloseImage: UIImage? @@ -968,6 +1034,8 @@ private final class ChatSendStarsScreenComponent: Component { private var balanceDisposable: Disposable? + private var badgePhysicsLink: SharedDisplayLinkDriver.Link? + override init(frame: CGRect) { self.bottomOverscrollLimit = 200.0 @@ -986,6 +1054,8 @@ private final class ChatSendStarsScreenComponent: Component { self.scrollContentView = UIView() + self.hierarchyTrackingNode = HierarchyTrackingNode() + super.init(frame: frame) self.addSubview(self.dimView) @@ -1016,6 +1086,30 @@ private final class ChatSendStarsScreenComponent: Component { self.addSubview(self.navigationBarContainer) self.dimView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.dimTapGesture(_:)))) + + self.addSubnode(self.hierarchyTrackingNode) + + self.hierarchyTrackingNode.updated = { [weak self] value in + guard let self else { + return + } + if value { + if self.badgePhysicsLink == nil { + let badgePhysicsLink = SharedDisplayLinkDriver.shared.add(framesPerSecond: .max, { [weak self] _ in + guard let self else { + return + } + self.updateBadgePhysics() + }) + self.badgePhysicsLink = badgePhysicsLink + } + } else { + if let badgePhysicsLink = self.badgePhysicsLink { + self.badgePhysicsLink = nil + badgePhysicsLink.invalidate() + } + } + } } required init?(coder: NSCoder) { @@ -1058,6 +1152,12 @@ private final class ChatSendStarsScreenComponent: Component { return result } + if let badgeView = self.badge.view, badgeView.hitTest(self.convert(point, to: badgeView), with: event) != nil { + if let sliderView = self.slider.view as? SliderComponent.View, let hitTestTarget = sliderView.hitTestTarget { + return hitTestTarget + } + } + let result = super.hitTest(point, with: event) return result } @@ -1081,7 +1181,7 @@ private final class ChatSendStarsScreenComponent: Component { transition.setPosition(view: self.navigationBarContainer, position: CGPoint(x: 0.0, y: topOffset + itemLayout.containerInset)) - let topOffsetDistance: CGFloat = min(200.0, floor(itemLayout.containerSize.height * 0.25)) + let topOffsetDistance: CGFloat = min(60.0, floor(itemLayout.containerSize.height * 0.25)) self.topOffsetDistance = topOffsetDistance var topOffsetFraction = topOffset / topOffsetDistance topOffsetFraction = max(0.0, min(1.0, topOffsetFraction)) @@ -1123,7 +1223,78 @@ private final class ChatSendStarsScreenComponent: Component { private var previousSliderValue: Float = 0.0 private var previousTimestamp: Double? - private var inertiaDirection: BadgeComponent.Direction? + + private var badgeAngularSpeed: CGFloat = 0.0 + private var badgeAngle: CGFloat = 0.0 + private var previousBadgeX: CGFloat? + private var previousPhysicsTimestamp: Double? + + private func updateBadgePhysics() { + let timestamp = CACurrentMediaTime() + + let deltaTime: CGFloat + if let previousPhysicsTimestamp = self.previousPhysicsTimestamp { + deltaTime = CGFloat(min(1.0 / 60.0, timestamp - previousPhysicsTimestamp)) + } else { + deltaTime = CGFloat(1.0 / 60.0) + } + self.previousPhysicsTimestamp = timestamp + + guard let badgeView = self.badge.view as? BadgeComponent.View else { + return + } + let badgeX = badgeView.center.x + + let horizontalVelocity: CGFloat + if let previousBadgeX = self.previousBadgeX { + horizontalVelocity = (badgeX - previousBadgeX) / deltaTime + } else { + horizontalVelocity = 0.0 + } + self.previousBadgeX = badgeX + + let testSpringFriction: CGFloat = 9.0 + let testSpringConstant: CGFloat = 243.0 + + let frictionConstant: CGFloat = testSpringFriction + let springConstant: CGFloat = testSpringConstant + let time: CGFloat = deltaTime + + var badgeAngle = self.badgeAngle + + badgeAngle -= horizontalVelocity * 0.0001 + if abs(badgeAngle) > 0.22 { + badgeAngle = badgeAngle < 0.0 ? -0.22 : 0.22 + } + + // friction force = velocity * friction constant + let frictionForce = self.badgeAngularSpeed * frictionConstant + // spring force = (target point - current position) * spring constant + let springForce = -badgeAngle * springConstant + // force = spring force - friction force + let force = springForce - frictionForce + + // velocity = current velocity + force * time / mass + self.badgeAngularSpeed = self.badgeAngularSpeed + force * time + // position = current position + velocity * time + badgeAngle = badgeAngle + self.badgeAngularSpeed * time + badgeAngle = badgeAngle.isNaN ? 0.0 : badgeAngle + + let epsilon: CGFloat = 0.01 + if abs(badgeAngle) < epsilon && abs(self.badgeAngularSpeed) < epsilon { + badgeAngle = 0.0 + self.badgeAngularSpeed = 0.0 + } + + if abs(badgeAngle) > 0.22 { + badgeAngle = badgeAngle < 0.0 ? -0.22 : 0.22 + } + + if self.badgeAngle != badgeAngle { + self.badgeAngle = badgeAngle + badgeView.updateBadgeAngle(angle: self.badgeAngle) + } + } func update(component: ChatSendStarsScreenComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { let environment = environment[ViewControllerComponentContainer.Environment.self].value @@ -1131,11 +1302,21 @@ private final class ChatSendStarsScreenComponent: Component { let resetScrolling = self.scrollView.bounds.width != availableSize.width - let sideInset: CGFloat = 16.0 + let fillingSize: CGFloat + if case .regular = environment.metrics.widthClass { + fillingSize = min(availableSize.width, 414.0) - environment.safeInsets.left * 2.0 + } else { + fillingSize = min(availableSize.width, 428.0) - environment.safeInsets.left * 2.0 + } + let sideInset: CGFloat = floor((availableSize.width - fillingSize) * 0.5) + 16.0 if self.component == nil { self.balance = component.balance - self.amount = 50 + var isLogarithmic = true + if let data = component.context.currentAppConfiguration.with({ $0 }).data, let value = data["ios_stars_reaction_logarithmic_scale"] as? Double { + isLogarithmic = Int(value) != 0 + } + self.amount = Amount(realValue: 50, maxRealValue: component.maxAmount, maxSliderValue: 999, isLogarithmic: isLogarithmic) if let myTopPeer = component.myTopPeer { self.isAnonymous = myTopPeer.isAnonymous } @@ -1182,8 +1363,8 @@ private final class ChatSendStarsScreenComponent: Component { let sliderSize = self.slider.update( transition: transition, component: AnyComponent(SliderComponent( - valueCount: component.maxAmount, - value: Int(self.amount), + valueCount: self.amount.maxSliderValue + 1, + value: self.amount.sliderValue, markPositions: false, trackBackgroundColor: .clear, trackForegroundColor: .clear, @@ -1193,7 +1374,9 @@ private final class ChatSendStarsScreenComponent: Component { guard let self, let component = self.component else { return } - self.amount = 1 + Int64(value) + self.amount = self.amount.withSliderValue(value) + self.didChangeAmount = true + self.state?.updated(transition: ComponentTransition(animation: .none).withUserData(IsAdjustingAmountHint())) let sliderValue = Float(value) / Float(component.maxAmount) @@ -1207,21 +1390,7 @@ private final class ChatSendStarsScreenComponent: Component { let speed = deltaValue / Float(deltaTime) let newSpeed = max(0, min(65.0, speed * 70.0)) - var inertiaDirection: BadgeComponent.Direction? - if newSpeed >= 1.0 { - if delta > 0.0 { - inertiaDirection = .right - } else { - inertiaDirection = .left - } - } - if inertiaDirection != self.inertiaDirection { - self.inertiaDirection = inertiaDirection - self.state?.updated(transition: .immediate) - } - if newSpeed < 0.01 && deltaValue < 0.001 { - } else { self.badgeStars.update(speed: newSpeed, delta: delta) } @@ -1238,10 +1407,6 @@ private final class ChatSendStarsScreenComponent: Component { self.previousTimestamp = nil self.badgeStars.update(speed: 0.0) } - if self.inertiaDirection != nil { - self.inertiaDirection = nil - self.state?.updated(transition: .immediate) - } } )), environment: {}, @@ -1250,7 +1415,7 @@ private final class ChatSendStarsScreenComponent: Component { let sliderFrame = CGRect(origin: CGPoint(x: sliderInset, y: contentHeight + 127.0), size: sliderSize) let sliderBackgroundFrame = CGRect(origin: CGPoint(x: sliderFrame.minX - 8.0, y: sliderFrame.minY + 7.0), size: CGSize(width: sliderFrame.width + 16.0, height: sliderFrame.height - 14.0)) - let progressFraction: CGFloat = CGFloat(self.amount) / CGFloat(component.maxAmount - 1) + let progressFraction: CGFloat = CGFloat(self.amount.sliderValue) / CGFloat(self.amount.maxSliderValue) let topOthersCount: Int? = component.topPeers.filter({ !$0.isMy }).max(by: { $0.count < $1.count })?.count var topCount: Int? @@ -1301,17 +1466,11 @@ private final class ChatSendStarsScreenComponent: Component { transition.setFrame(view: sliderBackgroundView, frame: sliderBackgroundFrame) - var effectiveInertiaDirection = self.inertiaDirection - if progressFraction <= 0.03 || progressFraction >= 0.97 { - effectiveInertiaDirection = nil - } - let badgeSize = self.badge.update( transition: transition, component: AnyComponent(BadgeComponent( theme: environment.theme, - title: "\(self.amount)", - inertiaDirection: effectiveInertiaDirection + title: "\(self.amount.realValue)" )), environment: {}, containerSize: CGSize(width: 200.0, height: 200.0) @@ -1363,7 +1522,7 @@ private final class ChatSendStarsScreenComponent: Component { environment: {}, containerSize: CGSize(width: 120.0, height: 100.0) ) - let leftButtonFrame = CGRect(origin: CGPoint(x: 16.0, y: floor((56.0 - leftButtonSize.height) * 0.5)), size: leftButtonSize) + let leftButtonFrame = CGRect(origin: CGPoint(x: sideInset, y: floor((56.0 - leftButtonSize.height) * 0.5)), size: leftButtonSize) if let leftButtonView = self.leftButton.view { if leftButtonView.superview == nil { self.navigationBarContainer.addSubview(leftButtonView) @@ -1542,15 +1701,24 @@ private final class ChatSendStarsScreenComponent: Component { if let index = mappedTopPeers.firstIndex(where: { $0.isMy }) { mappedTopPeers.remove(at: index) } - var myCount = Int(self.amount) + + var myCount = 0 if let myTopPeer = component.myTopPeer { myCount += myTopPeer.count } - mappedTopPeers.append(ChatSendStarsScreen.TopPeer( - peer: self.isAnonymous ? nil : component.myPeer, - isMy: true, - count: myCount - )) + var myCountAddition = 0 + if self.didChangeAmount { + myCountAddition = Int(self.amount.realValue) + } + myCount += myCountAddition + if myCount != 0 { + mappedTopPeers.append(ChatSendStarsScreen.TopPeer( + randomIndex: -1, + peer: self.isAnonymous ? nil : component.myPeer, + isMy: true, + count: myCount + )) + } mappedTopPeers.sort(by: { $0.count > $1.count }) if mappedTopPeers.count > 3 { mappedTopPeers = Array(mappedTopPeers.prefix(3)) @@ -1578,6 +1746,11 @@ private final class ChatSendStarsScreenComponent: Component { self.topPeerItems[topPeer.id] = itemView } + let itemCountString = presentationStringsFormattedNumber(Int32(topPeer.count), environment.dateTimeFormat.groupingSeparator) + /*if topPeer.isMy && myCountAddition != 0 && topPeer.count > myCountAddition { + itemCountString = "\(topPeer.count - myCountAddition) +\(myCountAddition)" + }*/ + let itemSize = itemView.update( transition: .immediate, component: AnyComponent(PlainButtonComponent( @@ -1586,7 +1759,7 @@ private final class ChatSendStarsScreenComponent: Component { theme: environment.theme, strings: environment.strings, peer: topPeer.peer, - count: topPeer.count + count: itemCountString )), effectAlignment: .center, action: { [weak self] in @@ -1747,7 +1920,7 @@ private final class ChatSendStarsScreenComponent: Component { self.cachedStarImage = (generateTintedImage(image: UIImage(bundleImageName: "Item List/PremiumIcon"), color: .white)!, environment.theme) } - let buttonString = environment.strings.SendStarReactions_SendButtonTitle("\(self.amount)").string + let buttonString = environment.strings.SendStarReactions_SendButtonTitle("\(self.amount.realValue)").string let buttonAttributedString = NSMutableAttributedString(string: buttonString, font: Font.semibold(17.0), textColor: .white, paragraphAlignment: .center) if let range = buttonAttributedString.string.range(of: "#"), let starImage = self.cachedStarImage?.0 { buttonAttributedString.addAttribute(.attachment, value: starImage, range: NSRange(range, in: buttonAttributedString.string)) @@ -1778,7 +1951,7 @@ private final class ChatSendStarsScreenComponent: Component { return } - if balance < self.amount { + if balance < self.amount.realValue { let _ = (component.context.engine.payments.starsTopUpOptions() |> take(1) |> deliverOnMainQueue).startStandalone(next: { [weak self] options in @@ -1789,7 +1962,7 @@ private final class ChatSendStarsScreenComponent: Component { return } - let purchaseScreen = component.context.sharedContext.makeStarsPurchaseScreen(context: component.context, starsContext: starsContext, options: options, purpose: .transfer(peerId: component.peer.id, requiredStars: self.amount), completion: { result in + let purchaseScreen = component.context.sharedContext.makeStarsPurchaseScreen(context: component.context, starsContext: starsContext, options: options, purpose: .reactions(peerId: component.peer.id, requiredStars: Int64(self.amount.realValue)), completion: { result in let _ = result //TODO:release }) @@ -1805,13 +1978,13 @@ private final class ChatSendStarsScreenComponent: Component { } let isBecomingTop: Bool if let topCount { - isBecomingTop = self.amount > topCount + isBecomingTop = self.amount.realValue > topCount } else { isBecomingTop = true } component.completion( - self.amount, + Int64(self.amount.realValue), self.isAnonymous, isBecomingTop, ChatSendStarsScreen.TransitionOut( @@ -1891,7 +2064,7 @@ private final class ChatSendStarsScreenComponent: Component { transition.setFrame(view: self.scrollContentView, frame: CGRect(origin: CGPoint(x: 0.0, y: topInset + containerInset), size: CGSize(width: availableSize.width, height: contentHeight))) transition.setPosition(layer: self.backgroundLayer, position: CGPoint(x: availableSize.width / 2.0, y: availableSize.height / 2.0)) - transition.setBounds(layer: self.backgroundLayer, bounds: CGRect(origin: CGPoint(), size: availableSize)) + transition.setBounds(layer: self.backgroundLayer, bounds: CGRect(origin: CGPoint(), size: CGSize(width: fillingSize, height: availableSize.height))) let scrollClippingFrame = CGRect(origin: CGPoint(x: 0.0, y: containerInset), size: CGSize(width: availableSize.width, height: clippingY - containerInset)) transition.setPosition(view: self.scrollContentClippingView, position: scrollClippingFrame.center) @@ -1953,7 +2126,7 @@ public class ChatSendStarsScreen: ViewControllerComponentContainer { fileprivate final class TopPeer: Equatable { enum Id: Hashable { - case anonymous + case anonymous(Int) case my case peer(EnginePeer.Id) } @@ -1964,7 +2137,7 @@ public class ChatSendStarsScreen: ViewControllerComponentContainer { } else if let peer = self.peer { return .peer(peer.id) } else { - return .anonymous + return .anonymous(self.randomIndex) } } @@ -1972,17 +2145,22 @@ public class ChatSendStarsScreen: ViewControllerComponentContainer { return self.peer == nil } + let randomIndex: Int let peer: EnginePeer? let isMy: Bool let count: Int - init(peer: EnginePeer?, isMy: Bool, count: Int) { + init(randomIndex: Int, peer: EnginePeer?, isMy: Bool, count: Int) { + self.randomIndex = randomIndex self.peer = peer self.isMy = isMy self.count = count } static func ==(lhs: TopPeer, rhs: TopPeer) -> Bool { + if lhs.randomIndex != rhs.randomIndex { + return false + } if lhs.peer != rhs.peer { return false } @@ -2099,6 +2277,7 @@ public class ChatSendStarsScreen: ViewControllerComponentContainer { return nil } + var nextRandomIndex = 0 return InitialData( peer: peer, myPeer: myPeer, @@ -2107,7 +2286,10 @@ public class ChatSendStarsScreen: ViewControllerComponentContainer { currentSentAmount: currentSentAmount, topPeers: topPeers.compactMap { topPeer -> ChatSendStarsScreen.TopPeer? in guard let topPeerId = topPeer.peerId else { + let randomIndex = nextRandomIndex + nextRandomIndex += 1 return ChatSendStarsScreen.TopPeer( + randomIndex: randomIndex, peer: nil, isMy: topPeer.isMy, count: Int(topPeer.count) @@ -2119,7 +2301,10 @@ public class ChatSendStarsScreen: ViewControllerComponentContainer { guard let topPeerValue else { return nil } + let randomIndex = nextRandomIndex + nextRandomIndex += 1 return ChatSendStarsScreen.TopPeer( + randomIndex: randomIndex, peer: topPeer.isAnonymous ? nil : topPeerValue, isMy: topPeer.isMy, count: Int(topPeer.count) @@ -2128,6 +2313,7 @@ public class ChatSendStarsScreen: ViewControllerComponentContainer { myTopPeer: myTopPeer.flatMap { topPeer -> ChatSendStarsScreen.TopPeer? in guard let topPeerId = topPeer.peerId else { return ChatSendStarsScreen.TopPeer( + randomIndex: -1, peer: nil, isMy: topPeer.isMy, count: Int(topPeer.count) @@ -2140,6 +2326,7 @@ public class ChatSendStarsScreen: ViewControllerComponentContainer { return nil } return ChatSendStarsScreen.TopPeer( + randomIndex: -1, peer: topPeer.isAnonymous ? nil : topPeerValue, isMy: topPeer.isMy, count: Int(topPeer.count) diff --git a/submodules/TelegramUI/Components/InteractiveTextComponent/Sources/InteractiveTextNodeWithEntities.swift b/submodules/TelegramUI/Components/InteractiveTextComponent/Sources/InteractiveTextNodeWithEntities.swift index cb92c3f9cba..1949e4f8c60 100644 --- a/submodules/TelegramUI/Components/InteractiveTextComponent/Sources/InteractiveTextNodeWithEntities.swift +++ b/submodules/TelegramUI/Components/InteractiveTextComponent/Sources/InteractiveTextNodeWithEntities.swift @@ -115,6 +115,8 @@ public final class InteractiveTextNodeWithEntities { private var enableLooping: Bool = true + public private(set) var attributedString: NSAttributedString? + public var visibilityRect: CGRect? { didSet { if !self.inlineStickerItemLayers.isEmpty && oldValue != self.visibilityRect { @@ -217,6 +219,8 @@ public final class InteractiveTextNodeWithEntities { let result = apply(applyArguments.applyArguments) if let maybeNode = maybeNode { + maybeNode.attributedString = arguments.attributedString + maybeNode.updateInteractiveContents( context: applyArguments.context, cache: applyArguments.cache, @@ -234,6 +238,8 @@ public final class InteractiveTextNodeWithEntities { } else { let resultNode = InteractiveTextNodeWithEntities(textNode: result) + resultNode.attributedString = arguments.attributedString + resultNode.updateInteractiveContents( context: applyArguments.context, cache: applyArguments.cache, diff --git a/submodules/TelegramUI/Components/MessageInputPanelComponent/Sources/ContextResultPanelComponent.swift b/submodules/TelegramUI/Components/MessageInputPanelComponent/Sources/ContextResultPanelComponent.swift index f1a70122bd9..634c62295cb 100644 --- a/submodules/TelegramUI/Components/MessageInputPanelComponent/Sources/ContextResultPanelComponent.swift +++ b/submodules/TelegramUI/Components/MessageInputPanelComponent/Sources/ContextResultPanelComponent.swift @@ -225,7 +225,7 @@ final class ContextResultPanelComponent: Component { sideInset: itemLayout.sideInset, title: peer.displayTitle(strings: component.strings, displayOrder: .firstLast), peer: peer, - subtitle: peer.addressName.flatMap { "@\($0)" }, + subtitle: peer.addressName.flatMap { PeerListItemComponent.Subtitle(text: "@\($0)", color: .neutral) }, subtitleAccessory: .none, presence: nil, selectionState: .none, @@ -294,7 +294,7 @@ final class ContextResultPanelComponent: Component { sideInset: sideInset, title: "AAAAAAAAAAAA", peer: nil, - subtitle: "BBBBBBB", + subtitle: PeerListItemComponent.Subtitle(text: "BBBBBBB", color: .neutral), subtitleAccessory: .none, presence: nil, selectionState: .none, diff --git a/submodules/TelegramUI/Components/NavigationStackComponent/Sources/NavigationStackComponent.swift b/submodules/TelegramUI/Components/NavigationStackComponent/Sources/NavigationStackComponent.swift index 0a0a12c4a4b..e7a795459fc 100644 --- a/submodules/TelegramUI/Components/NavigationStackComponent/Sources/NavigationStackComponent.swift +++ b/submodules/TelegramUI/Components/NavigationStackComponent/Sources/NavigationStackComponent.swift @@ -79,6 +79,11 @@ private final class NavigationContainer: UIView, UIGestureRecognizerDelegate { } public final class NavigationStackComponent: Component { + public enum CurlTransition { + case show + case hide + } + public let items: [AnyComponentWithIdentity] public let requestPop: () -> Void @@ -105,7 +110,7 @@ public final class NavigationStackComponent: Compon super.init(frame: frame) self.dimView.alpha = 0.0 - self.dimView.backgroundColor = UIColor.black.withAlphaComponent(0.2) + self.dimView.backgroundColor = UIColor.black.withAlphaComponent(0.3) self.dimView.isUserInteractionEnabled = false self.addSubview(self.dimView) } @@ -166,11 +171,20 @@ public final class NavigationStackComponent: Compon self.component = component self.state = state + var transition = transition + var curlTransition: NavigationStackComponent.CurlTransition? + if let curlTransitionValue = transition.userData(NavigationStackComponent.CurlTransition.self) { + transition = .immediate + curlTransition = curlTransitionValue + } + let navigationTransitionFraction = self.navigationContainer.transitionFraction self.navigationContainer.isNavigationEnabled = component.items.count > 1 var validItemIds: [AnyHashable] = [] + var removeImpl: (() -> Void)? + var readyItems: [ReadyItem] = [] for i in 0 ..< component.items.count { let item = component.items[i] @@ -184,6 +198,7 @@ public final class NavigationStackComponent: Compon } else { itemTransition = itemTransition.withAnimation(.none) itemView = ItemView() + itemView.clipsToBounds = true self.itemViews[itemId] = itemView itemView.contents.parentState = state } @@ -242,7 +257,18 @@ public final class NavigationStackComponent: Compon readyItem.itemTransition.setFrame(view: readyItem.itemView.dimView, frame: CGRect(origin: .zero, size: availableSize)) readyItem.itemTransition.setAlpha(view: readyItem.itemView.dimView, alpha: 1.0 - alphaTransitionFraction) - if readyItem.index > 0 && isAdded { + if curlTransition == .show && isAdded { + var fromFrame = itemFrame + fromFrame.size.height = 0.0 + let transition = ComponentTransition.easeInOut(duration: 0.3) + transition.animateBoundsSize(view: readyItem.itemView, from: fromFrame.size, to: itemFrame.size, completion: { _ in + removeImpl?() + }) + transition.animatePosition(view: readyItem.itemView, from: fromFrame.center, to: itemFrame.center) + } else if curlTransition == .hide && isAdded { + let transition = ComponentTransition.easeInOut(duration: 0.3) + transition.animateAlpha(view: readyItem.itemView.dimView, from: 1.0, to: 0.0) + } else if readyItem.index > 0 && isAdded { transition.animatePosition(view: itemComponentView, from: CGPoint(x: itemFrame.width, y: 0.0), to: .zero, additive: true, completion: nil) } } @@ -263,23 +289,49 @@ public final class NavigationStackComponent: Compon removedItemIds.append(id) } } - for id in removedItemIds { - guard let itemView = self.itemViews[id] else { - continue - } - if let itemComponeentView = itemView.contents.view { - var position = itemComponeentView.center - position.x += itemComponeentView.bounds.width - transition.setPosition(view: itemComponeentView, position: position, completion: { _ in + + removeImpl = { + for id in removedItemIds { + guard let itemView = self.itemViews[id] else { + continue + } + if let itemComponentView = itemView.contents.view, curlTransition != .show { + if curlTransition == .hide { + itemView.superview?.bringSubviewToFront(itemView) + var toFrame = itemView.frame + toFrame.size.height = 0.0 + let transition = ComponentTransition.easeInOut(duration: 0.3) + transition.setFrame(view: itemView, frame: toFrame, completion: { _ in + itemView.removeFromSuperview() + self.itemViews.removeValue(forKey: id) + }) + } else { + var position = itemComponentView.center + position.x += itemComponentView.bounds.width + transition.setPosition(view: itemComponentView, position: position, completion: { _ in + itemView.removeFromSuperview() + self.itemViews.removeValue(forKey: id) + }) + } + } else { itemView.removeFromSuperview() self.itemViews.removeValue(forKey: id) - }) - } else { - itemView.removeFromSuperview() - self.itemViews.removeValue(forKey: id) + } } } + if curlTransition == .show { + let transition = ComponentTransition.easeInOut(duration: 0.3) + for id in removedItemIds { + guard let itemView = self.itemViews[id] else { + continue + } + transition.setAlpha(view: itemView.dimView, alpha: 1.0) + } + } else { + removeImpl?() + } + let contentSize = CGSize(width: availableSize.width, height: contentHeight) self.navigationContainer.frame = CGRect(origin: .zero, size: contentSize) diff --git a/submodules/TelegramUI/Components/PeerAllowedReactionsScreen/Sources/PeerAllowedReactionsScreen.swift b/submodules/TelegramUI/Components/PeerAllowedReactionsScreen/Sources/PeerAllowedReactionsScreen.swift index b347a46bfda..539f4497e5c 100644 --- a/submodules/TelegramUI/Components/PeerAllowedReactionsScreen/Sources/PeerAllowedReactionsScreen.swift +++ b/submodules/TelegramUI/Components/PeerAllowedReactionsScreen/Sources/PeerAllowedReactionsScreen.swift @@ -154,13 +154,17 @@ final class PeerAllowedReactionsScreenComponent: Component { if Set(availableReactions.reactions.filter({ $0.isEnabled }).map(\.value)) == Set(enabledReactions.map(\.reaction)) { allowedReactions = .all } else { - allowedReactions = .limited(enabledReactions.map(\.reaction)) + if enabledReactions.isEmpty { + allowedReactions = .empty + } else { + allowedReactions = .limited(enabledReactions.map(\.reaction)) + } } } else { allowedReactions = .empty } - let reactionSettings = PeerReactionSettings(allowedReactions: allowedReactions, maxReactionCount: self.allowedReactionCount >= 11 ? nil : Int32(self.allowedReactionCount), starsAllowed: self.areStarsReactionsEnabled) + let reactionSettings = PeerReactionSettings(allowedReactions: allowedReactions, maxReactionCount: self.allowedReactionCount >= 11 ? nil : Int32(self.allowedReactionCount), starsAllowed: self.isEnabled && self.areStarsReactionsEnabled) if self.appliedReactionSettings != reactionSettings { if case .empty = allowedReactions { @@ -255,7 +259,7 @@ final class PeerAllowedReactionsScreenComponent: Component { } else { allowedReactions = .empty } - let reactionSettings = PeerReactionSettings(allowedReactions: allowedReactions, maxReactionCount: self.allowedReactionCount == 11 ? nil : Int32(self.allowedReactionCount), starsAllowed: self.areStarsReactionsEnabled) + let reactionSettings = PeerReactionSettings(allowedReactions: allowedReactions, maxReactionCount: self.allowedReactionCount == 11 ? nil : Int32(self.allowedReactionCount), starsAllowed: self.isEnabled && self.areStarsReactionsEnabled) let applyDisposable = (component.context.engine.peers.updatePeerReactionSettings(peerId: component.peerId, reactionSettings: reactionSettings) |> deliverOnMainQueue).start(error: { [weak self] error in @@ -603,7 +607,7 @@ final class PeerAllowedReactionsScreenComponent: Component { self.displayInput = false } - self.state?.updated(transition: .easeInOut(duration: 0.25)) + self.state?.updated(transition: .immediate) } } )), diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoData.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoData.swift index 5a33c2f46db..48263e0a887 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoData.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoData.swift @@ -847,12 +847,6 @@ func peerInfoScreenSettingsData(context: AccountContext, peerId: EnginePeer.Id, let starsState: Signal if let starsContext { starsState = starsContext.state - |> map { state in - if let state, state.balance > 0 || !state.transactions.isEmpty { - return state - } - return nil - } } else { starsState = .single(nil) } diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift index e85effb9b72..d8d55013382 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift @@ -308,6 +308,10 @@ final class PeerInfoSelectionPanelNode: ASDisplayNode { let separatorNode: ASDisplayNode let backgroundNode: NavigationBackgroundNode + var viewForOverlayContent: UIView? { + return self.selectionPanel.viewForOverlayContent + } + init(context: AccountContext, presentationData: PresentationData, peerId: PeerId, deleteMessages: @escaping () -> Void, shareMessages: @escaping () -> Void, forwardMessages: @escaping () -> Void, reportMessages: @escaping () -> Void, displayCopyProtectionTip: @escaping (ASDisplayNode, Bool) -> Void) { self.context = context self.peerId = peerId @@ -465,7 +469,7 @@ final class PeerInfoSelectionPanelNode: ASDisplayNode { self.separatorNode.backgroundColor = presentationData.theme.rootController.navigationBar.separatorColor let interfaceState = ChatPresentationInterfaceState(chatWallpaper: .color(0), theme: presentationData.theme, strings: presentationData.strings, dateTimeFormat: presentationData.dateTimeFormat, nameDisplayOrder: presentationData.nameDisplayOrder, limitsConfiguration: .defaultValue, fontSize: .regular, bubbleCorners: PresentationChatBubbleCorners(mainRadius: 16.0, auxiliaryRadius: 8.0, mergeBubbleCorners: true), accountPeerId: self.context.account.peerId, mode: .standard(.default), chatLocation: .peer(id: self.peerId), subject: nil, peerNearbyData: nil, greetingData: nil, pendingUnpinnedAllMessages: false, activeGroupCallInfo: nil, hasActiveGroupCall: false, importState: nil, threadData: nil, isGeneralThreadClosed: nil, replyMessage: nil, accountPeerColor: nil, businessIntro: nil) - let panelHeight = self.selectionPanel.updateLayout(width: layout.size.width, leftInset: layout.safeInsets.left, rightInset: layout.safeInsets.right, bottomInset: layout.intrinsicInsets.bottom, additionalSideInsets: UIEdgeInsets(), maxHeight: 0.0, isSecondary: false, transition: transition, interfaceState: interfaceState, metrics: layout.metrics, isMediaInputExpanded: false) + let panelHeight = self.selectionPanel.updateLayout(width: layout.size.width, leftInset: layout.safeInsets.left, rightInset: layout.safeInsets.right, bottomInset: layout.intrinsicInsets.bottom, additionalSideInsets: UIEdgeInsets(), maxHeight: layout.size.height, isSecondary: false, transition: transition, interfaceState: interfaceState, metrics: layout.metrics, isMediaInputExpanded: false) transition.updateFrame(node: self.selectionPanel, frame: CGRect(origin: CGPoint(), size: CGSize(width: layout.size.width, height: panelHeight))) @@ -1859,7 +1863,7 @@ private func infoItems(data: PeerInfoScreenData?, context: AccountContext, prese attributedString.addAttribute(.baselineOffset, value: 1.5, range: NSRange(range, in: attributedString.string)) } if let range = attributedString.string.range(of: "*") { - attributedString.addAttribute(ChatTextInputAttributes.customEmoji, value: ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: nil, fileId: 0, file: nil, custom: .stars(tinted: false)), range: NSRange(range, in: attributedString.string)) + attributedString.addAttribute(ChatTextInputAttributes.customEmoji, value: ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: nil, fileId: 1, file: nil, custom: .stars(tinted: false)), range: NSRange(range, in: attributedString.string)) attributedString.addAttribute(.baselineOffset, value: 1.5, range: NSRange(range, in: attributedString.string)) } @@ -6202,6 +6206,30 @@ final class PeerInfoScreenNode: ViewControllerTracingNode, PeerInfoScreenNodePro }))) } + var addedPrivacy = false + var privacyPolicyUrl: String? + if let cachedData = (data.cachedData as? CachedUserData), let botInfo = cachedData.botInfo { + if let url = botInfo.privacyPolicyUrl { + privacyPolicyUrl = url + } else if botInfo.commands.contains(where: { $0.text == "privacy" }) { + + } else { + privacyPolicyUrl = presentationData.strings.WebApp_PrivacyPolicy_URL + } + } + if let privacyPolicyUrl { + items.append(.action(ContextMenuActionItem(text: presentationData.strings.UserInfo_BotPrivacy, icon: { theme in + generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Info"), color: theme.contextMenu.primaryColor) + }, action: { [weak self] _, f in + f(.dismissWithoutContent) + + guard let self else { + return + } + self.context.sharedContext.openExternalUrl(context: self.context, urlContext: .generic, url: privacyPolicyUrl, forceExternal: false, presentationData: self.presentationData, navigationController: self.controller?.navigationController as? NavigationController, dismissInput: {}) + }))) + addedPrivacy = true + } if let cachedData = data.cachedData as? CachedUserData, let botInfo = cachedData.botInfo { for command in botInfo.commands { if command.text == "settings" { @@ -6218,7 +6246,7 @@ final class PeerInfoScreenNode: ViewControllerTracingNode, PeerInfoScreenNodePro f(.dismissWithoutContent) self?.performBotCommand(command: .help) }))) - } else if command.text == "privacy" { + } else if command.text == "privacy" && !addedPrivacy { items.append(.action(ContextMenuActionItem(text: presentationData.strings.UserInfo_BotPrivacy, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Info"), color: theme.contextMenu.primaryColor) }, action: { [weak self] _, f in @@ -12039,6 +12067,9 @@ final class PeerInfoScreenNode: ViewControllerTracingNode, PeerInfoScreenNodePro }) self.paneContainerNode.selectionPanelNode = selectionPanelNode self.paneContainerNode.addSubnode(selectionPanelNode) + if let viewForOverlayContent = selectionPanelNode.viewForOverlayContent { + self.paneContainerNode.view.addSubview(viewForOverlayContent) + } } selectionPanelNode.selectionPanel.selectedMessages = selectedMessageIds let panelHeight = selectionPanelNode.update(layout: layout, presentationData: self.presentationData, transition: wasAdded ? .immediate : transition) @@ -12046,13 +12077,23 @@ final class PeerInfoScreenNode: ViewControllerTracingNode, PeerInfoScreenNodePro if wasAdded { selectionPanelNode.frame = panelFrame transition.animatePositionAdditive(node: selectionPanelNode, offset: CGPoint(x: 0.0, y: panelHeight)) + + if let viewForOverlayContent = selectionPanelNode.viewForOverlayContent { + viewForOverlayContent.frame = panelFrame + transition.animatePositionAdditive(layer: viewForOverlayContent.layer, offset: CGPoint(x: 0.0, y: panelHeight)) + } } else { transition.updateFrame(node: selectionPanelNode, frame: panelFrame) + + if let viewForOverlayContent = selectionPanelNode.viewForOverlayContent { + transition.updateFrame(view: viewForOverlayContent, frame: panelFrame) + } } } else if let selectionPanelNode = self.paneContainerNode.selectionPanelNode { self.paneContainerNode.selectionPanelNode = nil transition.updateFrame(node: selectionPanelNode, frame: CGRect(origin: CGPoint(x: 0.0, y: layout.size.height), size: selectionPanelNode.bounds.size), completion: { [weak selectionPanelNode] _ in selectionPanelNode?.removeFromSupernode() + selectionPanelNode?.viewForOverlayContent?.removeFromSuperview() }) } diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoStoryPaneNode.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoStoryPaneNode.swift index 738ef1a489d..3eaa03290d4 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoStoryPaneNode.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoStoryPaneNode.swift @@ -52,7 +52,7 @@ private final class VisualMediaItemInteraction { let openItemContextActions: (EngineStoryItem, ASDisplayNode, CGRect, ContextGesture?) -> Void let toggleSelection: (Int32, Bool) -> Void - var hiddenMedia = Set() + var hiddenStories = Set() var selectedIds: Set? init( @@ -1825,7 +1825,7 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr let listContext = PeerStoryListContentContextImpl( context: self.context, listContext: self.listSource, - initialId: item.story.id, + initialId: item.storyId, splitIndexIntoDays: splitIndexIntoDays ) self.pendingOpenListContext = listContext @@ -1940,8 +1940,8 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr return } if let itemId { - let anyAmount = self.itemInteraction.hiddenMedia.isEmpty - self.itemInteraction.hiddenMedia = Set([itemId.id]) + let anyAmount = self.itemInteraction.hiddenStories.isEmpty + self.itemInteraction.hiddenStories = Set([itemId]) if let items = self.items, let item = items.items.first(where: { $0.id == AnyHashable(itemId.id) }) { self.itemGrid.ensureItemVisible(index: item.index, anyAmount: anyAmount) @@ -1961,7 +1961,7 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr } } } else { - self.itemInteraction.hiddenMedia = Set() + self.itemInteraction.hiddenStories = Set() } self.updateHiddenItems() @@ -2754,6 +2754,8 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr } } else if case .botPreview = self.scope { title = self.presentationData.strings.BotPreviews_SubtitleCount(Int32(state.totalCount)) + } else if case .search = self.scope { + title = self.presentationData.strings.StoryList_SubtitleCount(Int32(state.totalCount)) } else { title = "" } @@ -3018,23 +3020,6 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr } public func updateHiddenMedia() { - //TODO:updateHiddenMedia - /*self.itemGrid.forEachVisibleItem { item in - guard let itemLayer = item.layer as? ItemLayer else { - return - } - if let item = itemLayer.item { - if self.itemInteraction.hiddenMedia[item.message.id] != nil { - itemLayer.isHidden = true - itemLayer.updateHasSpoiler(hasSpoiler: false) - self.itemGridBinding.revealedSpoilerMessageIds.insert(item.message.id) - } else { - itemLayer.isHidden = false - } - } else { - itemLayer.isHidden = false - } - }*/ } public func transferVelocity(_ velocity: CGFloat) { @@ -3281,7 +3266,7 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr guard let itemLayer = itemValue.layer as? ItemLayer, let item = itemLayer.item else { return } - let itemHidden = self.itemInteraction.hiddenMedia.contains(item.story.id) + let itemHidden = self.itemInteraction.hiddenStories.contains(item.storyId) itemLayer.isHidden = itemHidden if let blurLayer = itemValue.blurLayer { diff --git a/submodules/TelegramUI/Components/PeerSelectionController/Sources/PeerSelectionControllerNode.swift b/submodules/TelegramUI/Components/PeerSelectionController/Sources/PeerSelectionControllerNode.swift index 26e890145f4..540dd3a0b80 100644 --- a/submodules/TelegramUI/Components/PeerSelectionController/Sources/PeerSelectionControllerNode.swift +++ b/submodules/TelegramUI/Components/PeerSelectionController/Sources/PeerSelectionControllerNode.swift @@ -713,7 +713,8 @@ final class PeerSelectionControllerNode: ASDisplayNode { canSendWhenOnline: false, forwardMessageIds: strongSelf.presentationInterfaceState.interfaceState.forwardMessageIds ?? [], canMakePaidContent: false, - currentPrice: nil + currentPrice: nil, + hasTimers: false )), hasEntityKeyboard: hasEntityKeyboard, gesture: gesture, diff --git a/submodules/TelegramUI/Components/Premium/PremiumStarComponent/Sources/PremiumStarComponent.swift b/submodules/TelegramUI/Components/Premium/PremiumStarComponent/Sources/PremiumStarComponent.swift index 9368f7daba5..8a531f9c82d 100644 --- a/submodules/TelegramUI/Components/Premium/PremiumStarComponent/Sources/PremiumStarComponent.swift +++ b/submodules/TelegramUI/Components/Premium/PremiumStarComponent/Sources/PremiumStarComponent.swift @@ -76,6 +76,7 @@ public final class PremiumStarComponent: Component { let hasIdleAnimations: Bool let colors: [UIColor]? let particleColor: UIColor? + let backgroundColor: UIColor? public init( theme: PresentationTheme, @@ -83,7 +84,8 @@ public final class PremiumStarComponent: Component { isVisible: Bool, hasIdleAnimations: Bool, colors: [UIColor]? = nil, - particleColor: UIColor? = nil + particleColor: UIColor? = nil, + backgroundColor: UIColor? = nil ) { self.theme = theme self.isIntro = isIntro @@ -91,10 +93,11 @@ public final class PremiumStarComponent: Component { self.hasIdleAnimations = hasIdleAnimations self.colors = colors self.particleColor = particleColor + self.backgroundColor = backgroundColor } public static func ==(lhs: PremiumStarComponent, rhs: PremiumStarComponent) -> Bool { - return lhs.theme === rhs.theme && lhs.isIntro == rhs.isIntro && lhs.isVisible == rhs.isVisible && lhs.hasIdleAnimations == rhs.hasIdleAnimations && lhs.colors == rhs.colors && lhs.particleColor == rhs.particleColor + return lhs.theme === rhs.theme && lhs.isIntro == rhs.isIntro && lhs.isVisible == rhs.isVisible && lhs.hasIdleAnimations == rhs.hasIdleAnimations && lhs.colors == rhs.colors && lhs.particleColor == rhs.particleColor && lhs.backgroundColor == rhs.backgroundColor } public final class View: UIView, SCNSceneRendererDelegate, ComponentTaggedView { @@ -290,71 +293,43 @@ public final class PremiumStarComponent: Component { } } - private var didSetup = false - private func setup() { - guard !self.didSetup, let scene = loadCompressedScene(name: "star2", version: sceneVersion) else { + private func updateColors(animated: Bool = false) { + guard let component = self.component, let colors = component.colors, let scene = self.sceneView.scene, let node = scene.rootNode.childNode(withName: "star", recursively: false) else { return } + if animated { + UIView.animate(withDuration: 0.25, animations: { + node.geometry?.materials.first?.diffuse.contents = generateDiffuseTexture(colors: colors) + }) + } else { + node.geometry?.materials.first?.diffuse.contents = generateDiffuseTexture(colors: colors) + } - self.didSetup = true - self.sceneView.scene = scene - self.sceneView.delegate = self + let names: [String] = [ + "particles_left", + "particles_right", + "particles_left_bottom", + "particles_right_bottom", + "particles_center" + ] - if let component = self.component, let node = scene.rootNode.childNode(withName: "star", recursively: false), let colors = - component.colors { - node.geometry?.materials.first?.diffuse.contents = generateDiffuseTexture(colors: colors) - - let names: [String] = [ - "particles_left", - "particles_right", - "particles_left_bottom", - "particles_right_bottom", - "particles_center" - ] - - let starNames: [String] = [ - "coins_left", - "coins_right" - ] - - if let particleColor = component.particleColor { - for name in starNames { - if let node = scene.rootNode.childNode(withName: name, recursively: false), let particleSystem = node.particleSystems?.first { - particleSystem.particleIntensity = 1.0 - particleSystem.particleIntensityVariation = 0.05 - particleSystem.particleColor = particleColor - particleSystem.particleColorVariation = SCNVector4Make(0.07, 0.0, 0.1, 0.0) - node.isHidden = false - - if let propertyControllers = particleSystem.propertyControllers, let sizeController = propertyControllers[.size], let colorController = propertyControllers[.color] { - let animation = CAKeyframeAnimation() - if let existing = colorController.animation as? CAKeyframeAnimation { - animation.keyTimes = existing.keyTimes - animation.values = existing.values?.compactMap { ($0 as? UIColor)?.alpha } ?? [] - } else { - animation.values = [ 0.0, 1.0, 1.0, 0.0 ] - } - let opacityController = SCNParticlePropertyController(animation: animation) - particleSystem.propertyControllers = [ - .size: sizeController, - .opacity: opacityController - ] - } - } - } - } - - for name in names { + let starNames: [String] = [ + "coins_left", + "coins_right" + ] + + if let particleColor = component.particleColor { + for name in starNames { if let node = scene.rootNode.childNode(withName: name, recursively: false), let particleSystem = node.particleSystems?.first { - if let particleColor = component.particleColor { - particleSystem.particleIntensity = min(1.0, 2.0 * particleSystem.particleIntensity) - particleSystem.particleIntensityVariation = 0.05 - particleSystem.particleColor = particleColor - particleSystem.particleColorVariation = SCNVector4Make(0.1, 0.0, 0.12, 0.0) - } else { - particleSystem.particleColorVariation = SCNVector4Make(0.12, 0.03, 0.035, 0.0) + if animated { + particleSystem.warmupDuration = 0.0 } - + particleSystem.particleIntensity = 1.0 + particleSystem.particleIntensityVariation = 0.05 + particleSystem.particleColor = particleColor + particleSystem.particleColorVariation = SCNVector4Make(0.07, 0.0, 0.1, 0.0) + node.isHidden = false + if let propertyControllers = particleSystem.propertyControllers, let sizeController = propertyControllers[.size], let colorController = propertyControllers[.color] { let animation = CAKeyframeAnimation() if let existing = colorController.animation as? CAKeyframeAnimation { @@ -364,15 +339,67 @@ public final class PremiumStarComponent: Component { animation.values = [ 0.0, 1.0, 1.0, 0.0 ] } let opacityController = SCNParticlePropertyController(animation: animation) - particleSystem.propertyControllers = [ + particleSystem.propertyControllers = [ .size: sizeController, .opacity: opacityController ] } } } + } else { + if animated { + for name in starNames { + if let node = scene.rootNode.childNode(withName: name, recursively: false) { + node.isHidden = true + } + } + } + } + + for name in names { + if let node = scene.rootNode.childNode(withName: name, recursively: false), let particleSystem = node.particleSystems?.first { + if let particleColor = component.particleColor { + particleSystem.particleIntensity = min(1.0, 2.0 * particleSystem.particleIntensity) + particleSystem.particleIntensityVariation = 0.05 + particleSystem.particleColor = particleColor + particleSystem.particleColorVariation = SCNVector4Make(0.1, 0.0, 0.12, 0.0) + } else { + particleSystem.particleColorVariation = SCNVector4Make(0.12, 0.03, 0.035, 0.0) + if animated { + particleSystem.particleColor = UIColor(rgb: 0xaa69ea) + } + } + + if let propertyControllers = particleSystem.propertyControllers, let sizeController = propertyControllers[.size], let colorController = propertyControllers[.color] { + let animation = CAKeyframeAnimation() + if let existing = colorController.animation as? CAKeyframeAnimation { + animation.keyTimes = existing.keyTimes + animation.values = existing.values?.compactMap { ($0 as? UIColor)?.alpha } ?? [] + } else { + animation.values = [ 0.0, 1.0, 1.0, 0.0 ] + } + let opacityController = SCNParticlePropertyController(animation: animation) + particleSystem.propertyControllers = [ + .size: sizeController, + .opacity: opacityController + ] + } + } + } + } + + private var didSetup = false + private func setup() { + guard !self.didSetup, let scene = loadCompressedScene(name: "star2", version: sceneVersion) else { + return } + self.didSetup = true + self.sceneView.scene = scene + self.sceneView.delegate = self + + self.updateColors() + if self.animateFrom != nil { let _ = self.sceneView.snapshot() } else { @@ -515,10 +542,6 @@ public final class PremiumStarComponent: Component { return } -// if let material = node.geometry?.materials.first { -// material.metalness.intensity = 0.4 -// } - let animation = CABasicAnimation(keyPath: "contentsTransform") animation.fillMode = .forwards animation.fromValue = NSValue(scnMatrix4: initial) @@ -536,7 +559,13 @@ public final class PremiumStarComponent: Component { node.geometry?.materials.first?.emission.addAnimation(group, forKey: "shimmer") } - private func playAppearanceAnimation(velocity: CGFloat? = nil, smallAngle: Bool = false, mirror: Bool = false, explode: Bool = false) { + private func playAppearanceAnimation( + velocity: CGFloat? = nil, + smallAngle: Bool = false, + mirror: Bool = false, + explode: Bool = false, + force: Bool = false + ) { guard let scene = self.sceneView.scene, let node = scene.rootNode.childNode(withName: "star", recursively: false) else { return } @@ -621,7 +650,7 @@ public final class PremiumStarComponent: Component { } var from = node.presentation.eulerAngles - if abs(from.y - .pi * 2.0) < 0.001 { + if abs(from.y) - .pi * 2.0 < 0.05 { from.y = 0.0 } node.removeAnimation(forKey: "tapRotate") @@ -633,6 +662,7 @@ public final class PremiumStarComponent: Component { if mirror { toValue *= -1 } + let to = SCNVector3(x: 0.0, y: toValue, z: 0.0) let distance = rad2deg(to.y - from.y) @@ -657,12 +687,20 @@ public final class PremiumStarComponent: Component { } func update(component: PremiumStarComponent, availableSize: CGSize, transition: ComponentTransition) -> CGSize { + let previousComponent = self.component self.component = component self.setup() - if let _ = component.particleColor { - self.sceneView.backgroundColor = component.theme.list.blocksBackgroundColor + if let previousComponent, component.colors != previousComponent.colors { + self.updateColors(animated: true) + self.playAppearanceAnimation(velocity: nil, mirror: component.colors?.contains(UIColor(rgb: 0xe57d02)) == true, explode: true, force: true) + } + + if let backgroundColor = component.backgroundColor { + self.sceneView.backgroundColor = backgroundColor + } else { + self.sceneView.backgroundColor = .clear } self.sceneView.bounds = CGRect(origin: .zero, size: CGSize(width: availableSize.width * 2.0, height: availableSize.height * 2.0)) diff --git a/submodules/TelegramUI/Components/Settings/AutomaticBusinessMessageSetupScreen/Sources/AutomaticBusinessMessageSetupScreen.swift b/submodules/TelegramUI/Components/Settings/AutomaticBusinessMessageSetupScreen/Sources/AutomaticBusinessMessageSetupScreen.swift index 289234bb625..10f82b47bd2 100644 --- a/submodules/TelegramUI/Components/Settings/AutomaticBusinessMessageSetupScreen/Sources/AutomaticBusinessMessageSetupScreen.swift +++ b/submodules/TelegramUI/Components/Settings/AutomaticBusinessMessageSetupScreen/Sources/AutomaticBusinessMessageSetupScreen.swift @@ -1357,7 +1357,7 @@ final class AutomaticBusinessMessageSetupScreenComponent: Component { sideInset: 0.0, title: peer.peer.displayTitle(strings: environment.strings, displayOrder: .firstLast), peer: peer.peer, - subtitle: peer.isContact ? environment.strings.ChatList_PeerTypeContact : environment.strings.ChatList_PeerTypeNonContactUser, + subtitle: PeerListItemComponent.Subtitle(text: peer.isContact ? environment.strings.ChatList_PeerTypeContact : environment.strings.ChatList_PeerTypeNonContactUser, color: .neutral), subtitleAccessory: .none, presence: nil, selectionState: .none, diff --git a/submodules/TelegramUI/Components/Settings/ChatbotSetupScreen/Sources/BusinessRecipientListScreen.swift b/submodules/TelegramUI/Components/Settings/ChatbotSetupScreen/Sources/BusinessRecipientListScreen.swift index 4ae6b6e02a2..41aa2a03215 100644 --- a/submodules/TelegramUI/Components/Settings/ChatbotSetupScreen/Sources/BusinessRecipientListScreen.swift +++ b/submodules/TelegramUI/Components/Settings/ChatbotSetupScreen/Sources/BusinessRecipientListScreen.swift @@ -507,7 +507,7 @@ final class BusinessRecipientListScreenComponent: Component { sideInset: 0.0, title: peer.peer.displayTitle(strings: environment.strings, displayOrder: .firstLast), peer: peer.peer, - subtitle: peer.isContact ? environment.strings.ChatList_PeerTypeContact : environment.strings.ChatList_PeerTypeNonContactUser, + subtitle: PeerListItemComponent.Subtitle(text: peer.isContact ? environment.strings.ChatList_PeerTypeContact : environment.strings.ChatList_PeerTypeNonContactUser, color: .neutral), subtitleAccessory: .none, presence: nil, selectionState: .none, diff --git a/submodules/TelegramUI/Components/Settings/WallpaperGridScreen/Sources/ThemeColorsGridController.swift b/submodules/TelegramUI/Components/Settings/WallpaperGridScreen/Sources/ThemeColorsGridController.swift index bc1c23f0ae8..77ed39bb0e4 100644 --- a/submodules/TelegramUI/Components/Settings/WallpaperGridScreen/Sources/ThemeColorsGridController.swift +++ b/submodules/TelegramUI/Components/Settings/WallpaperGridScreen/Sources/ThemeColorsGridController.swift @@ -194,7 +194,7 @@ public final class ThemeColorsGridController: ViewController, AttachmentContaina self?.push(controller) } - self.mainButtonState = AttachmentMainButtonState(text: self.presentationData.strings.Conversation_Theme_SetPhotoWallpaper, font: .regular, background: .color(.clear), textColor: self.presentationData.theme.actionSheet.controlAccentColor, isVisible: true, progress: .none, isEnabled: true) + self.mainButtonState = AttachmentMainButtonState(text: self.presentationData.strings.Conversation_Theme_SetPhotoWallpaper, font: .regular, background: .color(.clear), textColor: self.presentationData.theme.actionSheet.controlAccentColor, isVisible: true, progress: .none, isEnabled: true, hasShimmer: false) } required public init(coder aDecoder: NSCoder) { diff --git a/submodules/TelegramUI/Components/ShareWithPeersScreen/Sources/ShareWithPeersScreen.swift b/submodules/TelegramUI/Components/ShareWithPeersScreen/Sources/ShareWithPeersScreen.swift index e575681b3e7..30e6265ef68 100644 --- a/submodules/TelegramUI/Components/ShareWithPeersScreen/Sources/ShareWithPeersScreen.swift +++ b/submodules/TelegramUI/Components/ShareWithPeersScreen/Sources/ShareWithPeersScreen.swift @@ -1129,7 +1129,7 @@ final class ShareWithPeersScreenComponent: Component { sideInset: itemLayout.sideInset, title: peer.displayTitle(strings: environment.strings, displayOrder: .firstLast), peer: peer, - subtitle: subtitle, + subtitle: subtitle.flatMap { PeerListItemComponent.Subtitle(text: $0, color: .neutral) }, subtitleAccessory: .none, presence: nil, rightAccessory: accessory, @@ -1465,7 +1465,7 @@ final class ShareWithPeersScreenComponent: Component { sideInset: itemLayout.sideInset, title: peer.displayTitle(strings: environment.strings, displayOrder: .firstLast), peer: peer, - subtitle: subtitle, + subtitle: subtitle.flatMap { PeerListItemComponent.Subtitle(text: $0, color: .neutral) }, subtitleAccessory: .none, presence: stateValue.presences[peer.id], selectionState: .editing(isSelected: isSelected, isTinted: false), @@ -2346,7 +2346,7 @@ final class ShareWithPeersScreenComponent: Component { sideInset: sideInset, title: "Name", peer: nil, - subtitle: isContactsSearch ? "" : "sub", + subtitle: PeerListItemComponent.Subtitle(text: isContactsSearch ? "" : "sub", color: .neutral), subtitleAccessory: .none, presence: nil, selectionState: .editing(isSelected: false, isTinted: false), diff --git a/submodules/TelegramUI/Components/SliderComponent/Sources/SliderComponent.swift b/submodules/TelegramUI/Components/SliderComponent/Sources/SliderComponent.swift index 5c526ae4b7f..e6b47565176 100644 --- a/submodules/TelegramUI/Components/SliderComponent/Sources/SliderComponent.swift +++ b/submodules/TelegramUI/Components/SliderComponent/Sources/SliderComponent.swift @@ -70,6 +70,10 @@ public final class SliderComponent: Component { private var component: SliderComponent? private weak var state: EmptyComponentState? + public var hitTestTarget: UIView? { + return self.sliderView + } + override public init(frame: CGRect) { super.init(frame: frame) } diff --git a/submodules/TelegramUI/Components/Stars/StarsImageComponent/Sources/StarsImageComponent.swift b/submodules/TelegramUI/Components/Stars/StarsImageComponent/Sources/StarsImageComponent.swift index 72e92b950f6..09afb0a4e34 100644 --- a/submodules/TelegramUI/Components/Stars/StarsImageComponent/Sources/StarsImageComponent.swift +++ b/submodules/TelegramUI/Components/Stars/StarsImageComponent/Sources/StarsImageComponent.swift @@ -707,7 +707,11 @@ public final class StarsImageComponent: Component { } else { avatarNode = ImageNode() avatarNode.displaysAsynchronously = false - containerNode.view.addSubview(avatarNode.view) + if let smallIconOutlineView = self.smallIconOutlineView { + containerNode.view.insertSubview(avatarNode.view, belowSubview: smallIconOutlineView) + } else { + containerNode.view.addSubview(avatarNode.view) + } self.avatarNode = avatarNode avatarNode.setSignal(peerAvatarCompleteImage(account: component.context.account, peer: peer, size: imageSize, font: avatarPlaceholderFont(size: 43.0), fullSize: true)) diff --git a/submodules/TelegramUI/Components/Stars/StarsPurchaseScreen/Sources/StarsPurchaseScreen.swift b/submodules/TelegramUI/Components/Stars/StarsPurchaseScreen/Sources/StarsPurchaseScreen.swift index 7554de748b6..54935104050 100644 --- a/submodules/TelegramUI/Components/Stars/StarsPurchaseScreen/Sources/StarsPurchaseScreen.swift +++ b/submodules/TelegramUI/Components/Stars/StarsPurchaseScreen/Sources/StarsPurchaseScreen.swift @@ -230,6 +230,8 @@ private final class StarsPurchaseScreenContentComponent: CombinedComponent { textString = strings.Stars_Purchase_GiftInfo(component.peers.first?.value.compactDisplayTitle ?? "").string case .transfer: textString = strings.Stars_Purchase_StarsNeededInfo(component.peers.first?.value.compactDisplayTitle ?? "").string + case .reactions: + textString = strings.Stars_Purchase_StarsReactionsNeededInfo(component.peers.first?.value.compactDisplayTitle ?? "").string case let .subscription(_, _, renew): textString = renew ? strings.Stars_Purchase_SubscriptionRenewInfo(component.peers.first?.value.compactDisplayTitle ?? "").string : strings.Stars_Purchase_SubscriptionInfo(component.peers.first?.value.compactDisplayTitle ?? "").string case .unlockMedia: @@ -282,28 +284,6 @@ private final class StarsPurchaseScreenContentComponent: CombinedComponent { size.height += 21.0 context.component.externalState.descriptionHeight = text.size.height - - let stars: [Int64: Int] = [ - 15: 1, - 75: 2, - 250: 3, - 500: 4, - 1000: 5, - 2500: 6, - - 25: 1, - 50: 1, - 100: 2, - 150: 2, - 350: 3, - 750: 4, - 1500: 5, - - 5000: 6, - 10000: 6, - 25000: 7, - 35000: 7 - ] let externalStateUpdated = context.component.stateUpdated @@ -367,7 +347,7 @@ private final class StarsPurchaseScreenContentComponent: CombinedComponent { title: titleComponent, contentInsets: UIEdgeInsets(top: 12.0, left: -6.0, bottom: 12.0, right: 0.0), leftIcon: .custom(AnyComponentWithIdentity(id: 0, component: AnyComponent(StarsIconComponent( - count: stars[product.count] ?? 1 + amount: product.count ))), true), accessory: .custom(ListActionItemComponent.CustomAccessory(component: AnyComponentWithIdentity(id: 0, component: AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString( @@ -806,7 +786,8 @@ private final class StarsPurchaseScreenComponent: CombinedComponent { UIColor(rgb: 0xf9b004), UIColor(rgb: 0xfdd219) ], - particleColor: UIColor(rgb: 0xf9b004) + particleColor: UIColor(rgb: 0xf9b004), + backgroundColor: environment.theme.list.blocksBackgroundColor ), availableSize: CGSize(width: min(414.0, context.availableSize.width), height: 220.0), transition: context.transition @@ -837,7 +818,7 @@ private final class StarsPurchaseScreenComponent: CombinedComponent { titleText = strings.Stars_Purchase_StarsNeeded(Int32(requiredStars)) case .gift: titleText = strings.Stars_Purchase_GiftStars - case let .transfer(_, requiredStars), let .subscription(_, requiredStars, _), let .unlockMedia(requiredStars): + case let .transfer(_, requiredStars), let .reactions(_, requiredStars), let .subscription(_, requiredStars, _), let .unlockMedia(requiredStars): titleText = strings.Stars_Purchase_StarsNeeded(Int32(requiredStars)) } @@ -1129,7 +1110,30 @@ public final class StarsPurchaseScreen: ViewControllerComponentContainer { } } -func generateStarsIcon(count: Int) -> UIImage { +func generateStarsIcon(amount: Int64) -> UIImage { + let stars: [Int64: Int] = [ + 15: 1, + 75: 2, + 250: 3, + 500: 4, + 1000: 5, + 2500: 6, + + 25: 1, + 50: 1, + 100: 2, + 150: 2, + 350: 3, + 750: 4, + 1500: 5, + + 5000: 6, + 10000: 6, + 25000: 7, + 35000: 7 + ] + let count = stars[amount] ?? 1 + let image = generateGradientTintedImage( image: UIImage(bundleImageName: "Peer Info/PremiumIcon"), colors: [ @@ -1181,16 +1185,16 @@ func generateStarsIcon(count: Int) -> UIImage { } final class StarsIconComponent: CombinedComponent { - let count: Int + let amount: Int64 init( - count: Int + amount: Int64 ) { - self.count = count + self.amount = amount } static func ==(lhs: StarsIconComponent, rhs: StarsIconComponent) -> Bool { - if lhs.count != rhs.count { + if lhs.amount != rhs.amount { return false } return true @@ -1199,11 +1203,11 @@ final class StarsIconComponent: CombinedComponent { static var body: Body { let icon = Child(Image.self) - var image: (UIImage, Int)? + var image: (UIImage, Int64)? return { context in - if image == nil || image?.1 != context.component.count { - image = (generateStarsIcon(count: context.component.count), context.component.count) + if image == nil || image?.1 != context.component.amount { + image = (generateStarsIcon(amount: context.component.amount), context.component.amount) } let iconSize = CGSize(width: image!.0.size.width, height: 20.0) @@ -1230,6 +1234,8 @@ private extension StarsPurchasePurpose { return [peerId] case let .transfer(peerId, _): return [peerId] + case let .reactions(peerId, _): + return [peerId] case let .subscription(peerId, _, _): return [peerId] default: @@ -1243,6 +1249,8 @@ private extension StarsPurchasePurpose { return requiredStars case let .transfer(_, requiredStars): return requiredStars + case let .reactions(_, requiredStars): + return requiredStars case let .subscription(_, requiredStars, _): return requiredStars case let .unlockMedia(requiredStars): diff --git a/submodules/TelegramUI/Components/Stars/StarsTransactionScreen/BUILD b/submodules/TelegramUI/Components/Stars/StarsTransactionScreen/BUILD index 62a8ef68a8f..b814269b7d8 100644 --- a/submodules/TelegramUI/Components/Stars/StarsTransactionScreen/BUILD +++ b/submodules/TelegramUI/Components/Stars/StarsTransactionScreen/BUILD @@ -35,6 +35,7 @@ swift_library( "//submodules/TelegramUI/Components/Stars/StarsAvatarComponent", "//submodules/GalleryUI", "//submodules/TelegramUI/Components/MiniAppListScreen", + "//submodules/TelegramUI/Components/Premium/PremiumStarComponent", ], visibility = [ "//visibility:public", diff --git a/submodules/TelegramUI/Components/Stars/StarsTransactionScreen/Sources/StarsTransactionScreen.swift b/submodules/TelegramUI/Components/Stars/StarsTransactionScreen/Sources/StarsTransactionScreen.swift index 899149a3619..4e8ba3c5663 100644 --- a/submodules/TelegramUI/Components/Stars/StarsTransactionScreen/Sources/StarsTransactionScreen.swift +++ b/submodules/TelegramUI/Components/Stars/StarsTransactionScreen/Sources/StarsTransactionScreen.swift @@ -24,6 +24,7 @@ import StarsImageComponent import GalleryUI import StarsAvatarComponent import MiniAppListScreen +import PremiumStarComponent private final class StarsTransactionSheetContent: CombinedComponent { typealias EnvironmentType = ViewControllerComponentContainer.Environment @@ -97,10 +98,15 @@ private final class StarsTransactionSheetContent: CombinedComponent { peerIds.append(receipt.botPaymentId) case let .gift(message): peerIds.append(message.id.peerId) + if let action = message.media.first(where: { $0 is TelegramMediaAction }) as? TelegramMediaAction, case let .prizeStars(_, _, boostPeerId, _, _) = action.action, let boostPeerId { + peerIds.append(boostPeerId) + } case let .subscription(subscription): peerIds.append(subscription.peer.id) case let .importer(_, _, importer, _): peerIds.append(importer.peer.peerId) + case let .boost(peerId, _): + peerIds.append(peerId) } self.disposable = (context.engine.data.get( @@ -138,6 +144,8 @@ private final class StarsTransactionSheetContent: CombinedComponent { let closeButton = Child(Button.self) let title = Child(MultilineTextComponent.self) let star = Child(StarsImageComponent.self) + let activeStar = Child(PremiumStarComponent.self) + let amountBackground = Child(RoundedRectangle.self) let amount = Child(BalancedTextComponent.self) let amountStar = Child(BundleIconComponent.self) let description = Child(MultilineTextComponent.self) @@ -188,6 +196,7 @@ private final class StarsTransactionSheetContent: CombinedComponent { let titleText: String let amountText: String var descriptionText: String + var boostsText: String? let additionalText = strings.Stars_Transaction_Terms var buttonText: String? = strings.Common_OK var buttonIsDestructive = false @@ -203,6 +212,7 @@ private final class StarsTransactionSheetContent: CombinedComponent { var via: String? var messageId: EngineMessage.Id? var toPeer: EnginePeer? +// var toString: String? var transactionPeer: StarsContext.State.Transaction.Peer? var media: [AnyMediaReference] = [] var photo: TelegramMediaWebFile? @@ -213,9 +223,25 @@ private final class StarsTransactionSheetContent: CombinedComponent { var isSubscriptionFee = false var isCancelled = false var isReaction = false + var giveawayMessageId: MessageId? + var isBoost = false var delayedCloseOnOpenPeer = true switch subject { + case let .boost(peerId, boost): + guard let stars = boost.stars else { + fatalError() + } + let boosts = boost.multiplier + titleText = strings.Stars_Transaction_Giveaway_Boost_Stars(Int32(stars)) + descriptionText = "" + boostsText = strings.Stars_Transaction_Giveaway_Boost_Boosts(boosts) + count = stars + date = boost.date + toPeer = state.peerMap[peerId] +// toString = strings.Stars_Transaction_Giveaway_Boost_Subscribers(boost.quantity) + giveawayMessageId = boost.giveawayMessageId + isBoost = true case let .importer(peer, pricing, importer, usdRate): let usdValue = formatTonUsdValue(pricing.amount, divide: false, rate: usdRate, dateTimeFormat: environment.dateTimeFormat) titleText = strings.Stars_Transaction_Subscription_Title @@ -296,7 +322,18 @@ private final class StarsTransactionSheetContent: CombinedComponent { } } case let .transaction(transaction, parentPeer): - if let _ = transaction.subscriptionPeriod { + if let giveawayMessageIdValue = transaction.giveawayMessageId { + titleText = strings.Stars_Transaction_Giveaway_Title + descriptionText = "" + count = transaction.count + transactionId = transaction.id + date = transaction.date + giveawayMessageId = giveawayMessageIdValue + if case let .peer(peer) = transaction.peer { + toPeer = peer + } + transactionPeer = transaction.peer + } else if let _ = transaction.subscriptionPeriod { titleText = strings.Stars_Transaction_SubscriptionFee descriptionText = "" count = transaction.count @@ -424,25 +461,41 @@ private final class StarsTransactionSheetContent: CombinedComponent { delayedCloseOnOpenPeer = false case let .gift(message): let incoming = message.flags.contains(.Incoming) - titleText = incoming ? strings.Stars_Gift_Received_Title : strings.Stars_Gift_Sent_Title + let peerName = state.peerMap[message.id.peerId]?.compactDisplayTitle ?? "" descriptionText = incoming ? strings.Stars_Gift_Received_Text : strings.Stars_Gift_Sent_Text(peerName).string - if let action = message.media.first(where: { $0 is TelegramMediaAction }) as? TelegramMediaAction, case let .giftStars(_, _, countValue, _, _, _) = action.action { - count = countValue - if !incoming { - countIsGeneric = true + if let action = message.media.first(where: { $0 is TelegramMediaAction }) as? TelegramMediaAction { + if case let .giftStars(_, _, countValue, _, _, _) = action.action { + titleText = incoming ? strings.Stars_Gift_Received_Title : strings.Stars_Gift_Sent_Title + + count = countValue + if !incoming { + countIsGeneric = true + } + countOnTop = true + transactionId = nil + if message.id.peerId.id._internalGetInt64Value() == 777000 { + toPeer = nil + } else { + toPeer = state.peerMap[message.id.peerId] + } + } else if case let .prizeStars(countValue, _, boostPeerId, _, giveawayMessageIdValue) = action.action { + titleText = strings.Stars_Transaction_Giveaway_Title + + count = countValue + countOnTop = true + transactionId = nil + giveawayMessageId = giveawayMessageIdValue + if let boostPeerId { + toPeer = state.peerMap[boostPeerId] + } + } else { + fatalError() } - countOnTop = true - transactionId = nil } else { fatalError() } date = message.timestamp - if message.id.peerId.id._internalGetInt64Value() == 777000 { - toPeer = nil - } else { - toPeer = state.peerMap[message.id.peerId] - } isGift = true delayedCloseOnOpenPeer = false } @@ -462,7 +515,14 @@ private final class StarsTransactionSheetContent: CombinedComponent { let formattedAmount = presentationStringsFormattedNumber(abs(Int32(count)), dateTimeFormat.groupingSeparator) let countColor: UIColor - if isSubscription || isSubscriber { + var countFont: UIFont = isSubscription || isSubscriber ? Font.regular(17.0) : Font.semibold(17.0) + var countBackgroundColor: UIColor? + if let boostsText { + amountText = boostsText + countColor = .white + countBackgroundColor = UIColor(rgb: 0x9671ff) + countFont = Font.with(size: 14.0, design: .round, weight: .semibold) + } else if isSubscription || isSubscriber { amountText = strings.Stars_Transaction_Subscription_PerMonth(formattedAmount).string countColor = theme.list.itemSecondaryTextColor } else if countIsGeneric { @@ -506,28 +566,50 @@ private final class StarsTransactionSheetContent: CombinedComponent { } else { imageSubject = .none } - if isSubscription || isSubscriber || isSubscriptionFee { + if isSubscription || isSubscriber || isSubscriptionFee || giveawayMessageId != nil { imageIcon = .star } else { imageIcon = nil } - let star = star.update( - component: StarsImageComponent( - context: component.context, - subject: imageSubject, - theme: theme, - diameter: 90.0, - backgroundColor: theme.actionSheet.opaqueItemBackgroundColor, - icon: imageIcon, - action: !media.isEmpty ? { transitionNode, addToTransitionSurface in - component.openMedia(media.map { $0.media }, transitionNode, addToTransitionSurface) - } : nil - ), - availableSize: CGSize(width: context.availableSize.width, height: 200.0), - transition: .immediate - ) - - let amountAttributedText = NSMutableAttributedString(string: amountText, font: isSubscription || isSubscriber ? Font.regular(17.0) : Font.semibold(17.0), textColor: countColor) + var starChild: _UpdatedChildComponent + if isBoost { + starChild = activeStar.update( + component: PremiumStarComponent( + theme: theme, + isIntro: false, + isVisible: true, + hasIdleAnimations: true, + colors: [ + UIColor(rgb: 0xe57d02), + UIColor(rgb: 0xf09903), + UIColor(rgb: 0xf9b004), + UIColor(rgb: 0xfdd219) + ], + particleColor: UIColor(rgb: 0xf9b004), + backgroundColor: theme.actionSheet.opaqueItemBackgroundColor + ), + availableSize: CGSize(width: context.availableSize.width, height: 200.0), + transition: .immediate + ) + } else { + starChild = star.update( + component: StarsImageComponent( + context: component.context, + subject: imageSubject, + theme: theme, + diameter: 90.0, + backgroundColor: theme.actionSheet.opaqueItemBackgroundColor, + icon: imageIcon, + action: !media.isEmpty ? { transitionNode, addToTransitionSurface in + component.openMedia(media.map { $0.media }, transitionNode, addToTransitionSurface) + } : nil + ), + availableSize: CGSize(width: context.availableSize.width, height: 200.0), + transition: .immediate + ) + } + + let amountAttributedText = NSMutableAttributedString(string: amountText, font: countFont, textColor: countColor) let amount = amount.update( component: BalancedTextComponent( text: .plain(amountAttributedText), @@ -541,7 +623,7 @@ private final class StarsTransactionSheetContent: CombinedComponent { let amountStar = amountStar.update( component: BundleIconComponent( - name: "Premium/Stars/StarMedium", + name: boostsText != nil ? "Premium/BoostButtonIcon" : "Premium/Stars/StarMedium", tintColor: nil ), availableSize: context.availableSize, @@ -623,6 +705,34 @@ private final class StarsTransactionSheetContent: CombinedComponent { )) } + if let giveawayMessageId { + tableItems.append(.init( + id: "prize", + title: strings.Stars_Transaction_Giveaway_Prize, + component: AnyComponent( + MultilineTextComponent(text: .plain(NSAttributedString(string: strings.Stars_Transaction_Giveaway_Stars(Int32(count)), font: tableFont, textColor: tableTextColor))) + ) + )) + + tableItems.append(.init( + id: "reason", + title: strings.Stars_Transaction_Giveaway_Reason, + component: AnyComponent( + Button( + content: AnyComponent( + MultilineTextComponent(text: .plain(NSAttributedString(string: strings.Stars_Transaction_Giveaway_Giveaway, font: tableFont, textColor: tableLinkColor))) + ), + action: { + component.openMessage(giveawayMessageId) + Queue.mainQueue().after(1.0, { + component.cancel(false) + }) + } + ) + ) + )) + } + if let messageId { let peerName: String if case let .transaction(_, parentPeer) = component.subject { @@ -765,17 +875,17 @@ private final class StarsTransactionSheetContent: CombinedComponent { availableSize: CGSize(width: context.availableSize.width - textSideInset * 2.0, height: context.availableSize.height), transition: .immediate ) + + context.add(starChild + .position(CGPoint(x: context.availableSize.width / 2.0, y: starChild.size.height / 2.0 - 19.0)) + ) context.add(title .position(CGPoint(x: context.availableSize.width / 2.0, y: 31.0 + 125.0)) ) - context.add(star - .position(CGPoint(x: context.availableSize.width / 2.0, y: star.size.height / 2.0 - 19.0)) - ) - var originY: CGFloat = 0.0 - originY += star.size.height - 23.0 + originY += starChild.size.height - 23.0 var descriptionSize: CGSize = .zero if !descriptionText.isEmpty { @@ -825,7 +935,7 @@ private final class StarsTransactionSheetContent: CombinedComponent { originY += description.size.height + 10.0 } - let amountSpacing: CGFloat = 1.0 + let amountSpacing: CGFloat = countBackgroundColor != nil ? 4.0 : 1.0 var totalAmountWidth: CGFloat = amount.size.width + amountSpacing + amountStar.size.width var amountOriginX: CGFloat = floor(context.availableSize.width - totalAmountWidth) / 2.0 if let (statusText, statusColor) = transactionStatus { @@ -869,7 +979,7 @@ private final class StarsTransactionSheetContent: CombinedComponent { let amountLabelOriginX: CGFloat let amountStarOriginX: CGFloat - if isSubscription || isSubscriber { + if isSubscription || isSubscriber || boostsText != nil { amountStarOriginX = amountOriginX + amountStar.size.width / 2.0 amountLabelOriginX = amountOriginX + amountStar.size.width + amountSpacing + amount.size.width / 2.0 } else { @@ -877,11 +987,26 @@ private final class StarsTransactionSheetContent: CombinedComponent { amountStarOriginX = amountOriginX + amount.size.width + amountSpacing + amountStar.size.width / 2.0 } + var amountLabelOffsetY: CGFloat = 0.0 + var amountStarOffsetY: CGFloat = 0.0 + if let countBackgroundColor { + let amountBackground = amountBackground.update( + component: RoundedRectangle(color: countBackgroundColor, cornerRadius: 23 / 2.0), + availableSize: CGSize(width: totalAmountWidth + 14.0, height: 23.0), + transition: .immediate + ) + context.add(amountBackground + .position(CGPoint(x: context.availableSize.width / 2.0, y: amountOrigin + amount.size.height / 2.0 + 1.0)) + ) + amountLabelOffsetY = 2.0 + amountStarOffsetY = 5.0 + } + context.add(amount - .position(CGPoint(x: amountLabelOriginX, y: amountOrigin + amount.size.height / 2.0)) + .position(CGPoint(x: amountLabelOriginX, y: amountOrigin + amount.size.height / 2.0 + amountLabelOffsetY)) ) context.add(amountStar - .position(CGPoint(x: amountStarOriginX, y: amountOrigin + amountStar.size.height / 2.0 - UIScreenPixel)) + .position(CGPoint(x: amountStarOriginX, y: amountOrigin + amountStar.size.height / 2.0 - UIScreenPixel + amountStarOffsetY)) ) context.add(table @@ -1107,6 +1232,7 @@ public class StarsTransactionScreen: ViewControllerComponentContainer { case gift(EngineMessage) case subscription(StarsContext.State.Subscription) case importer(EnginePeer, StarsSubscriptionPricing, PeerInvitationImportersState.Importer, Double) + case boost(EnginePeer.Id, ChannelBoostersContext.State.Boost) } private let context: AccountContext @@ -1158,7 +1284,7 @@ public class StarsTransactionScreen: ViewControllerComponentContainer { theme: forceDark ? .dark : .default ) - self.navigationPresentation = .standaloneFlatModal + self.navigationPresentation = .flatModal self.automaticallyControlPresentationContextLayout = false openPeerImpl = { [weak self] peer in diff --git a/submodules/TelegramUI/Components/Stars/StarsTransactionsScreen/Sources/StarsTransactionsListPanelComponent.swift b/submodules/TelegramUI/Components/Stars/StarsTransactionsScreen/Sources/StarsTransactionsListPanelComponent.swift index 1800b7af0d4..5431bef8d3b 100644 --- a/submodules/TelegramUI/Components/Stars/StarsTransactionsScreen/Sources/StarsTransactionsListPanelComponent.swift +++ b/submodules/TelegramUI/Components/Stars/StarsTransactionsScreen/Sources/StarsTransactionsListPanelComponent.swift @@ -209,7 +209,10 @@ final class StarsTransactionsListPanelComponent: Component { var itemPeer = item.peer switch item.peer { case let .peer(peer): - if !item.media.isEmpty { + if let _ = item.giveawayMessageId { + itemTitle = peer.displayTitle(strings: environment.strings, displayOrder: .firstLast) + itemSubtitle = environment.strings.Stars_Intro_Transaction_GiveawayPrize + } else if !item.media.isEmpty { itemTitle = environment.strings.Stars_Intro_Transaction_MediaPurchase itemSubtitle = peer.displayTitle(strings: environment.strings, displayOrder: .firstLast) } else if let title = item.title { diff --git a/submodules/TelegramUI/Components/Stars/StarsTransactionsScreen/Sources/StarsTransactionsScreen.swift b/submodules/TelegramUI/Components/Stars/StarsTransactionsScreen/Sources/StarsTransactionsScreen.swift index 0b1390aebb8..658dcf5d12c 100644 --- a/submodules/TelegramUI/Components/Stars/StarsTransactionsScreen/Sources/StarsTransactionsScreen.swift +++ b/submodules/TelegramUI/Components/Stars/StarsTransactionsScreen/Sources/StarsTransactionsScreen.swift @@ -424,7 +424,8 @@ final class StarsTransactionsScreenComponent: Component { UIColor(rgb: 0xf9b004), UIColor(rgb: 0xfdd219) ], - particleColor: UIColor(rgb: 0xf9b004) + particleColor: UIColor(rgb: 0xf9b004), + backgroundColor: environment.theme.list.blocksBackgroundColor )), environment: {}, containerSize: CGSize(width: min(414.0, availableSize.width), height: 220.0) diff --git a/submodules/TelegramUI/Components/Stars/StarsTransferScreen/Sources/StarsTransferScreen.swift b/submodules/TelegramUI/Components/Stars/StarsTransferScreen/Sources/StarsTransferScreen.swift index d6384438a6a..62a93d57162 100644 --- a/submodules/TelegramUI/Components/Stars/StarsTransferScreen/Sources/StarsTransferScreen.swift +++ b/submodules/TelegramUI/Components/Stars/StarsTransferScreen/Sources/StarsTransferScreen.swift @@ -505,7 +505,7 @@ private final class SheetContent: CombinedComponent { } if let range = buttonAttributedString.string.range(of: "#"), let starImage = state.cachedStarImage?.0 { buttonAttributedString.addAttribute(.attachment, value: starImage, range: NSRange(range, in: buttonAttributedString.string)) - buttonAttributedString.addAttribute(.foregroundColor, value: UIColor(rgb: 0xffffff), range: NSRange(range, in: buttonAttributedString.string)) + buttonAttributedString.addAttribute(.foregroundColor, value: environment.theme.list.itemCheckColors.foregroundColor, range: NSRange(range, in: buttonAttributedString.string)) buttonAttributedString.addAttribute(.baselineOffset, value: 1.0, range: NSRange(range, in: buttonAttributedString.string)) } @@ -603,7 +603,7 @@ private final class SheetContent: CombinedComponent { }) } ), - availableSize: CGSize(width: 361.0, height: 50), + availableSize: CGSize(width: context.availableSize.width - 16.0 * 2.0, height: 50), transition: .immediate ) context.add(button diff --git a/submodules/TelegramUI/Components/Stars/StarsWithdrawalScreen/Sources/StarsWithdrawalScreen.swift b/submodules/TelegramUI/Components/Stars/StarsWithdrawalScreen/Sources/StarsWithdrawalScreen.swift index 7a03e2b91c6..fcf4852d387 100644 --- a/submodules/TelegramUI/Components/Stars/StarsWithdrawalScreen/Sources/StarsWithdrawalScreen.swift +++ b/submodules/TelegramUI/Components/Stars/StarsWithdrawalScreen/Sources/StarsWithdrawalScreen.swift @@ -110,6 +110,7 @@ private final class SheetContent: CombinedComponent { let titleString: String let amountTitle: String let amountPlaceholder: String + let amountLabel: String? let minAmount: Int64? let maxAmount: Int64? @@ -124,6 +125,7 @@ private final class SheetContent: CombinedComponent { minAmount = configuration.minWithdrawAmount maxAmount = status.balances.availableBalance + amountLabel = nil case .paidMedia: titleString = environment.strings.Stars_PaidContent_Title amountTitle = environment.strings.Stars_PaidContent_AmountTitle @@ -131,14 +133,22 @@ private final class SheetContent: CombinedComponent { minAmount = 1 maxAmount = configuration.maxPaidMediaAmount + + var usdRate = 0.012 + if let usdWithdrawRate = configuration.usdWithdrawRate, let amount = state.amount, amount > 0 { + usdRate = Double(usdWithdrawRate) / 1000.0 / 100.0 + amountLabel = "≈\(formatTonUsdValue(amount, divide: false, rate: usdRate, dateTimeFormat: environment.dateTimeFormat))" + } else { + amountLabel = nil + } case .reaction: titleString = environment.strings.Stars_SendStars_Title amountTitle = environment.strings.Stars_SendStars_AmountTitle amountPlaceholder = environment.strings.Stars_SendStars_AmountPlaceholder minAmount = 1 - //TODO: maxAmount = configuration.maxPaidMediaAmount + amountLabel = nil } let title = title.update( @@ -264,11 +274,13 @@ private final class SheetContent: CombinedComponent { component: AnyComponent( AmountFieldComponent( textColor: theme.list.itemPrimaryTextColor, + secondaryColor: theme.list.itemSecondaryTextColor, placeholderColor: theme.list.itemPlaceholderTextColor, value: state.amount, minValue: minAmount, maxValue: maxAmount, placeholderText: amountPlaceholder, + labelText: amountLabel, amountUpdated: { [weak state] amount in state?.amount = amount state?.updated() @@ -336,7 +348,7 @@ private final class SheetContent: CombinedComponent { } } ), - availableSize: CGSize(width: 361.0, height: 50), + availableSize: CGSize(width: context.availableSize.width - sideInset * 2.0, height: 50), transition: .immediate ) context.add(button @@ -576,30 +588,36 @@ private final class AmountFieldComponent: Component { typealias EnvironmentType = Empty let textColor: UIColor + let secondaryColor: UIColor let placeholderColor: UIColor let value: Int64? let minValue: Int64? let maxValue: Int64? let placeholderText: String + let labelText: String? let amountUpdated: (Int64?) -> Void let tag: AnyObject? init( textColor: UIColor, + secondaryColor: UIColor, placeholderColor: UIColor, value: Int64?, minValue: Int64?, maxValue: Int64?, placeholderText: String, + labelText: String?, amountUpdated: @escaping (Int64?) -> Void, tag: AnyObject? = nil ) { self.textColor = textColor + self.secondaryColor = secondaryColor self.placeholderColor = placeholderColor self.value = value self.minValue = minValue self.maxValue = maxValue self.placeholderText = placeholderText + self.labelText = labelText self.amountUpdated = amountUpdated self.tag = tag } @@ -608,6 +626,9 @@ private final class AmountFieldComponent: Component { if lhs.textColor != rhs.textColor { return false } + if lhs.secondaryColor != rhs.secondaryColor { + return false + } if lhs.placeholderColor != rhs.placeholderColor { return false } @@ -623,6 +644,9 @@ private final class AmountFieldComponent: Component { if lhs.placeholderText != rhs.placeholderText { return false } + if lhs.labelText != rhs.labelText { + return false + } return true } @@ -640,6 +664,7 @@ private final class AmountFieldComponent: Component { private let placeholderView: ComponentView private let iconView: UIImageView private let textField: TextFieldNodeView + private let labelView: ComponentView private var component: AmountFieldComponent? private weak var state: EmptyComponentState? @@ -647,6 +672,7 @@ private final class AmountFieldComponent: Component { override init(frame: CGRect) { self.placeholderView = ComponentView() self.textField = TextFieldNodeView(frame: .zero) + self.labelView = ComponentView() self.iconView = UIImageView(image: UIImage(bundleImageName: "Premium/Stars/StarLarge")) @@ -740,6 +766,7 @@ private final class AmountFieldComponent: Component { let size = CGSize(width: availableSize.width, height: 44.0) + let sideInset: CGFloat = 15.0 var leftInset: CGFloat = 15.0 if let icon = self.iconView.image { leftInset += icon.size.width + 6.0 @@ -765,10 +792,34 @@ private final class AmountFieldComponent: Component { } placeholderComponentView.frame = CGRect(origin: CGPoint(x: leftInset, y: floorToScreenPixels((size.height - placeholderSize.height) / 2.0) + 1.0 - UIScreenPixel), size: placeholderSize) - placeholderComponentView.isHidden = !(self.textField.text ?? "").isEmpty } + if let labelText = component.labelText { + let labelSize = self.labelView.update( + transition: .immediate, + component: AnyComponent( + Text( + text: labelText, + font: Font.regular(17.0), + color: component.secondaryColor + ) + ), + environment: {}, + containerSize: availableSize + ) + + if let labelView = self.labelView.view { + if labelView.superview == nil { + self.insertSubview(labelView, at: 0) + } + + labelView.frame = CGRect(origin: CGPoint(x: size.width - sideInset - labelSize.width, y: floorToScreenPixels((size.height - labelSize.height) / 2.0) + 1.0 - UIScreenPixel), size: labelSize) + } + } else if let labelView = self.labelView.view, labelView.superview != nil { + labelView.removeFromSuperview() + } + self.textField.frame = CGRect(x: leftInset, y: 0.0, width: size.width - 30.0, height: 44.0) return size @@ -808,15 +859,17 @@ func generateCloseButtonImage(backgroundColor: UIColor, foregroundColor: UIColor private struct StarsWithdrawConfiguration { static var defaultValue: StarsWithdrawConfiguration { - return StarsWithdrawConfiguration(minWithdrawAmount: nil, maxPaidMediaAmount: nil) + return StarsWithdrawConfiguration(minWithdrawAmount: nil, maxPaidMediaAmount: nil, usdWithdrawRate: nil) } let minWithdrawAmount: Int64? let maxPaidMediaAmount: Int64? + let usdWithdrawRate: Double? - fileprivate init(minWithdrawAmount: Int64?, maxPaidMediaAmount: Int64?) { + fileprivate init(minWithdrawAmount: Int64?, maxPaidMediaAmount: Int64?, usdWithdrawRate: Double?) { self.minWithdrawAmount = minWithdrawAmount self.maxPaidMediaAmount = maxPaidMediaAmount + self.usdWithdrawRate = usdWithdrawRate } static func with(appConfiguration: AppConfiguration) -> StarsWithdrawConfiguration { @@ -829,8 +882,12 @@ private struct StarsWithdrawConfiguration { if let value = data["stars_paid_post_amount_max"] as? Double { maxPaidMediaAmount = Int64(value) } + var usdWithdrawRate: Double? + if let value = data["stars_usd_withdraw_rate_x1000"] as? Double { + usdWithdrawRate = value + } - return StarsWithdrawConfiguration(minWithdrawAmount: minWithdrawAmount, maxPaidMediaAmount: maxPaidMediaAmount) + return StarsWithdrawConfiguration(minWithdrawAmount: minWithdrawAmount, maxPaidMediaAmount: maxPaidMediaAmount, usdWithdrawRate: usdWithdrawRate) } else { return .defaultValue } diff --git a/submodules/TelegramUI/Components/Stories/PeerListItemComponent/Sources/PeerListItemComponent.swift b/submodules/TelegramUI/Components/Stories/PeerListItemComponent/Sources/PeerListItemComponent.swift index fa948e4cedd..349aa6da229 100644 --- a/submodules/TelegramUI/Components/Stories/PeerListItemComponent/Sources/PeerListItemComponent.swift +++ b/submodules/TelegramUI/Components/Stories/PeerListItemComponent/Sources/PeerListItemComponent.swift @@ -164,6 +164,45 @@ public final class PeerListItemComponent: Component { } } + public struct Subtitle: Equatable { + public enum Color: Equatable { + case neutral + case accent + case constructive + } + + public var text: String + public var color: Color + + public init(text: String, color: Color) { + self.text = text + self.color = color + } + } + + public final class ExtractedTheme: Equatable { + public let inset: CGFloat + public let background: UIColor + + public init(inset: CGFloat, background: UIColor) { + self.inset = inset + self.background = background + } + + public static func ==(lhs: ExtractedTheme, rhs: ExtractedTheme) -> Bool { + if lhs === rhs { + return true + } + if lhs.inset != rhs.inset { + return false + } + if lhs.background != rhs.background { + return false + } + return true + } + } + let context: AccountContext let theme: PresentationTheme let strings: PresentationStrings @@ -171,12 +210,14 @@ public final class PeerListItemComponent: Component { let sideInset: CGFloat let title: String let avatar: Avatar? + let avatarComponent: AnyComponent? let peer: EnginePeer? let storyStats: PeerStoryStats? - let subtitle: String? + let subtitle: Subtitle? let subtitleAccessory: SubtitleAccessory let presence: EnginePeer.Presence? let rightAccessory: RightAccessory + let rightAccessoryComponent: AnyComponent? let reaction: Reaction? let story: EngineStoryItem? let message: EngineMessage? @@ -184,7 +225,8 @@ public final class PeerListItemComponent: Component { let selectionPosition: SelectionPosition let isEnabled: Bool let hasNext: Bool - let action: (EnginePeer, EngineMessage.Id?, UIView?) -> Void + let extractedTheme: ExtractedTheme? + let action: (EnginePeer, EngineMessage.Id?, PeerListItemComponent.View) -> Void let inlineActions: InlineActionsState? let contextAction: ((EnginePeer, ContextExtractedContentContainingView, ContextGesture) -> Void)? let openStories: ((EnginePeer, AvatarNode) -> Void)? @@ -197,12 +239,14 @@ public final class PeerListItemComponent: Component { sideInset: CGFloat, title: String, avatar: Avatar? = nil, + avatarComponent: AnyComponent? = nil, peer: EnginePeer?, storyStats: PeerStoryStats? = nil, - subtitle: String?, + subtitle: Subtitle?, subtitleAccessory: SubtitleAccessory, presence: EnginePeer.Presence?, rightAccessory: RightAccessory = .none, + rightAccessoryComponent: AnyComponent? = nil, reaction: Reaction? = nil, story: EngineStoryItem? = nil, message: EngineMessage? = nil, @@ -210,7 +254,8 @@ public final class PeerListItemComponent: Component { selectionPosition: SelectionPosition = .left, isEnabled: Bool = true, hasNext: Bool, - action: @escaping (EnginePeer, EngineMessage.Id?, UIView?) -> Void, + extractedTheme: ExtractedTheme? = nil, + action: @escaping (EnginePeer, EngineMessage.Id?, PeerListItemComponent.View) -> Void, inlineActions: InlineActionsState? = nil, contextAction: ((EnginePeer, ContextExtractedContentContainingView, ContextGesture) -> Void)? = nil, openStories: ((EnginePeer, AvatarNode) -> Void)? = nil @@ -222,12 +267,14 @@ public final class PeerListItemComponent: Component { self.sideInset = sideInset self.title = title self.avatar = avatar + self.avatarComponent = avatarComponent self.peer = peer self.storyStats = storyStats self.subtitle = subtitle self.subtitleAccessory = subtitleAccessory self.presence = presence self.rightAccessory = rightAccessory + self.rightAccessoryComponent = rightAccessoryComponent self.reaction = reaction self.story = story self.message = message @@ -235,6 +282,7 @@ public final class PeerListItemComponent: Component { self.selectionPosition = selectionPosition self.isEnabled = isEnabled self.hasNext = hasNext + self.extractedTheme = extractedTheme self.action = action self.inlineActions = inlineActions self.contextAction = contextAction @@ -263,6 +311,9 @@ public final class PeerListItemComponent: Component { if lhs.avatar != rhs.avatar { return false } + if lhs.avatarComponent != rhs.avatarComponent { + return false + } if lhs.peer != rhs.peer { return false } @@ -281,6 +332,9 @@ public final class PeerListItemComponent: Component { if lhs.rightAccessory != rhs.rightAccessory { return false } + if lhs.rightAccessoryComponent != rhs.rightAccessoryComponent { + return false + } if lhs.reaction != rhs.reaction { return false } @@ -309,21 +363,24 @@ public final class PeerListItemComponent: Component { } public final class View: ContextControllerSourceView, ListSectionComponent.ChildView { - private let extractedContainerView: ContextExtractedContentContainingView + public let extractedContainerView: ContextExtractedContentContainingView private let containerButton: HighlightTrackingButton private let swipeOptionContainer: ListItemSwipeOptionContainer private let title = ComponentView() - private let label = ComponentView() + private var label = ComponentView() private let separatorLayer: SimpleLayer - private let avatarNode: AvatarNode + private var avatarNode: AvatarNode? private var avatarImageView: UIImageView? private let avatarButtonView: HighlightTrackingButton private var avatarIcon: ComponentView? + private var avatarComponentView: ComponentView? + private var iconView: UIImageView? private var checkLayer: CheckLayer? + private var rightAccessoryComponentView: ComponentView? private var reactionLayer: InlineStickerItemLayer? private var heartReactionIcon: UIImageView? @@ -340,7 +397,13 @@ public final class PeerListItemComponent: Component { private var presenceManager: PeerPresenceStatusManager? public var avatarFrame: CGRect { - return self.avatarNode.frame + if let avatarComponentView = self.avatarComponentView, let avatarComponentViewImpl = avatarComponentView.view { + return avatarComponentViewImpl.frame + } else if let avatarNode = self.avatarNode { + return avatarNode.frame + } else { + return CGRect(origin: CGPoint(), size: CGSize()) + } } public var titleFrame: CGRect? { @@ -373,10 +436,6 @@ public final class PeerListItemComponent: Component { self.swipeOptionContainer = ListItemSwipeOptionContainer(frame: CGRect()) - self.avatarNode = AvatarNode(font: avatarFont) - self.avatarNode.isLayerBacked = false - self.avatarNode.isUserInteractionEnabled = false - self.avatarButtonView = HighlightTrackingButton() super.init(frame: frame) @@ -389,7 +448,6 @@ public final class PeerListItemComponent: Component { self.swipeOptionContainer.addSubview(self.containerButton) self.layer.addSublayer(self.separatorLayer) - self.containerButton.layer.addSublayer(self.avatarNode.layer) self.containerButton.addTarget(self, action: #selector(self.pressed), for: .touchUpInside) @@ -400,8 +458,16 @@ public final class PeerListItemComponent: Component { guard let self, let component = self.component else { return } + + let extractedBackgroundColor: UIColor + if let extractedTheme = component.extractedTheme { + extractedBackgroundColor = extractedTheme.background + } else { + extractedBackgroundColor = component.theme.rootController.navigationBar.blurredBackgroundColor + } + self.containerButton.clipsToBounds = value - self.containerButton.backgroundColor = value ? component.theme.rootController.navigationBar.blurredBackgroundColor : nil + self.containerButton.backgroundColor = value ? extractedBackgroundColor : nil self.containerButton.layer.cornerRadius = value ? 10.0 : 0.0 } self.extractedContainerView.willUpdateIsExtractedToContextPreview = { [weak self] value, transition in @@ -468,14 +534,16 @@ public final class PeerListItemComponent: Component { guard let component = self.component, let peer = component.peer else { return } - component.action(peer, component.message?.id, self.imageNode?.view) + component.action(peer, component.message?.id, self) } @objc private func avatarButtonPressed() { guard let component = self.component, let peer = component.peer else { return } - component.openStories?(peer, self.avatarNode) + if let avatarNode = self.avatarNode { + component.openStories?(peer, avatarNode) + } } private func updateReactionLayer() { @@ -574,18 +642,28 @@ public final class PeerListItemComponent: Component { self.avatarButtonView.isUserInteractionEnabled = component.storyStats != nil && component.openStories != nil - let labelData: (String, Bool) + let labelData: (String, Subtitle.Color) if let presence = component.presence { let timestamp = CFAbsoluteTimeGetCurrent() + NSTimeIntervalSince1970 let dateTimeFormat = component.context.sharedContext.currentPresentationData.with { $0 }.dateTimeFormat - labelData = stringAndActivityForUserPresence(strings: component.strings, dateTimeFormat: dateTimeFormat, presence: presence, relativeTo: Int32(timestamp)) + let labelDataValue = stringAndActivityForUserPresence(strings: component.strings, dateTimeFormat: dateTimeFormat, presence: presence, relativeTo: Int32(timestamp)) + labelData = (labelDataValue.0, labelDataValue.1 ? .accent : .neutral) } else if let subtitle = component.subtitle { - labelData = (subtitle, false) + labelData = (subtitle.text, subtitle.color) } else { - labelData = ("", false) + labelData = ("", .neutral) } - let contextInset: CGFloat = self.isExtractedToContextMenu ? 12.0 : 0.0 + let contextInset: CGFloat + if self.isExtractedToContextMenu { + if let extractedTheme = component.extractedTheme { + contextInset = extractedTheme.inset + } else { + contextInset = 12.0 + } + } else { + contextInset = 0.0 + } let height: CGFloat let titleFont: UIFont @@ -618,6 +696,31 @@ public final class PeerListItemComponent: Component { rightInset += 40.0 } + var rightAccessoryComponentSize: CGSize? + if let rightAccessoryComponent = component.rightAccessoryComponent { + var rightAccessoryComponentTransition = transition + let rightAccessoryComponentView: ComponentView + if let current = self.rightAccessoryComponentView { + rightAccessoryComponentView = current + } else { + rightAccessoryComponentTransition = rightAccessoryComponentTransition.withAnimation(.none) + rightAccessoryComponentView = ComponentView() + self.rightAccessoryComponentView = rightAccessoryComponentView + } + rightAccessoryComponentSize = rightAccessoryComponentView.update( + transition: rightAccessoryComponentTransition, + component: rightAccessoryComponent, + environment: {}, + containerSize: CGSize(width: 100.0, height: 100.0) + ) + } else if let rightAccessoryComponentView = self.rightAccessoryComponentView { + self.rightAccessoryComponentView = nil + rightAccessoryComponentView.view?.removeFromSuperview() + } + if let rightAccessoryComponentSize { + rightInset += 8.0 + rightAccessoryComponentSize.width + } + var avatarLeftInset: CGFloat = component.sideInset + 10.0 if case let .editing(isSelected, isTinted) = component.selectionState { @@ -669,16 +772,117 @@ public final class PeerListItemComponent: Component { let avatarSize: CGFloat = component.style == .compact ? 30.0 : 40.0 let avatarFrame = CGRect(origin: CGPoint(x: avatarLeftInset, y: floorToScreenPixels((height - verticalInset * 2.0 - avatarSize) / 2.0)), size: CGSize(width: avatarSize, height: avatarSize)) - if self.avatarNode.bounds.isEmpty { - self.avatarNode.frame = avatarFrame + + var statusIcon: EmojiStatusComponent.Content? + if let peer = component.peer { + if peer.isScam { + statusIcon = .text(color: component.theme.chat.message.incoming.scamColor, string: component.strings.Message_ScamAccount.uppercased()) + } else if peer.isFake { + statusIcon = .text(color: component.theme.chat.message.incoming.scamColor, string: component.strings.Message_FakeAccount.uppercased()) + } else if let emojiStatus = peer.emojiStatus { + statusIcon = .animation(content: .customEmoji(fileId: emojiStatus.fileId), size: CGSize(width: 20.0, height: 20.0), placeholderColor: component.theme.list.mediaPlaceholderColor, themeColor: component.theme.list.itemAccentColor, loopMode: .count(2)) + } else if peer.isVerified { + statusIcon = .verified(fillColor: component.theme.list.itemCheckColors.fillColor, foregroundColor: component.theme.list.itemCheckColors.foregroundColor, sizeType: .compact) + } else if peer.isPremium { + statusIcon = .premium(color: component.theme.list.itemAccentColor) + } + } + + if let avatarComponent = component.avatarComponent { + let avatarComponentView: ComponentView + var avatarComponentTransition = transition + if let current = self.avatarComponentView { + avatarComponentView = current + } else { + avatarComponentTransition = avatarComponentTransition.withAnimation(.none) + avatarComponentView = ComponentView() + self.avatarComponentView = avatarComponentView + } + + let _ = avatarComponentView.update( + transition: avatarComponentTransition, + component: avatarComponent, + environment: {}, + containerSize: avatarFrame.size + ) + if let avatarComponentViewImpl = avatarComponentView.view { + if avatarComponentViewImpl.superview == nil { + self.containerButton.insertSubview(avatarComponentViewImpl, at: 0) + } + avatarComponentTransition.setFrame(view: avatarComponentViewImpl, frame: avatarFrame) + } + + if let avatarNode = self.avatarNode { + self.avatarNode = nil + avatarNode.layer.removeFromSuperlayer() + } } else { - transition.setFrame(layer: self.avatarNode.layer, frame: avatarFrame) + let avatarNode: AvatarNode + if let current = self.avatarNode { + avatarNode = current + } else { + avatarNode = AvatarNode(font: avatarFont) + avatarNode.isLayerBacked = false + avatarNode.isUserInteractionEnabled = false + self.avatarNode = avatarNode + self.containerButton.layer.insertSublayer(avatarNode.layer, at: 0) + } + + if avatarNode.bounds.isEmpty { + avatarNode.frame = avatarFrame + } else { + transition.setFrame(layer: avatarNode.layer, frame: avatarFrame) + } + + if let peer = component.peer { + let clipStyle: AvatarNodeClipStyle + if case let .channel(channel) = peer, channel.flags.contains(.isForum) { + clipStyle = .roundedRect + } else { + clipStyle = .round + } + let _ = clipStyle + let _ = synchronousLoad + + if peer.smallProfileImage != nil { + avatarNode.setPeerV2( + context: component.context, + theme: component.theme, + peer: peer, + authorOfMessage: nil, + overrideImage: nil, + emptyColor: nil, + clipStyle: .round, + synchronousLoad: synchronousLoad, + displayDimensions: CGSize(width: avatarSize, height: avatarSize) + ) + } else { + avatarNode.setPeer(context: component.context, theme: component.theme, peer: peer, clipStyle: clipStyle, synchronousLoad: synchronousLoad, displayDimensions: CGSize(width: avatarSize, height: avatarSize)) + } + avatarNode.setStoryStats(storyStats: component.storyStats.flatMap { storyStats -> AvatarNode.StoryStats in + return AvatarNode.StoryStats( + totalCount: storyStats.totalCount == 0 ? 0 : 1, + unseenCount: storyStats.unseenCount == 0 ? 0 : 1, + hasUnseenCloseFriendsItems: storyStats.hasUnseenCloseFriends + ) + }, presentationParams: AvatarNode.StoryPresentationParams( + colors: AvatarNode.Colors(theme: component.theme), + lineWidth: 1.33, + inactiveLineWidth: 1.33 + ), transition: transition) + avatarNode.isHidden = false + } else { + avatarNode.isHidden = true + } + + if let avatarComponentView = self.avatarComponentView { + self.avatarComponentView = nil + avatarComponentView.view?.removeFromSuperview() + } } transition.setFrame(view: self.avatarButtonView, frame: avatarFrame) - var statusIcon: EmojiStatusComponent.Content? - if let avatar = component.avatar { let avatarImageView: UIImageView if let current = self.avatarImageView { @@ -698,59 +902,6 @@ public final class PeerListItemComponent: Component { avatarImageView.removeFromSuperview() } } - - if let peer = component.peer { - let clipStyle: AvatarNodeClipStyle - if case let .channel(channel) = peer, channel.flags.contains(.isForum) { - clipStyle = .roundedRect - } else { - clipStyle = .round - } - let _ = clipStyle - let _ = synchronousLoad - - if peer.smallProfileImage != nil { - self.avatarNode.setPeerV2( - context: component.context, - theme: component.theme, - peer: peer, - authorOfMessage: nil, - overrideImage: nil, - emptyColor: nil, - clipStyle: .round, - synchronousLoad: synchronousLoad, - displayDimensions: CGSize(width: avatarSize, height: avatarSize) - ) - } else { - self.avatarNode.setPeer(context: component.context, theme: component.theme, peer: peer, clipStyle: clipStyle, synchronousLoad: synchronousLoad, displayDimensions: CGSize(width: avatarSize, height: avatarSize)) - } - self.avatarNode.setStoryStats(storyStats: component.storyStats.flatMap { storyStats -> AvatarNode.StoryStats in - return AvatarNode.StoryStats( - totalCount: storyStats.totalCount == 0 ? 0 : 1, - unseenCount: storyStats.unseenCount == 0 ? 0 : 1, - hasUnseenCloseFriendsItems: storyStats.hasUnseenCloseFriends - ) - }, presentationParams: AvatarNode.StoryPresentationParams( - colors: AvatarNode.Colors(theme: component.theme), - lineWidth: 1.33, - inactiveLineWidth: 1.33 - ), transition: transition) - self.avatarNode.isHidden = false - - if peer.isScam { - statusIcon = .text(color: component.theme.chat.message.incoming.scamColor, string: component.strings.Message_ScamAccount.uppercased()) - } else if peer.isFake { - statusIcon = .text(color: component.theme.chat.message.incoming.scamColor, string: component.strings.Message_FakeAccount.uppercased()) - } else if let emojiStatus = peer.emojiStatus { - statusIcon = .animation(content: .customEmoji(fileId: emojiStatus.fileId), size: CGSize(width: 20.0, height: 20.0), placeholderColor: component.theme.list.mediaPlaceholderColor, themeColor: component.theme.list.itemAccentColor, loopMode: .count(2)) - } else if peer.isVerified { - statusIcon = .verified(fillColor: component.theme.list.itemCheckColors.fillColor, foregroundColor: component.theme.list.itemCheckColors.foregroundColor, sizeType: .compact) - } else if peer.isPremium { - statusIcon = .premium(color: component.theme.list.itemAccentColor) - } - } else { - self.avatarNode.isHidden = true - } let previousTitleFrame = self.title.view?.frame var previousTitleContents: UIView? @@ -779,10 +930,39 @@ public final class PeerListItemComponent: Component { ) let labelAvailableWidth = component.style == .compact ? availableTextWidth - titleSize.width : availableSize.width - leftInset - rightInset + let labelColor: UIColor + switch labelData.1 { + case .neutral: + labelColor = component.theme.list.itemSecondaryTextColor + case .accent: + labelColor = component.theme.list.itemAccentColor + case .constructive: + //TODO:release + labelColor = UIColor(rgb: 0x33C758) + } + + var animateLabelDirection: Bool? + if !transition.animation.isImmediate, let previousComponent, let previousSubtitle = previousComponent.subtitle, let subtitle = component.subtitle, subtitle.color != previousSubtitle.color { + let animateLabelDirectionValue: Bool + if case .constructive = subtitle.color { + animateLabelDirectionValue = true + } else { + animateLabelDirectionValue = false + } + animateLabelDirection = animateLabelDirectionValue + if let labelView = self.label.view { + transition.setPosition(view: labelView, position: labelView.center.offsetBy(dx: 0.0, dy: animateLabelDirectionValue ? -6.0 : 6.0)) + labelView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false, completion: { [weak labelView] _ in + labelView?.removeFromSuperview() + }) + } + self.label = ComponentView() + } + let labelSize = self.label.update( transition: .immediate, component: AnyComponent(MultilineTextComponent( - text: .plain(NSAttributedString(string: labelData.0, font: subtitleFont, textColor: labelData.1 ? component.theme.list.itemAccentColor : component.theme.list.itemSecondaryTextColor)) + text: .plain(NSAttributedString(string: labelData.0, font: subtitleFont, textColor: labelColor)) )), environment: {}, containerSize: CGSize(width: labelAvailableWidth, height: 100.0) @@ -898,11 +1078,6 @@ public final class PeerListItemComponent: Component { } } - if labelView.superview == nil { - labelView.isUserInteractionEnabled = false - self.containerButton.addSubview(labelView) - } - let labelFrame: CGRect switch component.style { case .generic: @@ -911,7 +1086,25 @@ public final class PeerListItemComponent: Component { labelFrame = CGRect(origin: CGPoint(x: titleFrame.maxX + 4.0, y: floor((height - verticalInset * 2.0 - centralContentHeight) / 2.0)), size: labelSize) } - transition.setFrame(view: labelView, frame: labelFrame) + + if labelView.superview == nil { + labelView.isUserInteractionEnabled = false + labelView.layer.anchorPoint = CGPoint() + self.containerButton.addSubview(labelView) + + labelView.center = labelFrame.origin + } else { + transition.setPosition(view: labelView, position: labelFrame.origin) + } + + labelView.bounds = CGRect(origin: CGPoint(), size: labelFrame.size) + + if let animateLabelDirection { + transition.animatePosition(view: labelView, from: CGPoint(x: 0.0, y: animateLabelDirection ? 6.0 : -6.0), to: CGPoint(), additive: true) + if !transition.animation.isImmediate { + labelView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15) + } + } } let imageSize = CGSize(width: 22.0, height: 22.0) @@ -951,6 +1144,16 @@ public final class PeerListItemComponent: Component { } } + if let rightAccessoryComponentViewImpl = self.rightAccessoryComponentView?.view, let rightAccessoryComponentSize { + var rightAccessoryComponentTransition = transition + if rightAccessoryComponentViewImpl.superview == nil { + rightAccessoryComponentViewImpl.isUserInteractionEnabled = false + rightAccessoryComponentTransition = rightAccessoryComponentTransition.withAnimation(.none) + self.containerButton.addSubview(rightAccessoryComponentViewImpl) + } + rightAccessoryComponentTransition.setFrame(view: rightAccessoryComponentViewImpl, frame: CGRect(origin: CGPoint(x: availableSize.width - (contextInset * 2.0 + component.sideInset) - rightAccessoryComponentSize.width, y: floor((height - verticalInset * 2.0 - rightAccessoryComponentSize.width) / 2.0)), size: rightAccessoryComponentSize)) + } + var reactionIconTransition = transition if previousComponent?.reaction != component.reaction { if let reaction = component.reaction, case .builtin("❤") = reaction.reaction { diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryChatContent.swift b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryChatContent.swift index cb8c039a8e3..f5857b2e405 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryChatContent.swift +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryChatContent.swift @@ -1472,7 +1472,7 @@ public final class PeerStoryListContentContextImpl: StoryContentContext { private var currentPeerData: (EnginePeer.Id, Promise)? - public init(context: AccountContext, listContext: StoryListContext, initialId: Int32?, splitIndexIntoDays: Bool) { + public init(context: AccountContext, listContext: StoryListContext, initialId: StoryId?, splitIndexIntoDays: Bool) { self.context = context let preferHighQualityStories: Signal = combineLatest( @@ -1511,9 +1511,9 @@ public final class PeerStoryListContentContextImpl: StoryContentContext { focusedIndex = nil } } else if let initialId = initialId { - if let index = state.items.firstIndex(where: { $0.storyItem.id == initialId }) { + if let index = state.items.firstIndex(where: { $0.id == initialId }) { focusedIndex = index - } else if let index = state.items.firstIndex(where: { $0.storyItem.id <= initialId }) { + } else if let index = state.items.firstIndex(where: { $0.storyItem.id <= initialId.id }) { focusedIndex = index } else { focusedIndex = nil diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift index 2f213613972..6d68c120bb1 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift @@ -4464,6 +4464,23 @@ public final class StoryItemSetContainerComponent: Component { return EmojiComponentReactionItem(reaction: reaction.reaction.rawValue, file: reaction.stillAnimation) } + var selectedItems: Set = Set() + if let myReaction = component.slice.item.storyItem.myReaction { + switch myReaction { + case .builtin, .stars: + if let availableReactions = component.availableReactions { + for availableReaction in availableReactions.reactionItems { + if availableReaction.reaction.rawValue == myReaction { + selectedItems.insert(AnyHashable(availableReaction.stillAnimation.fileId)) + break + } + } + } + case let .custom(fileId): + selectedItems.insert(AnyHashable(MediaId(namespace: Namespaces.Media.CloudFile, id: fileId))) + } + } + return EmojiPagerContentComponent.emojiInputData( context: component.context, animationCache: animationCache, @@ -4475,7 +4492,7 @@ public final class StoryItemSetContainerComponent: Component { areUnicodeEmojiEnabled: false, areCustomEmojiEnabled: true, chatPeerId: component.context.account.peerId, - selectedItems: Set(), + selectedItems: selectedItems, premiumIfSavedMessages: false ) }, diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetViewListComponent.swift b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetViewListComponent.swift index 843fe4d68ba..ccc85856e55 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetViewListComponent.swift +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetViewListComponent.swift @@ -548,7 +548,7 @@ final class StoryItemSetViewListComponent: Component { title: item.peer.displayTitle(strings: component.strings, displayOrder: .firstLast), peer: item.peer, storyStats: item.storyStats, - subtitle: dateText, + subtitle: PeerListItemComponent.Subtitle(text: dateText, color: .neutral), subtitleAccessory: subtitleAccessory, presence: nil, reaction: item.reaction.flatMap { reaction -> PeerListItemComponent.Reaction in @@ -589,7 +589,7 @@ final class StoryItemSetViewListComponent: Component { message: item.message, selectionState: .none, hasNext: index != viewListState.totalCount - 1 || itemLayout.premiumFooterSize != nil, - action: { [weak self] peer, messageId, sourceView in + action: { [weak self] peer, messageId, itemView in guard let self, let component = self.component else { return } @@ -598,7 +598,7 @@ final class StoryItemSetViewListComponent: Component { } if let messageId { component.openMessage(peer, messageId) - } else if let storyItem, let sourceView { + } else if let storyItem, let sourceView = itemView.imageNode?.view { component.openReposts(peer, storyItem.id, sourceView) } else { component.openPeer(peer) @@ -929,7 +929,7 @@ final class StoryItemSetViewListComponent: Component { sideInset: 0.0, title: "AAAAAAAAAAAA", peer: nil, - subtitle: "BBBBBBB", + subtitle: PeerListItemComponent.Subtitle(text: "BBBBBBB", color: .neutral), subtitleAccessory: .checks, presence: nil, selectionState: .none, diff --git a/submodules/TelegramUI/Components/VideoMessageCameraScreen/Sources/VideoMessageCameraScreen.swift b/submodules/TelegramUI/Components/VideoMessageCameraScreen/Sources/VideoMessageCameraScreen.swift index 39b2d9e9fbe..af68fabbce8 100644 --- a/submodules/TelegramUI/Components/VideoMessageCameraScreen/Sources/VideoMessageCameraScreen.swift +++ b/submodules/TelegramUI/Components/VideoMessageCameraScreen/Sources/VideoMessageCameraScreen.swift @@ -334,11 +334,7 @@ private final class VideoMessageCameraScreenComponent: CombinedComponent { self.updateScreenBrightness(isFlashOn: isFlashOn) if controller.cameraState.position == .back { - if isFlashOn { - camera.setTorchActive(true) - } else { - camera.setTorchActive(false) - } + camera.setTorchActive(isFlashOn) } } diff --git a/submodules/TelegramUI/Images.xcassets/Chat/Context Menu/Boost.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Chat/Context Menu/Boost.imageset/Contents.json index 7781121b216..9401600f2e9 100644 --- a/submodules/TelegramUI/Images.xcassets/Chat/Context Menu/Boost.imageset/Contents.json +++ b/submodules/TelegramUI/Images.xcassets/Chat/Context Menu/Boost.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "boost_24.pdf", + "filename" : "boost_24 (3).pdf", "idiom" : "universal" } ], diff --git a/submodules/TelegramUI/Images.xcassets/Chat/Context Menu/Boost.imageset/boost_24 (3).pdf b/submodules/TelegramUI/Images.xcassets/Chat/Context Menu/Boost.imageset/boost_24 (3).pdf new file mode 100644 index 00000000000..ece191c910a Binary files /dev/null and b/submodules/TelegramUI/Images.xcassets/Chat/Context Menu/Boost.imageset/boost_24 (3).pdf differ diff --git a/submodules/TelegramUI/Images.xcassets/Chat/Context Menu/Boost.imageset/boost_24.pdf b/submodules/TelegramUI/Images.xcassets/Chat/Context Menu/Boost.imageset/boost_24.pdf deleted file mode 100644 index 717e218c2ff..00000000000 Binary files a/submodules/TelegramUI/Images.xcassets/Chat/Context Menu/Boost.imageset/boost_24.pdf and /dev/null differ diff --git a/submodules/TelegramUI/Images.xcassets/Chat/Message/Stars.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Chat/Message/Stars.imageset/Contents.json new file mode 100644 index 00000000000..f72b66eab06 --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Chat/Message/Stars.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "givstars.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/submodules/TelegramUI/Images.xcassets/Chat/Message/Stars.imageset/givstars.pdf b/submodules/TelegramUI/Images.xcassets/Chat/Message/Stars.imageset/givstars.pdf new file mode 100644 index 00000000000..fa02782788c Binary files /dev/null and b/submodules/TelegramUI/Images.xcassets/Chat/Message/Stars.imageset/givstars.pdf differ diff --git a/submodules/TelegramUI/Images.xcassets/Instant View/InstantViewOff.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Instant View/InstantViewOff.imageset/Contents.json new file mode 100644 index 00000000000..850bf80cffe --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Instant View/InstantViewOff.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "boostoff_24.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/submodules/TelegramUI/Images.xcassets/Instant View/InstantViewOff.imageset/boostoff_24.pdf b/submodules/TelegramUI/Images.xcassets/Instant View/InstantViewOff.imageset/boostoff_24.pdf new file mode 100644 index 00000000000..c2ad22e1d1b Binary files /dev/null and b/submodules/TelegramUI/Images.xcassets/Instant View/InstantViewOff.imageset/boostoff_24.pdf differ diff --git a/submodules/TelegramUI/Images.xcassets/Instant View/OpenDocument.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Instant View/OpenDocument.imageset/Contents.json new file mode 100644 index 00000000000..c60877c4032 --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Instant View/OpenDocument.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "docviewer_24.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/submodules/TelegramUI/Images.xcassets/Instant View/OpenDocument.imageset/docviewer_24.pdf b/submodules/TelegramUI/Images.xcassets/Instant View/OpenDocument.imageset/docviewer_24.pdf new file mode 100644 index 00000000000..18bc4e9756b Binary files /dev/null and b/submodules/TelegramUI/Images.xcassets/Instant View/OpenDocument.imageset/docviewer_24.pdf differ diff --git a/submodules/TelegramUI/Images.xcassets/Peer Info/VerifiedIconBackground.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Peer Info/VerifiedIconBackground.imageset/Contents.json index 33022f34d11..1e55c1b35f3 100644 --- a/submodules/TelegramUI/Images.xcassets/Peer Info/VerifiedIconBackground.imageset/Contents.json +++ b/submodules/TelegramUI/Images.xcassets/Peer Info/VerifiedIconBackground.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "verifybadge1_20.pdf", + "filename" : "g1.svg", "idiom" : "universal" } ], diff --git a/submodules/TelegramUI/Images.xcassets/Peer Info/VerifiedIconBackground.imageset/g1.svg b/submodules/TelegramUI/Images.xcassets/Peer Info/VerifiedIconBackground.imageset/g1.svg new file mode 100644 index 00000000000..1a3718b319e --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Peer Info/VerifiedIconBackground.imageset/g1.svg @@ -0,0 +1,3 @@ + + + diff --git a/submodules/TelegramUI/Images.xcassets/Peer Info/VerifiedIconBackground.imageset/verifybadge1_20.pdf b/submodules/TelegramUI/Images.xcassets/Peer Info/VerifiedIconBackground.imageset/verifybadge1_20.pdf deleted file mode 100644 index 0c2ce2a770a..00000000000 Binary files a/submodules/TelegramUI/Images.xcassets/Peer Info/VerifiedIconBackground.imageset/verifybadge1_20.pdf and /dev/null differ diff --git a/submodules/TelegramUI/Images.xcassets/Peer Info/VerifiedIconForeground.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Peer Info/VerifiedIconForeground.imageset/Contents.json index 235e74ff647..9b0fedeb5db 100644 --- a/submodules/TelegramUI/Images.xcassets/Peer Info/VerifiedIconForeground.imageset/Contents.json +++ b/submodules/TelegramUI/Images.xcassets/Peer Info/VerifiedIconForeground.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "verifybadge2_20.pdf", + "filename" : "Logo 2.svg", "idiom" : "universal" } ], diff --git a/submodules/TelegramUI/Images.xcassets/Peer Info/VerifiedIconForeground.imageset/Logo 2.svg b/submodules/TelegramUI/Images.xcassets/Peer Info/VerifiedIconForeground.imageset/Logo 2.svg new file mode 100644 index 00000000000..b86e5fa5c13 --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Peer Info/VerifiedIconForeground.imageset/Logo 2.svg @@ -0,0 +1,3 @@ + + + diff --git a/submodules/TelegramUI/Images.xcassets/Peer Info/VerifiedIconForeground.imageset/verifybadge2_20.pdf b/submodules/TelegramUI/Images.xcassets/Peer Info/VerifiedIconForeground.imageset/verifybadge2_20.pdf deleted file mode 100644 index 4742f9c3f4c..00000000000 Binary files a/submodules/TelegramUI/Images.xcassets/Peer Info/VerifiedIconForeground.imageset/verifybadge2_20.pdf and /dev/null differ diff --git a/submodules/TelegramUI/Images.xcassets/Premium/PremiumStar.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Premium/PremiumStar.imageset/Contents.json new file mode 100644 index 00000000000..560d4c0836f --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Premium/PremiumStar.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "premium_30.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/submodules/TelegramUI/Images.xcassets/Premium/PremiumStar.imageset/premium_30.pdf b/submodules/TelegramUI/Images.xcassets/Premium/PremiumStar.imageset/premium_30.pdf new file mode 100644 index 00000000000..322354940cd Binary files /dev/null and b/submodules/TelegramUI/Images.xcassets/Premium/PremiumStar.imageset/premium_30.pdf differ diff --git a/submodules/TelegramUI/Resources/Readability/Readability.js b/submodules/TelegramUI/Resources/Readability/Readability.js new file mode 100644 index 00000000000..535afffd4d4 --- /dev/null +++ b/submodules/TelegramUI/Resources/Readability/Readability.js @@ -0,0 +1,2754 @@ +/* + * Copyright (c) 2010 Arc90 Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* + * This code is heavily based on Arc90's readability.js (1.7.1) script + * available at: http://code.google.com/p/arc90labs-readability + */ + +/** + * Public constructor. + * @param {HTMLDocument} doc The document to parse. + * @param {Object} options The options object. + */ +function Readability(doc, options) { + // In some older versions, people passed a URI as the first argument. Cope: + if (options && options.documentElement) { + doc = options; + options = arguments[2]; + } else if (!doc || !doc.documentElement) { + throw new Error( + "First argument to Readability constructor should be a document object." + ); + } + options = options || {}; + + this._doc = doc; + this._docJSDOMParser = this._doc.firstChild.__JSDOMParser__; + this._articleTitle = null; + this._articleByline = null; + this._articleDir = null; + this._articleSiteName = null; + this._attempts = []; + this._metadata = {}; + + // Configurable options + this._debug = !!options.debug; + this._maxElemsToParse = + options.maxElemsToParse || this.DEFAULT_MAX_ELEMS_TO_PARSE; + this._nbTopCandidates = + options.nbTopCandidates || this.DEFAULT_N_TOP_CANDIDATES; + this._charThreshold = options.charThreshold || this.DEFAULT_CHAR_THRESHOLD; + this._classesToPreserve = this.CLASSES_TO_PRESERVE.concat( + options.classesToPreserve || [] + ); + this._keepClasses = !!options.keepClasses; + this._serializer = + options.serializer || + function (el) { + return el.innerHTML; + }; + this._disableJSONLD = !!options.disableJSONLD; + this._allowedVideoRegex = options.allowedVideoRegex || this.REGEXPS.videos; + this._linkDensityModifier = options.linkDensityModifier || 0; + + // Start with all flags set + this._flags = + this.FLAG_STRIP_UNLIKELYS | + this.FLAG_WEIGHT_CLASSES | + this.FLAG_CLEAN_CONDITIONALLY; + + // Control whether log messages are sent to the console + if (this._debug) { + let logNode = function (node) { + if (node.nodeType == node.TEXT_NODE) { + return `${node.nodeName} ("${node.textContent}")`; + } + let attrPairs = Array.from(node.attributes || [], function (attr) { + return `${attr.name}="${attr.value}"`; + }).join(" "); + return `<${node.localName} ${attrPairs}>`; + }; + this.log = function () { + if (typeof console !== "undefined") { + let args = Array.from(arguments, arg => { + if (arg && arg.nodeType == this.ELEMENT_NODE) { + return logNode(arg); + } + return arg; + }); + args.unshift("Reader: (Readability)"); + // eslint-disable-next-line no-console + console.log(...args); + } else if (typeof dump !== "undefined") { + /* global dump */ + var msg = Array.prototype.map + .call(arguments, function (x) { + return x && x.nodeName ? logNode(x) : x; + }) + .join(" "); + dump("Reader: (Readability) " + msg + "\n"); + } + }; + } else { + this.log = function () {}; + } +} + +Readability.prototype = { + FLAG_STRIP_UNLIKELYS: 0x1, + FLAG_WEIGHT_CLASSES: 0x2, + FLAG_CLEAN_CONDITIONALLY: 0x4, + + // https://developer.mozilla.org/en-US/docs/Web/API/Node/nodeType + ELEMENT_NODE: 1, + TEXT_NODE: 3, + + // Max number of nodes supported by this parser. Default: 0 (no limit) + DEFAULT_MAX_ELEMS_TO_PARSE: 0, + + // The number of top candidates to consider when analysing how + // tight the competition is among candidates. + DEFAULT_N_TOP_CANDIDATES: 5, + + // Element tags to score by default. + DEFAULT_TAGS_TO_SCORE: "section,h2,h3,h4,h5,h6,p,td,pre" + .toUpperCase() + .split(","), + + // The default number of chars an article must have in order to return a result + DEFAULT_CHAR_THRESHOLD: 500, + + // All of the regular expressions in use within readability. + // Defined up here so we don't instantiate them repeatedly in loops. + REGEXPS: { + // NOTE: These two regular expressions are duplicated in + // Readability-readerable.js. Please keep both copies in sync. + unlikelyCandidates: + /-ad-|ai2html|banner|breadcrumbs|combx|comment|community|cover-wrap|disqus|extra|footer|gdpr|header|legends|menu|related|remark|replies|rss|shoutbox|sidebar|skyscraper|social|sponsor|supplemental|ad-break|agegate|pagination|pager|popup|yom-remote/i, + okMaybeItsACandidate: /and|article|body|column|content|main|shadow/i, + + positive: + /article|body|content|entry|hentry|h-entry|main|page|pagination|post|text|blog|story/i, + negative: + /-ad-|hidden|^hid$| hid$| hid |^hid |banner|combx|comment|com-|contact|foot|footer|footnote|gdpr|masthead|media|meta|outbrain|promo|related|scroll|share|shoutbox|sidebar|skyscraper|sponsor|shopping|tags|widget/i, + extraneous: + /print|archive|comment|discuss|e[\-]?mail|share|reply|all|login|sign|single|utility/i, + byline: /byline|author|dateline|writtenby|p-author/i, + replaceFonts: /<(\/?)font[^>]*>/gi, + normalize: /\s{2,}/g, + videos: + /\/\/(www\.)?((dailymotion|youtube|youtube-nocookie|player\.vimeo|v\.qq)\.com|(archive|upload\.wikimedia)\.org|player\.twitch\.tv)/i, + shareElements: /(\b|_)(share|sharedaddy)(\b|_)/i, + nextLink: /(next|weiter|continue|>([^\|]|$)|»([^\|]|$))/i, + prevLink: /(prev|earl|old|new|<|«)/i, + tokenize: /\W+/g, + whitespace: /^\s*$/, + hasContent: /\S$/, + hashUrl: /^#.+/, + srcsetUrl: /(\S+)(\s+[\d.]+[xw])?(\s*(?:,|$))/g, + b64DataUrl: /^data:\s*([^\s;,]+)\s*;\s*base64\s*,/i, + // Commas as used in Latin, Sindhi, Chinese and various other scripts. + // see: https://en.wikipedia.org/wiki/Comma#Comma_variants + commas: /\u002C|\u060C|\uFE50|\uFE10|\uFE11|\u2E41|\u2E34|\u2E32|\uFF0C/g, + // See: https://schema.org/Article + jsonLdArticleTypes: + /^Article|AdvertiserContentArticle|NewsArticle|AnalysisNewsArticle|AskPublicNewsArticle|BackgroundNewsArticle|OpinionNewsArticle|ReportageNewsArticle|ReviewNewsArticle|Report|SatiricalArticle|ScholarlyArticle|MedicalScholarlyArticle|SocialMediaPosting|BlogPosting|LiveBlogPosting|DiscussionForumPosting|TechArticle|APIReference$/, + // used to see if a node's content matches words commonly used for ad blocks or loading indicators + adWords: + /^(ad(vertising|vertisement)?|pub(licité)?|werb(ung)?|广告|Реклама|Anuncio)$/iu, + loadingWords: + /^((loading|正在加载|Загрузка|chargement|cargando)(…|\.\.\.)?)$/iu, + }, + + UNLIKELY_ROLES: [ + "menu", + "menubar", + "complementary", + "navigation", + "alert", + "alertdialog", + "dialog", + ], + + DIV_TO_P_ELEMS: new Set([ + "BLOCKQUOTE", + "DL", + "DIV", + "IMG", + "OL", + "P", + "PRE", + "TABLE", + "UL", + ]), + + ALTER_TO_DIV_EXCEPTIONS: ["DIV", "ARTICLE", "SECTION", "P"], + + PRESENTATIONAL_ATTRIBUTES: [ + "align", + "background", + "bgcolor", + "border", + "cellpadding", + "cellspacing", + "frame", + "hspace", + "rules", + "style", + "valign", + "vspace", + ], + + DEPRECATED_SIZE_ATTRIBUTE_ELEMS: ["TABLE", "TH", "TD", "HR", "PRE"], + + // The commented out elements qualify as phrasing content but tend to be + // removed by readability when put into paragraphs, so we ignore them here. + PHRASING_ELEMS: [ + // "CANVAS", "IFRAME", "SVG", "VIDEO", + "ABBR", + "AUDIO", + "B", + "BDO", + "BR", + "BUTTON", + "CITE", + "CODE", + "DATA", + "DATALIST", + "DFN", + "EM", + "EMBED", + "I", + "IMG", + "INPUT", + "KBD", + "LABEL", + "MARK", + "MATH", + "METER", + "NOSCRIPT", + "OBJECT", + "OUTPUT", + "PROGRESS", + "Q", + "RUBY", + "SAMP", + "SCRIPT", + "SELECT", + "SMALL", + "SPAN", + "STRONG", + "SUB", + "SUP", + "TEXTAREA", + "TIME", + "VAR", + "WBR", + ], + + // These are the classes that readability sets itself. + CLASSES_TO_PRESERVE: ["page"], + + // These are the list of HTML entities that need to be escaped. + HTML_ESCAPE_MAP: { + lt: "<", + gt: ">", + amp: "&", + quot: '"', + apos: "'", + }, + + /** + * Run any post-process modifications to article content as necessary. + * + * @param Element + * @return void + **/ + _postProcessContent(articleContent) { + // Readability cannot open relative uris so we convert them to absolute uris. + this._fixRelativeUris(articleContent); + + this._simplifyNestedElements(articleContent); + + if (!this._keepClasses) { + // Remove classes. + this._cleanClasses(articleContent); + } + }, + + /** + * Iterates over a NodeList, calls `filterFn` for each node and removes node + * if function returned `true`. + * + * If function is not passed, removes all the nodes in node list. + * + * @param NodeList nodeList The nodes to operate on + * @param Function filterFn the function to use as a filter + * @return void + */ + _removeNodes(nodeList, filterFn) { + // Avoid ever operating on live node lists. + if (this._docJSDOMParser && nodeList._isLiveNodeList) { + throw new Error("Do not pass live node lists to _removeNodes"); + } + for (var i = nodeList.length - 1; i >= 0; i--) { + var node = nodeList[i]; + var parentNode = node.parentNode; + if (parentNode) { + if (!filterFn || filterFn.call(this, node, i, nodeList)) { + parentNode.removeChild(node); + } + } + } + }, + + /** + * Iterates over a NodeList, and calls _setNodeTag for each node. + * + * @param NodeList nodeList The nodes to operate on + * @param String newTagName the new tag name to use + * @return void + */ + _replaceNodeTags(nodeList, newTagName) { + // Avoid ever operating on live node lists. + if (this._docJSDOMParser && nodeList._isLiveNodeList) { + throw new Error("Do not pass live node lists to _replaceNodeTags"); + } + for (const node of nodeList) { + this._setNodeTag(node, newTagName); + } + }, + + /** + * Iterate over a NodeList, which doesn't natively fully implement the Array + * interface. + * + * For convenience, the current object context is applied to the provided + * iterate function. + * + * @param NodeList nodeList The NodeList. + * @param Function fn The iterate function. + * @return void + */ + _forEachNode(nodeList, fn) { + Array.prototype.forEach.call(nodeList, fn, this); + }, + + /** + * Iterate over a NodeList, and return the first node that passes + * the supplied test function + * + * For convenience, the current object context is applied to the provided + * test function. + * + * @param NodeList nodeList The NodeList. + * @param Function fn The test function. + * @return void + */ + _findNode(nodeList, fn) { + return Array.prototype.find.call(nodeList, fn, this); + }, + + /** + * Iterate over a NodeList, return true if any of the provided iterate + * function calls returns true, false otherwise. + * + * For convenience, the current object context is applied to the + * provided iterate function. + * + * @param NodeList nodeList The NodeList. + * @param Function fn The iterate function. + * @return Boolean + */ + _someNode(nodeList, fn) { + return Array.prototype.some.call(nodeList, fn, this); + }, + + /** + * Iterate over a NodeList, return true if all of the provided iterate + * function calls return true, false otherwise. + * + * For convenience, the current object context is applied to the + * provided iterate function. + * + * @param NodeList nodeList The NodeList. + * @param Function fn The iterate function. + * @return Boolean + */ + _everyNode(nodeList, fn) { + return Array.prototype.every.call(nodeList, fn, this); + }, + + _getAllNodesWithTag(node, tagNames) { + if (node.querySelectorAll) { + return node.querySelectorAll(tagNames.join(",")); + } + return [].concat.apply( + [], + tagNames.map(function (tag) { + var collection = node.getElementsByTagName(tag); + return Array.isArray(collection) ? collection : Array.from(collection); + }) + ); + }, + + /** + * Removes the class="" attribute from every element in the given + * subtree, except those that match CLASSES_TO_PRESERVE and + * the classesToPreserve array from the options object. + * + * @param Element + * @return void + */ + _cleanClasses(node) { + var classesToPreserve = this._classesToPreserve; + var className = (node.getAttribute("class") || "") + .split(/\s+/) + .filter(cls => classesToPreserve.includes(cls)) + .join(" "); + + if (className) { + node.setAttribute("class", className); + } else { + node.removeAttribute("class"); + } + + for (node = node.firstElementChild; node; node = node.nextElementSibling) { + this._cleanClasses(node); + } + }, + + /** + * Converts each and uri in the given element to an absolute URI, + * ignoring #ref URIs. + * + * @param Element + * @return void + */ + _fixRelativeUris(articleContent) { + var baseURI = this._doc.baseURI; + var documentURI = this._doc.documentURI; + function toAbsoluteURI(uri) { + // Leave hash links alone if the base URI matches the document URI: + if (baseURI == documentURI && uri.charAt(0) == "#") { + return uri; + } + + // Otherwise, resolve against base URI: + try { + return new URL(uri, baseURI).href; + } catch (ex) { + // Something went wrong, just return the original: + } + return uri; + } + + var links = this._getAllNodesWithTag(articleContent, ["a"]); + this._forEachNode(links, function (link) { + var href = link.getAttribute("href"); + if (href) { + // Remove links with javascript: URIs, since + // they won't work after scripts have been removed from the page. + if (href.indexOf("javascript:") === 0) { + // if the link only contains simple text content, it can be converted to a text node + if ( + link.childNodes.length === 1 && + link.childNodes[0].nodeType === this.TEXT_NODE + ) { + var text = this._doc.createTextNode(link.textContent); + link.parentNode.replaceChild(text, link); + } else { + // if the link has multiple children, they should all be preserved + var container = this._doc.createElement("span"); + while (link.firstChild) { + container.appendChild(link.firstChild); + } + link.parentNode.replaceChild(container, link); + } + } else { + link.setAttribute("href", toAbsoluteURI(href)); + } + } + }); + + var medias = this._getAllNodesWithTag(articleContent, [ + "img", + "picture", + "figure", + "video", + "audio", + "source", + ]); + + this._forEachNode(medias, function (media) { + var src = media.getAttribute("src"); + var poster = media.getAttribute("poster"); + var srcset = media.getAttribute("srcset"); + + if (src) { + media.setAttribute("src", toAbsoluteURI(src)); + } + + if (poster) { + media.setAttribute("poster", toAbsoluteURI(poster)); + } + + if (srcset) { + var newSrcset = srcset.replace( + this.REGEXPS.srcsetUrl, + function (_, p1, p2, p3) { + return toAbsoluteURI(p1) + (p2 || "") + p3; + } + ); + + media.setAttribute("srcset", newSrcset); + } + }); + }, + + _simplifyNestedElements(articleContent) { + var node = articleContent; + + while (node) { + if ( + node.parentNode && + ["DIV", "SECTION"].includes(node.tagName) && + !(node.id && node.id.startsWith("readability")) + ) { + if (this._isElementWithoutContent(node)) { + node = this._removeAndGetNext(node); + continue; + } else if ( + this._hasSingleTagInsideElement(node, "DIV") || + this._hasSingleTagInsideElement(node, "SECTION") + ) { + var child = node.children[0]; + for (var i = 0; i < node.attributes.length; i++) { + child.setAttribute( + node.attributes[i].name, + node.attributes[i].value + ); + } + node.parentNode.replaceChild(child, node); + node = child; + continue; + } + } + + node = this._getNextNode(node); + } + }, + + /** + * Get the article title as an H1. + * + * @return string + **/ + _getArticleTitle() { + var doc = this._doc; + var curTitle = ""; + var origTitle = ""; + + try { + curTitle = origTitle = doc.title.trim(); + + // If they had an element with id "title" in their HTML + if (typeof curTitle !== "string") { + curTitle = origTitle = this._getInnerText( + doc.getElementsByTagName("title")[0] + ); + } + } catch (e) { + /* ignore exceptions setting the title. */ + } + + var titleHadHierarchicalSeparators = false; + function wordCount(str) { + return str.split(/\s+/).length; + } + + // If there's a separator in the title, first remove the final part + if (/ [\|\-\\\/>»] /.test(curTitle)) { + titleHadHierarchicalSeparators = / [\\\/>»] /.test(curTitle); + curTitle = origTitle.replace(/(.*)[\|\-\\\/>»] .*/gi, "$1"); + + // If the resulting title is too short, remove the first part instead: + if (wordCount(curTitle) < 3) { + curTitle = origTitle.replace(/[^\|\-\\\/>»]*[\|\-\\\/>»](.*)/gi, "$1"); + } + } else if (curTitle.includes(": ")) { + // Check if we have an heading containing this exact string, so we + // could assume it's the full title. + var headings = this._getAllNodesWithTag(doc, ["h1", "h2"]); + var trimmedTitle = curTitle.trim(); + var match = this._someNode(headings, function (heading) { + return heading.textContent.trim() === trimmedTitle; + }); + + // If we don't, let's extract the title out of the original title string. + if (!match) { + curTitle = origTitle.substring(origTitle.lastIndexOf(":") + 1); + + // If the title is now too short, try the first colon instead: + if (wordCount(curTitle) < 3) { + curTitle = origTitle.substring(origTitle.indexOf(":") + 1); + // But if we have too many words before the colon there's something weird + // with the titles and the H tags so let's just use the original title instead + } else if (wordCount(origTitle.substr(0, origTitle.indexOf(":"))) > 5) { + curTitle = origTitle; + } + } + } else if (curTitle.length > 150 || curTitle.length < 15) { + var hOnes = doc.getElementsByTagName("h1"); + + if (hOnes.length === 1) { + curTitle = this._getInnerText(hOnes[0]); + } + } + + curTitle = curTitle.trim().replace(this.REGEXPS.normalize, " "); + // If we now have 4 words or fewer as our title, and either no + // 'hierarchical' separators (\, /, > or ») were found in the original + // title or we decreased the number of words by more than 1 word, use + // the original title. + var curTitleWordCount = wordCount(curTitle); + if ( + curTitleWordCount <= 4 && + (!titleHadHierarchicalSeparators || + curTitleWordCount != + wordCount(origTitle.replace(/[\|\-\\\/>»]+/g, "")) - 1) + ) { + curTitle = origTitle; + } + + return curTitle; + }, + + /** + * Prepare the HTML document for readability to scrape it. + * This includes things like stripping javascript, CSS, and handling terrible markup. + * + * @return void + **/ + _prepDocument() { + var doc = this._doc; + + // Remove all style tags in head + this._removeNodes(this._getAllNodesWithTag(doc, ["style"])); + + if (doc.body) { + this._replaceBrs(doc.body); + } + + this._replaceNodeTags(this._getAllNodesWithTag(doc, ["font"]), "SPAN"); + }, + + /** + * Finds the next node, starting from the given node, and ignoring + * whitespace in between. If the given node is an element, the same node is + * returned. + */ + _nextNode(node) { + var next = node; + while ( + next && + next.nodeType != this.ELEMENT_NODE && + this.REGEXPS.whitespace.test(next.textContent) + ) { + next = next.nextSibling; + } + return next; + }, + + /** + * Replaces 2 or more successive
elements with a single

. + * Whitespace between
elements are ignored. For example: + *

foo
bar


abc
+ * will become: + *
foo
bar

abc

+ */ + _replaceBrs(elem) { + this._forEachNode(this._getAllNodesWithTag(elem, ["br"]), function (br) { + var next = br.nextSibling; + + // Whether 2 or more
elements have been found and replaced with a + //

block. + var replaced = false; + + // If we find a
chain, remove the
s until we hit another node + // or non-whitespace. This leaves behind the first
in the chain + // (which will be replaced with a

later). + while ((next = this._nextNode(next)) && next.tagName == "BR") { + replaced = true; + var brSibling = next.nextSibling; + next.remove(); + next = brSibling; + } + + // If we removed a
chain, replace the remaining
with a

. Add + // all sibling nodes as children of the

until we hit another
+ // chain. + if (replaced) { + var p = this._doc.createElement("p"); + br.parentNode.replaceChild(p, br); + + next = p.nextSibling; + while (next) { + // If we've hit another

, we're done adding children to this

. + if (next.tagName == "BR") { + var nextElem = this._nextNode(next.nextSibling); + if (nextElem && nextElem.tagName == "BR") { + break; + } + } + + if (!this._isPhrasingContent(next)) { + break; + } + + // Otherwise, make this node a child of the new

. + var sibling = next.nextSibling; + p.appendChild(next); + next = sibling; + } + + while (p.lastChild && this._isWhitespace(p.lastChild)) { + p.lastChild.remove(); + } + + if (p.parentNode.tagName === "P") { + this._setNodeTag(p.parentNode, "DIV"); + } + } + }); + }, + + _setNodeTag(node, tag) { + this.log("_setNodeTag", node, tag); + if (this._docJSDOMParser) { + node.localName = tag.toLowerCase(); + node.tagName = tag.toUpperCase(); + return node; + } + + var replacement = node.ownerDocument.createElement(tag); + while (node.firstChild) { + replacement.appendChild(node.firstChild); + } + node.parentNode.replaceChild(replacement, node); + if (node.readability) { + replacement.readability = node.readability; + } + + for (var i = 0; i < node.attributes.length; i++) { + try { + replacement.setAttribute( + node.attributes[i].name, + node.attributes[i].value + ); + } catch (ex) { + /* it's possible for setAttribute() to throw if the attribute name + * isn't a valid XML Name. Such attributes can however be parsed from + * source in HTML docs, see https://github.com/whatwg/html/issues/4275, + * so we can hit them here and then throw. We don't care about such + * attributes so we ignore them. + */ + } + } + return replacement; + }, + + /** + * Prepare the article node for display. Clean out any inline styles, + * iframes, forms, strip extraneous

tags, etc. + * + * @param Element + * @return void + **/ + _prepArticle(articleContent) { + this._cleanStyles(articleContent); + + // Check for data tables before we continue, to avoid removing items in + // those tables, which will often be isolated even though they're + // visually linked to other content-ful elements (text, images, etc.). + this._markDataTables(articleContent); + + this._fixLazyImages(articleContent); + + // Clean out junk from the article content + this._cleanConditionally(articleContent, "form"); + this._cleanConditionally(articleContent, "fieldset"); + this._clean(articleContent, "object"); + this._clean(articleContent, "embed"); + this._clean(articleContent, "footer"); + this._clean(articleContent, "link"); + this._clean(articleContent, "aside"); + + // Clean out elements with little content that have "share" in their id/class combinations from final top candidates, + // which means we don't remove the top candidates even they have "share". + + var shareElementThreshold = this.DEFAULT_CHAR_THRESHOLD; + + this._forEachNode(articleContent.children, function (topCandidate) { + this._cleanMatchedNodes(topCandidate, function (node, matchString) { + return ( + this.REGEXPS.shareElements.test(matchString) && + node.textContent.length < shareElementThreshold + ); + }); + }); + + this._clean(articleContent, "iframe"); + this._clean(articleContent, "input"); + this._clean(articleContent, "textarea"); + this._clean(articleContent, "select"); + this._clean(articleContent, "button"); + this._cleanHeaders(articleContent); + + // Do these last as the previous stuff may have removed junk + // that will affect these + this._cleanConditionally(articleContent, "table"); + this._cleanConditionally(articleContent, "ul"); + this._cleanConditionally(articleContent, "div"); + + // replace H1 with H2 as H1 should be only title that is displayed separately + this._replaceNodeTags( + this._getAllNodesWithTag(articleContent, ["h1"]), + "h2" + ); + + // Remove extra paragraphs + this._removeNodes( + this._getAllNodesWithTag(articleContent, ["p"]), + function (paragraph) { + var imgCount = paragraph.getElementsByTagName("img").length; + var embedCount = paragraph.getElementsByTagName("embed").length; + var objectCount = paragraph.getElementsByTagName("object").length; + // At this point, nasty iframes have been removed, only remain embedded video ones. + var iframeCount = paragraph.getElementsByTagName("iframe").length; + var totalCount = imgCount + embedCount + objectCount + iframeCount; + + return totalCount === 0 && !this._getInnerText(paragraph, false); + } + ); + + this._forEachNode( + this._getAllNodesWithTag(articleContent, ["br"]), + function (br) { + var next = this._nextNode(br.nextSibling); + if (next && next.tagName == "P") { + br.remove(); + } + } + ); + + // Remove single-cell tables + this._forEachNode( + this._getAllNodesWithTag(articleContent, ["table"]), + function (table) { + var tbody = this._hasSingleTagInsideElement(table, "TBODY") + ? table.firstElementChild + : table; + if (this._hasSingleTagInsideElement(tbody, "TR")) { + var row = tbody.firstElementChild; + if (this._hasSingleTagInsideElement(row, "TD")) { + var cell = row.firstElementChild; + cell = this._setNodeTag( + cell, + this._everyNode(cell.childNodes, this._isPhrasingContent) + ? "P" + : "DIV" + ); + table.parentNode.replaceChild(cell, table); + } + } + } + ); + }, + + /** + * Initialize a node with the readability object. Also checks the + * className/id for special names to add to its score. + * + * @param Element + * @return void + **/ + _initializeNode(node) { + node.readability = { contentScore: 0 }; + + switch (node.tagName) { + case "DIV": + node.readability.contentScore += 5; + break; + + case "PRE": + case "TD": + case "BLOCKQUOTE": + node.readability.contentScore += 3; + break; + + case "ADDRESS": + case "OL": + case "UL": + case "DL": + case "DD": + case "DT": + case "LI": + case "FORM": + node.readability.contentScore -= 3; + break; + + case "H1": + case "H2": + case "H3": + case "H4": + case "H5": + case "H6": + case "TH": + node.readability.contentScore -= 5; + break; + } + + node.readability.contentScore += this._getClassWeight(node); + }, + + _removeAndGetNext(node) { + var nextNode = this._getNextNode(node, true); + node.remove(); + return nextNode; + }, + + /** + * Traverse the DOM from node to node, starting at the node passed in. + * Pass true for the second parameter to indicate this node itself + * (and its kids) are going away, and we want the next node over. + * + * Calling this in a loop will traverse the DOM depth-first. + */ + _getNextNode(node, ignoreSelfAndKids) { + // First check for kids if those aren't being ignored + if (!ignoreSelfAndKids && node.firstElementChild) { + return node.firstElementChild; + } + // Then for siblings... + if (node.nextElementSibling) { + return node.nextElementSibling; + } + // And finally, move up the parent chain *and* find a sibling + // (because this is depth-first traversal, we will have already + // seen the parent nodes themselves). + do { + node = node.parentNode; + } while (node && !node.nextElementSibling); + return node && node.nextElementSibling; + }, + + // compares second text to first one + // 1 = same text, 0 = completely different text + // works the way that it splits both texts into words and then finds words that are unique in second text + // the result is given by the lower length of unique parts + _textSimilarity(textA, textB) { + var tokensA = textA + .toLowerCase() + .split(this.REGEXPS.tokenize) + .filter(Boolean); + var tokensB = textB + .toLowerCase() + .split(this.REGEXPS.tokenize) + .filter(Boolean); + if (!tokensA.length || !tokensB.length) { + return 0; + } + var uniqTokensB = tokensB.filter(token => !tokensA.includes(token)); + var distanceB = uniqTokensB.join(" ").length / tokensB.join(" ").length; + return 1 - distanceB; + }, + + _checkByline(node, matchString) { + if (this._articleByline || this._metadata.byline) { + return false; + } + + if (node.getAttribute !== undefined) { + var rel = node.getAttribute("rel"); + var itemprop = node.getAttribute("itemprop"); + } + + if ( + (rel === "author" || + (itemprop && itemprop.includes("author")) || + this.REGEXPS.byline.test(matchString)) && + this._isValidByline(node.textContent) + ) { + this._articleByline = node.textContent.trim(); + return true; + } + + return false; + }, + + _getNodeAncestors(node, maxDepth) { + maxDepth = maxDepth || 0; + var i = 0, + ancestors = []; + while (node.parentNode) { + ancestors.push(node.parentNode); + if (maxDepth && ++i === maxDepth) { + break; + } + node = node.parentNode; + } + return ancestors; + }, + + /*** + * grabArticle - Using a variety of metrics (content score, classname, element types), find the content that is + * most likely to be the stuff a user wants to read. Then return it wrapped up in a div. + * + * @param page a document to run upon. Needs to be a full document, complete with body. + * @return Element + **/ + /* eslint-disable-next-line complexity */ + _grabArticle(page) { + this.log("**** grabArticle ****"); + var doc = this._doc; + var isPaging = page !== null; + page = page ? page : this._doc.body; + + // We can't grab an article if we don't have a page! + if (!page) { + this.log("No body found in document. Abort."); + return null; + } + + var pageCacheHtml = page.innerHTML; + + while (true) { + this.log("Starting grabArticle loop"); + var stripUnlikelyCandidates = this._flagIsActive( + this.FLAG_STRIP_UNLIKELYS + ); + + // First, node prepping. Trash nodes that look cruddy (like ones with the + // class name "comment", etc), and turn divs into P tags where they have been + // used inappropriately (as in, where they contain no other block level elements.) + var elementsToScore = []; + var node = this._doc.documentElement; + + let shouldRemoveTitleHeader = true; + + while (node) { + if (node.tagName === "HTML") { + this._articleLang = node.getAttribute("lang"); + } + + var matchString = node.className + " " + node.id; + + if (!this._isProbablyVisible(node)) { + this.log("Removing hidden node - " + matchString); + node = this._removeAndGetNext(node); + continue; + } + + // User is not able to see elements applied with both "aria-modal = true" and "role = dialog" + if ( + node.getAttribute("aria-modal") == "true" && + node.getAttribute("role") == "dialog" + ) { + node = this._removeAndGetNext(node); + continue; + } + + // Check to see if this node is a byline, and remove it if it is. + if (this._checkByline(node, matchString)) { + node = this._removeAndGetNext(node); + continue; + } + + if (shouldRemoveTitleHeader && this._headerDuplicatesTitle(node)) { + this.log( + "Removing header: ", + node.textContent.trim(), + this._articleTitle.trim() + ); + shouldRemoveTitleHeader = false; + node = this._removeAndGetNext(node); + continue; + } + + // Remove unlikely candidates + if (stripUnlikelyCandidates) { + if ( + this.REGEXPS.unlikelyCandidates.test(matchString) && + !this.REGEXPS.okMaybeItsACandidate.test(matchString) && + !this._hasAncestorTag(node, "table") && + !this._hasAncestorTag(node, "code") && + node.tagName !== "BODY" && + node.tagName !== "A" + ) { + this.log("Removing unlikely candidate - " + matchString); + node = this._removeAndGetNext(node); + continue; + } + + if (this.UNLIKELY_ROLES.includes(node.getAttribute("role"))) { + this.log( + "Removing content with role " + + node.getAttribute("role") + + " - " + + matchString + ); + node = this._removeAndGetNext(node); + continue; + } + } + + // Remove DIV, SECTION, and HEADER nodes without any content(e.g. text, image, video, or iframe). + if ( + (node.tagName === "DIV" || + node.tagName === "SECTION" || + node.tagName === "HEADER" || + node.tagName === "H1" || + node.tagName === "H2" || + node.tagName === "H3" || + node.tagName === "H4" || + node.tagName === "H5" || + node.tagName === "H6") && + this._isElementWithoutContent(node) + ) { + node = this._removeAndGetNext(node); + continue; + } + + if (this.DEFAULT_TAGS_TO_SCORE.includes(node.tagName)) { + elementsToScore.push(node); + } + + // Turn all divs that don't have children block level elements into p's + if (node.tagName === "DIV") { + // Put phrasing content into paragraphs. + var p = null; + var childNode = node.firstChild; + while (childNode) { + var nextSibling = childNode.nextSibling; + if (this._isPhrasingContent(childNode)) { + if (p !== null) { + p.appendChild(childNode); + } else if (!this._isWhitespace(childNode)) { + p = doc.createElement("p"); + node.replaceChild(p, childNode); + p.appendChild(childNode); + } + } else if (p !== null) { + while (p.lastChild && this._isWhitespace(p.lastChild)) { + p.lastChild.remove(); + } + p = null; + } + childNode = nextSibling; + } + + // Sites like http://mobile.slate.com encloses each paragraph with a DIV + // element. DIVs with only a P element inside and no text content can be + // safely converted into plain P elements to avoid confusing the scoring + // algorithm with DIVs with are, in practice, paragraphs. + if ( + this._hasSingleTagInsideElement(node, "P") && + this._getLinkDensity(node) < 0.25 + ) { + var newNode = node.children[0]; + node.parentNode.replaceChild(newNode, node); + node = newNode; + elementsToScore.push(node); + } else if (!this._hasChildBlockElement(node)) { + node = this._setNodeTag(node, "P"); + elementsToScore.push(node); + } + } + node = this._getNextNode(node); + } + + /** + * Loop through all paragraphs, and assign a score to them based on how content-y they look. + * Then add their score to their parent node. + * + * A score is determined by things like number of commas, class names, etc. Maybe eventually link density. + **/ + var candidates = []; + this._forEachNode(elementsToScore, function (elementToScore) { + if ( + !elementToScore.parentNode || + typeof elementToScore.parentNode.tagName === "undefined" + ) { + return; + } + + // If this paragraph is less than 25 characters, don't even count it. + var innerText = this._getInnerText(elementToScore); + if (innerText.length < 25) { + return; + } + + // Exclude nodes with no ancestor. + var ancestors = this._getNodeAncestors(elementToScore, 5); + if (ancestors.length === 0) { + return; + } + + var contentScore = 0; + + // Add a point for the paragraph itself as a base. + contentScore += 1; + + // Add points for any commas within this paragraph. + contentScore += innerText.split(this.REGEXPS.commas).length; + + // For every 100 characters in this paragraph, add another point. Up to 3 points. + contentScore += Math.min(Math.floor(innerText.length / 100), 3); + + // Initialize and score ancestors. + this._forEachNode(ancestors, function (ancestor, level) { + if ( + !ancestor.tagName || + !ancestor.parentNode || + typeof ancestor.parentNode.tagName === "undefined" + ) { + return; + } + + if (typeof ancestor.readability === "undefined") { + this._initializeNode(ancestor); + candidates.push(ancestor); + } + + // Node score divider: + // - parent: 1 (no division) + // - grandparent: 2 + // - great grandparent+: ancestor level * 3 + if (level === 0) { + var scoreDivider = 1; + } else if (level === 1) { + scoreDivider = 2; + } else { + scoreDivider = level * 3; + } + ancestor.readability.contentScore += contentScore / scoreDivider; + }); + }); + + // After we've calculated scores, loop through all of the possible + // candidate nodes we found and find the one with the highest score. + var topCandidates = []; + for (var c = 0, cl = candidates.length; c < cl; c += 1) { + var candidate = candidates[c]; + + // Scale the final candidates score based on link density. Good content + // should have a relatively small link density (5% or less) and be mostly + // unaffected by this operation. + var candidateScore = + candidate.readability.contentScore * + (1 - this._getLinkDensity(candidate)); + candidate.readability.contentScore = candidateScore; + + this.log("Candidate:", candidate, "with score " + candidateScore); + + for (var t = 0; t < this._nbTopCandidates; t++) { + var aTopCandidate = topCandidates[t]; + + if ( + !aTopCandidate || + candidateScore > aTopCandidate.readability.contentScore + ) { + topCandidates.splice(t, 0, candidate); + if (topCandidates.length > this._nbTopCandidates) { + topCandidates.pop(); + } + break; + } + } + } + + var topCandidate = topCandidates[0] || null; + var neededToCreateTopCandidate = false; + var parentOfTopCandidate; + + // If we still have no top candidate, just use the body as a last resort. + // We also have to copy the body node so it is something we can modify. + if (topCandidate === null || topCandidate.tagName === "BODY") { + // Move all of the page's children into topCandidate + topCandidate = doc.createElement("DIV"); + neededToCreateTopCandidate = true; + // Move everything (not just elements, also text nodes etc.) into the container + // so we even include text directly in the body: + while (page.firstChild) { + this.log("Moving child out:", page.firstChild); + topCandidate.appendChild(page.firstChild); + } + + page.appendChild(topCandidate); + + this._initializeNode(topCandidate); + } else if (topCandidate) { + // Find a better top candidate node if it contains (at least three) nodes which belong to `topCandidates` array + // and whose scores are quite closed with current `topCandidate` node. + var alternativeCandidateAncestors = []; + for (var i = 1; i < topCandidates.length; i++) { + if ( + topCandidates[i].readability.contentScore / + topCandidate.readability.contentScore >= + 0.75 + ) { + alternativeCandidateAncestors.push( + this._getNodeAncestors(topCandidates[i]) + ); + } + } + var MINIMUM_TOPCANDIDATES = 3; + if (alternativeCandidateAncestors.length >= MINIMUM_TOPCANDIDATES) { + parentOfTopCandidate = topCandidate.parentNode; + while (parentOfTopCandidate.tagName !== "BODY") { + var listsContainingThisAncestor = 0; + for ( + var ancestorIndex = 0; + ancestorIndex < alternativeCandidateAncestors.length && + listsContainingThisAncestor < MINIMUM_TOPCANDIDATES; + ancestorIndex++ + ) { + listsContainingThisAncestor += Number( + alternativeCandidateAncestors[ancestorIndex].includes( + parentOfTopCandidate + ) + ); + } + if (listsContainingThisAncestor >= MINIMUM_TOPCANDIDATES) { + topCandidate = parentOfTopCandidate; + break; + } + parentOfTopCandidate = parentOfTopCandidate.parentNode; + } + } + if (!topCandidate.readability) { + this._initializeNode(topCandidate); + } + + // Because of our bonus system, parents of candidates might have scores + // themselves. They get half of the node. There won't be nodes with higher + // scores than our topCandidate, but if we see the score going *up* in the first + // few steps up the tree, that's a decent sign that there might be more content + // lurking in other places that we want to unify in. The sibling stuff + // below does some of that - but only if we've looked high enough up the DOM + // tree. + parentOfTopCandidate = topCandidate.parentNode; + var lastScore = topCandidate.readability.contentScore; + // The scores shouldn't get too low. + var scoreThreshold = lastScore / 3; + while (parentOfTopCandidate.tagName !== "BODY") { + if (!parentOfTopCandidate.readability) { + parentOfTopCandidate = parentOfTopCandidate.parentNode; + continue; + } + var parentScore = parentOfTopCandidate.readability.contentScore; + if (parentScore < scoreThreshold) { + break; + } + if (parentScore > lastScore) { + // Alright! We found a better parent to use. + topCandidate = parentOfTopCandidate; + break; + } + lastScore = parentOfTopCandidate.readability.contentScore; + parentOfTopCandidate = parentOfTopCandidate.parentNode; + } + + // If the top candidate is the only child, use parent instead. This will help sibling + // joining logic when adjacent content is actually located in parent's sibling node. + parentOfTopCandidate = topCandidate.parentNode; + while ( + parentOfTopCandidate.tagName != "BODY" && + parentOfTopCandidate.children.length == 1 + ) { + topCandidate = parentOfTopCandidate; + parentOfTopCandidate = topCandidate.parentNode; + } + if (!topCandidate.readability) { + this._initializeNode(topCandidate); + } + } + + // Now that we have the top candidate, look through its siblings for content + // that might also be related. Things like preambles, content split by ads + // that we removed, etc. + var articleContent = doc.createElement("DIV"); + if (isPaging) { + articleContent.id = "readability-content"; + } + + var siblingScoreThreshold = Math.max( + 10, + topCandidate.readability.contentScore * 0.2 + ); + // Keep potential top candidate's parent node to try to get text direction of it later. + parentOfTopCandidate = topCandidate.parentNode; + var siblings = parentOfTopCandidate.children; + + for (var s = 0, sl = siblings.length; s < sl; s++) { + var sibling = siblings[s]; + var append = false; + + this.log( + "Looking at sibling node:", + sibling, + sibling.readability + ? "with score " + sibling.readability.contentScore + : "" + ); + this.log( + "Sibling has score", + sibling.readability ? sibling.readability.contentScore : "Unknown" + ); + + if (sibling === topCandidate) { + append = true; + } else { + var contentBonus = 0; + + // Give a bonus if sibling nodes and top candidates have the example same classname + if ( + sibling.className === topCandidate.className && + topCandidate.className !== "" + ) { + contentBonus += topCandidate.readability.contentScore * 0.2; + } + + if ( + sibling.readability && + sibling.readability.contentScore + contentBonus >= + siblingScoreThreshold + ) { + append = true; + } else if (sibling.nodeName === "P") { + var linkDensity = this._getLinkDensity(sibling); + var nodeContent = this._getInnerText(sibling); + var nodeLength = nodeContent.length; + + if (nodeLength > 80 && linkDensity < 0.25) { + append = true; + } else if ( + nodeLength < 80 && + nodeLength > 0 && + linkDensity === 0 && + nodeContent.search(/\.( |$)/) !== -1 + ) { + append = true; + } + } + } + + if (append) { + this.log("Appending node:", sibling); + + if (!this.ALTER_TO_DIV_EXCEPTIONS.includes(sibling.nodeName)) { + // We have a node that isn't a common block level element, like a form or td tag. + // Turn it into a div so it doesn't get filtered out later by accident. + this.log("Altering sibling:", sibling, "to div."); + + sibling = this._setNodeTag(sibling, "DIV"); + } + + articleContent.appendChild(sibling); + // Fetch children again to make it compatible + // with DOM parsers without live collection support. + siblings = parentOfTopCandidate.children; + // siblings is a reference to the children array, and + // sibling is removed from the array when we call appendChild(). + // As a result, we must revisit this index since the nodes + // have been shifted. + s -= 1; + sl -= 1; + } + } + + if (this._debug) { + this.log("Article content pre-prep: " + articleContent.innerHTML); + } + // So we have all of the content that we need. Now we clean it up for presentation. + this._prepArticle(articleContent); + if (this._debug) { + this.log("Article content post-prep: " + articleContent.innerHTML); + } + + if (neededToCreateTopCandidate) { + // We already created a fake div thing, and there wouldn't have been any siblings left + // for the previous loop, so there's no point trying to create a new div, and then + // move all the children over. Just assign IDs and class names here. No need to append + // because that already happened anyway. + topCandidate.id = "readability-page-1"; + topCandidate.className = "page"; + } else { + var div = doc.createElement("DIV"); + div.id = "readability-page-1"; + div.className = "page"; + while (articleContent.firstChild) { + div.appendChild(articleContent.firstChild); + } + articleContent.appendChild(div); + } + + if (this._debug) { + this.log("Article content after paging: " + articleContent.innerHTML); + } + + var parseSuccessful = true; + + // Now that we've gone through the full algorithm, check to see if + // we got any meaningful content. If we didn't, we may need to re-run + // grabArticle with different flags set. This gives us a higher likelihood of + // finding the content, and the sieve approach gives us a higher likelihood of + // finding the -right- content. + var textLength = this._getInnerText(articleContent, true).length; + if (textLength < this._charThreshold) { + parseSuccessful = false; + // eslint-disable-next-line no-unsanitized/property + page.innerHTML = pageCacheHtml; + + this._attempts.push({ + articleContent, + textLength, + }); + + if (this._flagIsActive(this.FLAG_STRIP_UNLIKELYS)) { + this._removeFlag(this.FLAG_STRIP_UNLIKELYS); + } else if (this._flagIsActive(this.FLAG_WEIGHT_CLASSES)) { + this._removeFlag(this.FLAG_WEIGHT_CLASSES); + } else if (this._flagIsActive(this.FLAG_CLEAN_CONDITIONALLY)) { + this._removeFlag(this.FLAG_CLEAN_CONDITIONALLY); + } else { + // No luck after removing flags, just return the longest text we found during the different loops + this._attempts.sort(function (a, b) { + return b.textLength - a.textLength; + }); + + // But first check if we actually have something + if (!this._attempts[0].textLength) { + return null; + } + + articleContent = this._attempts[0].articleContent; + parseSuccessful = true; + } + } + + if (parseSuccessful) { + // Find out text direction from ancestors of final top candidate. + var ancestors = [parentOfTopCandidate, topCandidate].concat( + this._getNodeAncestors(parentOfTopCandidate) + ); + this._someNode(ancestors, function (ancestor) { + if (!ancestor.tagName) { + return false; + } + var articleDir = ancestor.getAttribute("dir"); + if (articleDir) { + this._articleDir = articleDir; + return true; + } + return false; + }); + return articleContent; + } + } + }, + + /** + * Check whether the input string could be a byline. + * This verifies that the input is a string, and that the length + * is less than 100 chars. + * + * @param possibleByline {string} - a string to check whether its a byline. + * @return Boolean - whether the input string is a byline. + */ + _isValidByline(byline) { + if (typeof byline == "string" || byline instanceof String) { + byline = byline.trim(); + return !!byline.length && byline.length < 100; + } + return false; + }, + + /** + * Converts some of the common HTML entities in string to their corresponding characters. + * + * @param str {string} - a string to unescape. + * @return string without HTML entity. + */ + _unescapeHtmlEntities(str) { + if (!str) { + return str; + } + + var htmlEscapeMap = this.HTML_ESCAPE_MAP; + return str + .replace(/&(quot|amp|apos|lt|gt);/g, function (_, tag) { + return htmlEscapeMap[tag]; + }) + .replace(/&#(?:x([0-9a-f]+)|([0-9]+));/gi, function (_, hex, numStr) { + var num = parseInt(hex || numStr, hex ? 16 : 10); + + // these character references are replaced by a conforming HTML parser + if (num == 0 || num > 0x10ffff || (num >= 0xd800 && num <= 0xdfff)) { + num = 0xfffd; + } + + return String.fromCodePoint(num); + }); + }, + + /** + * Try to extract metadata from JSON-LD object. + * For now, only Schema.org objects of type Article or its subtypes are supported. + * @return Object with any metadata that could be extracted (possibly none) + */ + _getJSONLD(doc) { + var scripts = this._getAllNodesWithTag(doc, ["script"]); + + var metadata; + + this._forEachNode(scripts, function (jsonLdElement) { + if ( + !metadata && + jsonLdElement.getAttribute("type") === "application/ld+json" + ) { + try { + // Strip CDATA markers if present + var content = jsonLdElement.textContent.replace( + /^\s*\s*$/g, + "" + ); + var parsed = JSON.parse(content); + if ( + !parsed["@context"] || + !parsed["@context"].match(/^https?\:\/\/schema\.org\/?$/) + ) { + return; + } + + if (!parsed["@type"] && Array.isArray(parsed["@graph"])) { + parsed = parsed["@graph"].find(function (it) { + return (it["@type"] || "").match(this.REGEXPS.jsonLdArticleTypes); + }); + } + + if ( + !parsed || + !parsed["@type"] || + !parsed["@type"].match(this.REGEXPS.jsonLdArticleTypes) + ) { + return; + } + + metadata = {}; + + if ( + typeof parsed.name === "string" && + typeof parsed.headline === "string" && + parsed.name !== parsed.headline + ) { + // we have both name and headline element in the JSON-LD. They should both be the same but some websites like aktualne.cz + // put their own name into "name" and the article title to "headline" which confuses Readability. So we try to check if either + // "name" or "headline" closely matches the html title, and if so, use that one. If not, then we use "name" by default. + + var title = this._getArticleTitle(); + var nameMatches = this._textSimilarity(parsed.name, title) > 0.75; + var headlineMatches = + this._textSimilarity(parsed.headline, title) > 0.75; + + if (headlineMatches && !nameMatches) { + metadata.title = parsed.headline; + } else { + metadata.title = parsed.name; + } + } else if (typeof parsed.name === "string") { + metadata.title = parsed.name.trim(); + } else if (typeof parsed.headline === "string") { + metadata.title = parsed.headline.trim(); + } + if (parsed.author) { + if (typeof parsed.author.name === "string") { + metadata.byline = parsed.author.name.trim(); + } else if ( + Array.isArray(parsed.author) && + parsed.author[0] && + typeof parsed.author[0].name === "string" + ) { + metadata.byline = parsed.author + .filter(function (author) { + return author && typeof author.name === "string"; + }) + .map(function (author) { + return author.name.trim(); + }) + .join(", "); + } + } + if (typeof parsed.description === "string") { + metadata.excerpt = parsed.description.trim(); + } + if (parsed.publisher && typeof parsed.publisher.name === "string") { + metadata.siteName = parsed.publisher.name.trim(); + } + if (typeof parsed.datePublished === "string") { + metadata.datePublished = parsed.datePublished.trim(); + } + } catch (err) { + this.log(err.message); + } + } + }); + return metadata ? metadata : {}; + }, + + /** + * Attempts to get excerpt and byline metadata for the article. + * + * @param {Object} jsonld — object containing any metadata that + * could be extracted from JSON-LD object. + * + * @return Object with optional "excerpt" and "byline" properties + */ + _getArticleMetadata(jsonld) { + var metadata = {}; + var values = {}; + var metaElements = this._doc.getElementsByTagName("meta"); + + // property is a space-separated list of values + var propertyPattern = + /\s*(article|dc|dcterm|og|twitter)\s*:\s*(author|creator|description|published_time|title|site_name)\s*/gi; + + // name is a single value + var namePattern = + /^\s*(?:(dc|dcterm|og|twitter|parsely|weibo:(article|webpage))\s*[-\.:]\s*)?(author|creator|pub-date|description|title|site_name)\s*$/i; + + // Find description tags. + this._forEachNode(metaElements, function (element) { + var elementName = element.getAttribute("name"); + var elementProperty = element.getAttribute("property"); + var content = element.getAttribute("content"); + if (!content) { + return; + } + var matches = null; + var name = null; + + if (elementProperty) { + matches = elementProperty.match(propertyPattern); + if (matches) { + // Convert to lowercase, and remove any whitespace + // so we can match below. + name = matches[0].toLowerCase().replace(/\s/g, ""); + // multiple authors + values[name] = content.trim(); + } + } + if (!matches && elementName && namePattern.test(elementName)) { + name = elementName; + if (content) { + // Convert to lowercase, remove any whitespace, and convert dots + // to colons so we can match below. + name = name.toLowerCase().replace(/\s/g, "").replace(/\./g, ":"); + values[name] = content.trim(); + } + } + }); + + // get title + metadata.title = + jsonld.title || + values["dc:title"] || + values["dcterm:title"] || + values["og:title"] || + values["weibo:article:title"] || + values["weibo:webpage:title"] || + values.title || + values["twitter:title"] || + values["parsely-title"]; + + if (!metadata.title) { + metadata.title = this._getArticleTitle(); + } + + // get author + metadata.byline = + jsonld.byline || + values["dc:creator"] || + values["dcterm:creator"] || + values.author || + values["parsely-author"]; + + // get description + metadata.excerpt = + jsonld.excerpt || + values["dc:description"] || + values["dcterm:description"] || + values["og:description"] || + values["weibo:article:description"] || + values["weibo:webpage:description"] || + values.description || + values["twitter:description"]; + + // get site name + metadata.siteName = jsonld.siteName || values["og:site_name"]; + + // get article published time + metadata.publishedTime = + jsonld.datePublished || + values["article:published_time"] || + values["parsely-pub-date"] || + null; + + // in many sites the meta value is escaped with HTML entities, + // so here we need to unescape it + metadata.title = this._unescapeHtmlEntities(metadata.title); + metadata.byline = this._unescapeHtmlEntities(metadata.byline); + metadata.excerpt = this._unescapeHtmlEntities(metadata.excerpt); + metadata.siteName = this._unescapeHtmlEntities(metadata.siteName); + metadata.publishedTime = this._unescapeHtmlEntities(metadata.publishedTime); + + return metadata; + }, + + /** + * Check if node is image, or if node contains exactly only one image + * whether as a direct child or as its descendants. + * + * @param Element + **/ + _isSingleImage(node) { + while (node) { + if (node.tagName === "IMG") { + return true; + } + if (node.children.length !== 1 || node.textContent.trim() !== "") { + return false; + } + node = node.children[0]; + } + return false; + }, + + /** + * Find all