diff --git a/.bazelrc b/.bazelrc index 43ccf92e1f..5ecdbaff0d 100644 --- a/.bazelrc +++ b/.bazelrc @@ -29,6 +29,7 @@ build --features=debug_prefix_map_pwd_is_dot build --features=swift.cacheable_swiftmodules build --features=swift.debug_prefix_map build --features=swift.enable_vfsoverlays +build --features=swift.vfsoverlay build --strategy=Genrule=standalone build --spawn_strategy=standalone diff --git a/Random.txt b/Random.txt index 397d9dd933..3578a816e4 100644 --- a/Random.txt +++ b/Random.txt @@ -1 +1 @@ -3a64b94cc76109006741756f85403c85 +3a64b94cc76109006741756f85403c86 diff --git a/Telegram/Telegram-iOS/en.lproj/Localizable.strings b/Telegram/Telegram-iOS/en.lproj/Localizable.strings index cdb5e338f9..7fc36c6c7b 100644 --- a/Telegram/Telegram-iOS/en.lproj/Localizable.strings +++ b/Telegram/Telegram-iOS/en.lproj/Localizable.strings @@ -12285,6 +12285,8 @@ Sorry for the inconvenience."; "Stars.Intro.Transaction.FragmentWithdrawal.Subtitle" = "via Fragment"; "Stars.Intro.Transaction.TelegramAds.Title" = "Withdrawal"; "Stars.Intro.Transaction.TelegramAds.Subtitle" = "via Telegram Ads"; +"Stars.Intro.Transaction.Gift" = "Gift"; +"Stars.Intro.Transaction.ConvertedGift" = "Converted Gift"; "Stars.Intro.Transaction.Unsupported.Title" = "Unsupported"; "Stars.Intro.Transaction.Refund" = "Refund"; @@ -12941,3 +12943,8 @@ Sorry for the inconvenience."; "VerificationCodes.DescriptionText" = "This chat is used to receive verification codes from third-party services."; "Conversation.CodeCopied" = "Code copied to clipboard"; + +"Stars.Purchase.StarGiftInfo" = "Buy Stars to send **%@** gifts that can be kept on the profile or converted to Stars."; + +"SharedMedia.GiftCount_1" = "%@ gift"; +"SharedMedia.GiftCount_any" = "%@ gifts"; diff --git a/submodules/AccountContext/Sources/AccountContext.swift b/submodules/AccountContext/Sources/AccountContext.swift index fef2ed9501..1f8fd53658 100644 --- a/submodules/AccountContext/Sources/AccountContext.swift +++ b/submodules/AccountContext/Sources/AccountContext.swift @@ -1017,8 +1017,11 @@ public protocol SharedAccountContext: AnyObject { 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 makeStarsIntroScreen(context: AccountContext) -> ViewController func makeGiftViewScreen(context: AccountContext, message: EngineMessage) -> ViewController + func makeContentReportScreen(context: AccountContext, subject: ReportContentSubject, forceDark: Bool, present: @escaping (ViewController) -> Void, completion: @escaping () -> Void) + func makeMiniAppListScreenInitialData(context: AccountContext) -> Signal func makeMiniAppListScreen(context: AccountContext, initialData: MiniAppListScreenInitialData) -> ViewController @@ -1136,7 +1139,7 @@ public protocol AccountContext: AnyObject { func chatLocationUnreadCount(for location: ChatLocation, contextHolder: Atomic) -> Signal func applyMaxReadIndex(for location: ChatLocation, contextHolder: Atomic, messageIndex: MessageIndex) - func scheduleGroupCall(peerId: PeerId) + func scheduleGroupCall(peerId: PeerId, parentController: ViewController) func joinGroupCall(peerId: PeerId, invite: String?, requestJoinAsPeerId: ((@escaping (PeerId?) -> Void) -> Void)?, activeCall: EngineGroupCallDescription) func requestCall(peerId: PeerId, isVideo: Bool, completion: @escaping () -> Void) } diff --git a/submodules/AccountContext/Sources/Premium.swift b/submodules/AccountContext/Sources/Premium.swift index 7f8d9fdaaa..0edf8c349c 100644 --- a/submodules/AccountContext/Sources/Premium.swift +++ b/submodules/AccountContext/Sources/Premium.swift @@ -130,6 +130,7 @@ public enum StarsPurchasePurpose: Equatable { case subscription(peerId: EnginePeer.Id, requiredStars: Int64, renew: Bool) case gift(peerId: EnginePeer.Id) case unlockMedia(requiredStars: Int64) + case starGift(peerId: EnginePeer.Id, requiredStars: Int64) } public struct PremiumConfiguration { diff --git a/submodules/AccountContext/Sources/PresentationCallManager.swift b/submodules/AccountContext/Sources/PresentationCallManager.swift index 310c6846ce..c285caa029 100644 --- a/submodules/AccountContext/Sources/PresentationCallManager.swift +++ b/submodules/AccountContext/Sources/PresentationCallManager.swift @@ -4,6 +4,7 @@ import AsyncDisplayKit import TelegramCore import SwiftSignalKit import TelegramAudio +import Display public enum RequestCallResult { case requested @@ -211,6 +212,7 @@ public struct PresentationGroupCallState: Equatable { public var subscribedToScheduled: Bool public var isVideoEnabled: Bool public var isVideoWatchersLimitReached: Bool + public var hasVideo: Bool public init( myPeerId: EnginePeer.Id, @@ -225,7 +227,8 @@ public struct PresentationGroupCallState: Equatable { scheduleTimestamp: Int32?, subscribedToScheduled: Bool, isVideoEnabled: Bool, - isVideoWatchersLimitReached: Bool + isVideoWatchersLimitReached: Bool, + hasVideo: Bool ) { self.myPeerId = myPeerId self.networkState = networkState @@ -240,6 +243,7 @@ public struct PresentationGroupCallState: Equatable { self.subscribedToScheduled = subscribedToScheduled self.isVideoEnabled = isVideoEnabled self.isVideoWatchersLimitReached = isVideoWatchersLimitReached + self.hasVideo = hasVideo } } @@ -472,5 +476,5 @@ public protocol PresentationCallManager: AnyObject { func requestCall(context: AccountContext, peerId: EnginePeer.Id, isVideo: Bool, endCurrentIfAny: Bool) -> RequestCallResult func joinGroupCall(context: AccountContext, peerId: EnginePeer.Id, invite: String?, requestJoinAsPeerId: ((@escaping (EnginePeer.Id?) -> Void) -> Void)?, initialCall: EngineGroupCallDescription, endCurrentIfAny: Bool) -> JoinGroupCallManagerResult - func scheduleGroupCall(context: AccountContext, peerId: EnginePeer.Id, endCurrentIfAny: Bool) -> RequestScheduleGroupCallResult + func scheduleGroupCall(context: AccountContext, peerId: EnginePeer.Id, endCurrentIfAny: Bool, parentController: ViewController) -> RequestScheduleGroupCallResult } diff --git a/submodules/AccountContext/Sources/UniversalVideoNode.swift b/submodules/AccountContext/Sources/UniversalVideoNode.swift index d224b6aa78..788431c2be 100644 --- a/submodules/AccountContext/Sources/UniversalVideoNode.swift +++ b/submodules/AccountContext/Sources/UniversalVideoNode.swift @@ -48,7 +48,7 @@ public protocol UniversalVideoContent { var dimensions: CGSize { get } var duration: Double { get } - func makeContentNode(postbox: Postbox, audioSession: ManagedAudioSession) -> UniversalVideoContentNode & ASDisplayNode + func makeContentNode(accountId: AccountRecordId, postbox: Postbox, audioSession: ManagedAudioSession) -> UniversalVideoContentNode & ASDisplayNode func isEqual(to other: UniversalVideoContent) -> Bool } @@ -90,6 +90,7 @@ public enum UniversalVideoNodeFetchControl { } public final class UniversalVideoNode: ASDisplayNode { + private let accountId: AccountRecordId private let postbox: Postbox private let audioSession: ManagedAudioSession private let manager: UniversalVideoManager @@ -135,11 +136,12 @@ public final class UniversalVideoNode: ASDisplayNode { if self.canAttachContent { assert(self.contentRequestIndex == nil) + let accountId = self.accountId let content = self.content let postbox = self.postbox let audioSession = self.audioSession self.contentRequestIndex = self.manager.attachUniversalVideoContent(content: self.content, priority: self.priority, create: { - return content.makeContentNode(postbox: postbox, audioSession: audioSession) + return content.makeContentNode(accountId: accountId, postbox: postbox, audioSession: audioSession) }, update: { [weak self] contentNodeAndFlags in if let strongSelf = self { strongSelf.updateContentNode(contentNodeAndFlags) @@ -160,7 +162,8 @@ public final class UniversalVideoNode: ASDisplayNode { return self.contentNode != nil } - public init(postbox: Postbox, audioSession: ManagedAudioSession, manager: UniversalVideoManager, decoration: UniversalVideoDecoration, content: UniversalVideoContent, priority: UniversalVideoPriority, autoplay: Bool = false, snapshotContentWhenGone: Bool = false) { + public init(accountId: AccountRecordId, postbox: Postbox, audioSession: ManagedAudioSession, manager: UniversalVideoManager, decoration: UniversalVideoDecoration, content: UniversalVideoContent, priority: UniversalVideoPriority, autoplay: Bool = false, snapshotContentWhenGone: Bool = false) { + self.accountId = accountId self.postbox = postbox self.audioSession = audioSession self.manager = manager diff --git a/submodules/AvatarVideoNode/Sources/AvatarVideoNode.swift b/submodules/AvatarVideoNode/Sources/AvatarVideoNode.swift index 6cc94e44f2..85c701d480 100644 --- a/submodules/AvatarVideoNode/Sources/AvatarVideoNode.swift +++ b/submodules/AvatarVideoNode/Sources/AvatarVideoNode.swift @@ -234,7 +234,7 @@ public final class AvatarVideoNode: ASDisplayNode { if self.videoNode == nil { let context = self.context let mediaManager = context.sharedContext.mediaManager - let videoNode = UniversalVideoNode(postbox: context.account.postbox, audioSession: mediaManager.audioSession, manager: mediaManager.universalVideoManager, decoration: VideoDecoration(), content: videoContent, priority: .embedded) + let videoNode = UniversalVideoNode(accountId: context.account.id, postbox: context.account.postbox, audioSession: mediaManager.audioSession, manager: mediaManager.universalVideoManager, decoration: VideoDecoration(), content: videoContent, priority: .embedded) videoNode.clipsToBounds = true videoNode.isUserInteractionEnabled = false videoNode.isHidden = true diff --git a/submodules/Camera/Sources/Camera.swift b/submodules/Camera/Sources/Camera.swift index ca84dca958..f0a4d76653 100644 --- a/submodules/Camera/Sources/Camera.swift +++ b/submodules/Camera/Sources/Camera.swift @@ -102,7 +102,7 @@ final class CameraDeviceContext { return 30.0 } switch DeviceModel.current { - case .iPhone15ProMax, .iPhone14ProMax, .iPhone13ProMax: + case .iPhone15ProMax, .iPhone14ProMax, .iPhone13ProMax, .iPhone16ProMax: return 60.0 default: return 30.0 diff --git a/submodules/Camera/Sources/CameraMetrics.swift b/submodules/Camera/Sources/CameraMetrics.swift index 4d5c684da8..01d68996e1 100644 --- a/submodules/Camera/Sources/CameraMetrics.swift +++ b/submodules/Camera/Sources/CameraMetrics.swift @@ -34,6 +34,10 @@ public extension Camera { self = .iPhone15Pro case .iPhone15ProMax: self = .iPhone15ProMax + case .iPhone16Pro: + self = .iPhone15Pro + case .iPhone16ProMax: + self = .iPhone15ProMax case .unknown: self = .unknown default: diff --git a/submodules/ChatImportUI/Sources/ChatImportActivityScreen.swift b/submodules/ChatImportUI/Sources/ChatImportActivityScreen.swift index fcc1047125..9634edd0a8 100644 --- a/submodules/ChatImportUI/Sources/ChatImportActivityScreen.swift +++ b/submodules/ChatImportUI/Sources/ChatImportActivityScreen.swift @@ -464,7 +464,7 @@ public final class ChatImportActivityScreen: ViewController { let videoContent = NativeVideoContent(id: .message(1, EngineMedia.Id(namespace: 0, id: 1)), userLocation: .other, fileReference: .standalone(media: dummyFile), streamVideo: .none, loopVideo: true, enableSound: false, fetchAutomatically: true, onlyFullSizeThumbnail: false, continuePlayingWithoutSoundOnLostAudioSession: false, placeholderColor: .black, storeAfterDownload: nil) - let videoNode = UniversalVideoNode(postbox: context.account.postbox, audioSession: context.sharedContext.mediaManager.audioSession, manager: context.sharedContext.mediaManager.universalVideoManager, decoration: decoration, content: videoContent, priority: .embedded) + let videoNode = UniversalVideoNode(accountId: context.account.id, postbox: context.account.postbox, audioSession: context.sharedContext.mediaManager.audioSession, manager: context.sharedContext.mediaManager.universalVideoManager, decoration: decoration, content: videoContent, priority: .embedded) videoNode.frame = CGRect(origin: CGPoint(), size: CGSize(width: 2.0, height: 2.0)) videoNode.alpha = 0.01 self.videoNode = videoNode diff --git a/submodules/ComponentFlow/Source/Base/Transition.swift b/submodules/ComponentFlow/Source/Base/Transition.swift index b42f21063e..837eacb44d 100644 --- a/submodules/ComponentFlow/Source/Base/Transition.swift +++ b/submodules/ComponentFlow/Source/Base/Transition.swift @@ -1189,6 +1189,27 @@ public struct ComponentTransition { } } + public func animateTintColor(layer: CALayer, from: UIColor, to: UIColor, completion: ((Bool) -> Void)? = nil) { + switch self.animation { + case .none: + completion?(true) + case let .curve(duration, curve): + let previousColor: CGColor = from.cgColor + + layer.animate( + from: previousColor, + to: to.cgColor, + keyPath: "contentsMultiplyColor", + duration: duration, + delay: 0.0, + curve: curve, + removeOnCompletion: true, + additive: false, + completion: completion + ) + } + } + public func setGradientColors(layer: CAGradientLayer, colors: [UIColor], completion: ((Bool) -> Void)? = nil) { if let current = layer.colors { if current.count == colors.count { diff --git a/submodules/Display/Source/DeviceMetrics.swift b/submodules/Display/Source/DeviceMetrics.swift index b01c2a487d..302af10890 100644 --- a/submodules/Display/Source/DeviceMetrics.swift +++ b/submodules/Display/Source/DeviceMetrics.swift @@ -36,6 +36,8 @@ public enum DeviceMetrics: CaseIterable, Equatable { case iPhone14ProZoomed case iPhone14ProMax case iPhone14ProMaxZoomed + case iPhone16Pro + case iPhone16ProMax case iPad case iPadMini case iPad102Inch @@ -68,6 +70,8 @@ public enum DeviceMetrics: CaseIterable, Equatable { .iPhone14ProZoomed, .iPhone14ProMax, .iPhone14ProMaxZoomed, + .iPhone16Pro, + .iPhone16ProMax, .iPad, .iPadMini, .iPad102Inch, @@ -171,6 +175,10 @@ public enum DeviceMetrics: CaseIterable, Equatable { return CGSize(width: 430.0, height: 932.0) case .iPhone14ProMaxZoomed: return CGSize(width: 375.0, height: 812.0) + case .iPhone16Pro: + return CGSize(width: 402.0, height: 874.0) + case .iPhone16ProMax: + return CGSize(width: 440.0, height: 956.0) case .iPad: return CGSize(width: 768.0, height: 1024.0) case .iPadMini: @@ -204,6 +212,8 @@ public enum DeviceMetrics: CaseIterable, Equatable { return 53.0 + UIScreenPixel case .iPhone14Pro, .iPhone14ProMax: return 55.0 + case .iPhone16Pro, .iPhone16ProMax: + return 55.0 case let .unknown(_, _, _, screenCornerRadius): return screenCornerRadius default: @@ -213,7 +223,7 @@ public enum DeviceMetrics: CaseIterable, Equatable { func safeInsets(inLandscape: Bool) -> UIEdgeInsets { switch self { - case .iPhoneX, .iPhoneXSMax, .iPhoneXr, .iPhone12Mini, .iPhone12, .iPhone12ProMax, .iPhone13Mini, .iPhone13, .iPhone13Pro, .iPhone13ProMax, .iPhone14Pro, .iPhone14ProZoomed, .iPhone14ProMax, .iPhone14ProMaxZoomed: + case .iPhoneX, .iPhoneXSMax, .iPhoneXr, .iPhone12Mini, .iPhone12, .iPhone12ProMax, .iPhone13Mini, .iPhone13, .iPhone13Pro, .iPhone13ProMax, .iPhone14Pro, .iPhone14ProZoomed, .iPhone14ProMax, .iPhone14ProMaxZoomed, .iPhone16Pro, .iPhone16ProMax: return inLandscape ? UIEdgeInsets(top: 0.0, left: 44.0, bottom: 0.0, right: 44.0) : UIEdgeInsets(top: 44.0, left: 0.0, bottom: 0.0, right: 0.0) default: return UIEdgeInsets.zero @@ -222,7 +232,7 @@ public enum DeviceMetrics: CaseIterable, Equatable { public func onScreenNavigationHeight(inLandscape: Bool, systemOnScreenNavigationHeight: CGFloat?) -> CGFloat? { switch self { - case .iPhoneX, .iPhoneXSMax, .iPhoneXr, .iPhone12Mini, .iPhone12, .iPhone12ProMax, .iPhone13Mini, .iPhone13, .iPhone13Pro, .iPhone13ProMax, .iPhone14Pro, .iPhone14ProMax: + case .iPhoneX, .iPhoneXSMax, .iPhoneXr, .iPhone12Mini, .iPhone12, .iPhone12ProMax, .iPhone13Mini, .iPhone13, .iPhone13Pro, .iPhone13ProMax, .iPhone14Pro, .iPhone14ProMax, .iPhone16Pro, .iPhone16ProMax: return inLandscape ? 21.0 : 34.0 case .iPhone14ProZoomed: return inLandscape ? 21.0 : 28.0 @@ -262,6 +272,8 @@ public enum DeviceMetrics: CaseIterable, Equatable { return 54.0 case .iPhone14ProMaxZoomed: return 47.0 + case .iPhone16Pro, .iPhone16ProMax: + return 54.0 case .iPhoneX, .iPhoneXSMax, .iPhoneXr, .iPhone12Mini, .iPhone12, .iPhone12ProMax, .iPhone13Mini, .iPhone13, .iPhone13Pro, .iPhone13ProMax: return 44.0 case .iPadPro11Inch, .iPadPro3rdGen, .iPadMini, .iPadMini6thGen: @@ -280,7 +292,7 @@ public enum DeviceMetrics: CaseIterable, Equatable { return 162.0 case .iPhone6, .iPhone6Plus: return 163.0 - case .iPhoneX, .iPhoneXSMax, .iPhoneXr, .iPhone12Mini, .iPhone12, .iPhone12ProMax, .iPhone13Mini, .iPhone13, .iPhone13Pro, .iPhone13ProMax, .iPhone14Pro, .iPhone14ProZoomed, .iPhone14ProMax, .iPhone14ProMaxZoomed: + case .iPhoneX, .iPhoneXSMax, .iPhoneXr, .iPhone12Mini, .iPhone12, .iPhone12ProMax, .iPhone13Mini, .iPhone13, .iPhone13Pro, .iPhone13ProMax, .iPhone14Pro, .iPhone14ProZoomed, .iPhone14ProMax, .iPhone14ProMaxZoomed, .iPhone16Pro, .iPhone16ProMax: return 172.0 case .iPad, .iPad102Inch, .iPadPro10Inch: return 348.0 @@ -299,9 +311,9 @@ public enum DeviceMetrics: CaseIterable, Equatable { return 216.0 case .iPhone6Plus: return 226.0 - case .iPhoneX, .iPhone12Mini, .iPhone12, .iPhone13Mini, .iPhone13, .iPhone13Pro, .iPhone14Pro, .iPhone14ProZoomed, .iPhone14ProMaxZoomed: + case .iPhoneX, .iPhone12Mini, .iPhone12, .iPhone13Mini, .iPhone13, .iPhone13Pro, .iPhone14Pro, .iPhone14ProZoomed, .iPhone14ProMaxZoomed, .iPhone16Pro: return 292.0 - case .iPhoneXSMax, .iPhoneXr, .iPhone12ProMax, .iPhone13ProMax, .iPhone14ProMax: + case .iPhoneXSMax, .iPhoneXr, .iPhone12ProMax, .iPhone13ProMax, .iPhone14ProMax, .iPhone16ProMax: return 302.0 case .iPad, .iPad102Inch, .iPadPro10Inch: return 263.0 @@ -320,7 +332,7 @@ public enum DeviceMetrics: CaseIterable, Equatable { func predictiveInputHeight(inLandscape: Bool) -> CGFloat { if inLandscape { switch self { - case .iPhone4, .iPhone5, .iPhone6, .iPhone6Plus, .iPhoneX, .iPhoneXSMax, .iPhoneXr, .iPhone12Mini, .iPhone12, .iPhone12ProMax, .iPhone13Mini, .iPhone13, .iPhone13Pro, .iPhone13ProMax, .iPhone14Pro, .iPhone14ProZoomed, .iPhone14ProMax, .iPhone14ProMaxZoomed: + case .iPhone4, .iPhone5, .iPhone6, .iPhone6Plus, .iPhoneX, .iPhoneXSMax, .iPhoneXr, .iPhone12Mini, .iPhone12, .iPhone12ProMax, .iPhone13Mini, .iPhone13, .iPhone13Pro, .iPhone13ProMax, .iPhone14Pro, .iPhone14ProZoomed, .iPhone14ProMax, .iPhone14ProMaxZoomed, .iPhone16Pro, .iPhone16ProMax: return 37.0 case .iPad, .iPad102Inch, .iPadPro10Inch, .iPadPro11Inch, .iPadPro, .iPadPro3rdGen, .iPadMini, .iPadMini6thGen: return 50.0 @@ -331,7 +343,7 @@ public enum DeviceMetrics: CaseIterable, Equatable { switch self { case .iPhone4, .iPhone5: return 37.0 - case .iPhone6, .iPhoneX, .iPhoneXSMax, .iPhoneXr, .iPhone12Mini, .iPhone12, .iPhone12ProMax, .iPhone13Mini, .iPhone13, .iPhone13Pro, .iPhone13ProMax, .iPhone14Pro, .iPhone14ProZoomed, .iPhone14ProMax, .iPhone14ProMaxZoomed: + case .iPhone6, .iPhoneX, .iPhoneXSMax, .iPhoneXr, .iPhone12Mini, .iPhone12, .iPhone12ProMax, .iPhone13Mini, .iPhone13, .iPhone13Pro, .iPhone13ProMax, .iPhone14Pro, .iPhone14ProZoomed, .iPhone14ProMax, .iPhone14ProMaxZoomed, .iPhone16Pro, .iPhone16ProMax: return 44.0 case .iPhone6Plus: return 45.0 @@ -358,7 +370,7 @@ public enum DeviceMetrics: CaseIterable, Equatable { public var hasDynamicIsland: Bool { switch self { - case .iPhone14Pro, .iPhone14ProZoomed, .iPhone14ProMax, .iPhone14ProMaxZoomed: + case .iPhone14Pro, .iPhone14ProZoomed, .iPhone14ProMax, .iPhone14ProMaxZoomed, .iPhone16Pro, .iPhone16ProMax: return true default: return false diff --git a/submodules/DrawingUI/Sources/DrawingStickerEntityView.swift b/submodules/DrawingUI/Sources/DrawingStickerEntityView.swift index ae5a1f2e6e..c1c8f5d686 100644 --- a/submodules/DrawingUI/Sources/DrawingStickerEntityView.swift +++ b/submodules/DrawingUI/Sources/DrawingStickerEntityView.swift @@ -330,6 +330,7 @@ public class DrawingStickerEntityView: DrawingEntityView { private func setupWithVideo(_ file: TelegramMediaFile) { let videoNode = UniversalVideoNode( + accountId: self.context.account.id, postbox: self.context.account.postbox, audioSession: self.context.sharedContext.mediaManager.audioSession, manager: self.context.sharedContext.mediaManager.universalVideoManager, diff --git a/submodules/GalleryUI/Sources/ChatVideoGalleryItemScrubberView.swift b/submodules/GalleryUI/Sources/ChatVideoGalleryItemScrubberView.swift index bee6431d99..b98b543865 100644 --- a/submodules/GalleryUI/Sources/ChatVideoGalleryItemScrubberView.swift +++ b/submodules/GalleryUI/Sources/ChatVideoGalleryItemScrubberView.swift @@ -9,6 +9,7 @@ import UniversalMediaPlayer import TelegramPresentationData import RangeSet import ShimmerEffect +import TelegramUniversalVideoContent private let textFont = Font.with(size: 13.0, design: .regular, weight: .regular, traits: [.monospacedNumbers]) diff --git a/submodules/GalleryUI/Sources/GalleryController.swift b/submodules/GalleryUI/Sources/GalleryController.swift index c5e76739a1..1f1e17ba77 100644 --- a/submodules/GalleryUI/Sources/GalleryController.swift +++ b/submodules/GalleryUI/Sources/GalleryController.swift @@ -245,7 +245,7 @@ public func galleryItemForEntry( content = NativeVideoContent(id: .message(message.stableId, file.fileId), userLocation: .peer(message.id.peerId), fileReference: .message(message: MessageReference(message), media: file), imageReference: mediaImage.flatMap({ ImageMediaReference.message(message: MessageReference(message), media: $0) }), loopVideo: true, enableSound: false, tempFilePath: tempFilePath, captureProtected: captureProtected, storeAfterDownload: generateStoreAfterDownload?(message, file)) } else { if true || (file.mimeType == "video/mpeg4" || file.mimeType == "video/mov" || file.mimeType == "video/mp4") { - if NativeVideoContent.isHLSVideo(file: file), context.sharedContext.immediateExperimentalUISettings.dynamicStreaming { + if NativeVideoContent.isHLSVideo(file: file) { content = HLSVideoContent(id: .message(message.id, message.stableId, file.fileId), userLocation: .peer(message.id.peerId), fileReference: .message(message: MessageReference(message), media: file), streamVideo: streamVideos, loopVideo: loopVideos) } else { content = NativeVideoContent(id: .message(message.stableId, file.fileId), userLocation: .peer(message.id.peerId), fileReference: .message(message: MessageReference(message), media: file), imageReference: mediaImage.flatMap({ ImageMediaReference.message(message: MessageReference(message), media: $0) }), streamVideo: .conservative, loopVideo: loopVideos, tempFilePath: tempFilePath, captureProtected: captureProtected, storeAfterDownload: generateStoreAfterDownload?(message, file)) @@ -578,6 +578,7 @@ public class GalleryController: ViewController, StandalonePresentableController, private let landscape: Bool private let timecode: Double? private var playbackRate: Double? + private var videoQuality: UniversalVideoContentVideoQuality = .auto private let accountInUseDisposable = MetaDisposable() private let disposable = MetaDisposable() @@ -1757,6 +1758,16 @@ public class GalleryController: ViewController, StandalonePresentableController, } } + func updateSharedVideoQuality(_ videoQuality: UniversalVideoContentVideoQuality) { + self.videoQuality = videoQuality + + self.galleryNode.pager.forEachItemNode { itemNode in + if let itemNode = itemNode as? UniversalVideoGalleryItemNode { + itemNode.updateVideoQuality(videoQuality) + } + } + } + public var keyShortcuts: [KeyShortcut] { var keyShortcuts: [KeyShortcut] = [] keyShortcuts.append( diff --git a/submodules/GalleryUI/Sources/Items/UniversalVideoGalleryItem.swift b/submodules/GalleryUI/Sources/Items/UniversalVideoGalleryItem.swift index 8efcf39fdc..ac455d91b6 100644 --- a/submodules/GalleryUI/Sources/Items/UniversalVideoGalleryItem.swift +++ b/submodules/GalleryUI/Sources/Items/UniversalVideoGalleryItem.swift @@ -769,6 +769,8 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode { private var moreBarButtonRate: Double = 1.0 private var moreBarButtonRateTimestamp: Double? + private let settingsBarButton: MoreHeaderButton + private var videoNode: UniversalVideoNode? private var videoNodeUserInteractionEnabled: Bool = false private var videoFramePreview: FramePreview? @@ -798,6 +800,7 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode { private var item: UniversalVideoGalleryItem? private var playbackRate: Double? + private var videoQuality: UniversalVideoContentVideoQuality = .auto private let playbackRatePromise = ValuePromise() private let statusDisposable = MetaDisposable() @@ -849,11 +852,15 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode { self.moreBarButton.isUserInteractionEnabled = true self.moreBarButton.setContent(.more(optionsCircleImage(dark: false))) + self.settingsBarButton = MoreHeaderButton() + self.settingsBarButton.isUserInteractionEnabled = true + super.init() self.clipsToBounds = true self.moreBarButton.addTarget(self, action: #selector(self.moreButtonPressed), forControlEvents: .touchUpInside) + self.settingsBarButton.addTarget(self, action: #selector(self.settingsButtonPressed), forControlEvents: .touchUpInside) self.footerContentNode.interacting = { [weak self] value in self?.isInteractingPromise.set(value) @@ -968,7 +975,10 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode { } self.moreBarButton.contextAction = { [weak self] sourceNode, gesture in - self?.openMoreMenu(sourceNode: sourceNode, gesture: gesture) + guard let self else { + return + } + self.openMoreMenu(sourceNode: self.moreBarButton.referenceNode, gesture: gesture, isSettings: false) } self.titleContentView = GalleryTitleView(frame: CGRect()) @@ -1116,6 +1126,7 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode { var forceEnableUserInteraction = false var isAnimated = false var isEnhancedWebPlayer = false + var isAdaptive = false if let content = item.content as? NativeVideoContent { isAnimated = content.fileReference.media.isAnimated self.videoFramePreview = MediaPlayerFramePreview(postbox: item.context.account.postbox, userLocation: content.userLocation, userContentType: .video, fileReference: content.fileReference) @@ -1139,6 +1150,14 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode { } else if let _ = item.content as? PlatformVideoContent { disablePlayerControls = true forceEnablePiP = true + } else if let _ = item.content as? HLSVideoContent { + isAdaptive = true + } + + if isAdaptive { + self.settingsBarButton.setContent(.image(generateTintedImage(image: UIImage(bundleImageName: "Media Gallery/NavigationSettingsQAuto"), color: .white))) + } else { + self.settingsBarButton.setContent(.image(generateTintedImage(image: UIImage(bundleImageName: "Media Gallery/NavigationSettings"), color: .white))) } let dimensions = item.content.dimensions @@ -1159,7 +1178,7 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode { let mediaManager = item.context.sharedContext.mediaManager - let videoNode = UniversalVideoNode(postbox: item.context.account.postbox, audioSession: mediaManager.audioSession, manager: mediaManager.universalVideoManager, decoration: GalleryVideoDecoration(), content: item.content, priority: .gallery) + let videoNode = UniversalVideoNode(accountId: item.context.account.id, postbox: item.context.account.postbox, audioSession: mediaManager.audioSession, manager: mediaManager.universalVideoManager, decoration: GalleryVideoDecoration(), content: item.content, priority: .gallery) let videoScale: CGFloat if item.content is WebEmbedVideoContent { videoScale = 1.0 @@ -1259,7 +1278,7 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode { } } let status = messageMediaFileStatus(context: item.context, messageId: message.id, file: file) - if !isWebpage && message.adAttribute == nil { + if !isWebpage && message.adAttribute == nil && !NativeVideoContent.isHLSVideo(file: file) { scrubberView.setFetchStatusSignal(status, strings: self.presentationData.strings, decimalSeparator: self.presentationData.dateTimeFormat.decimalSeparator, fileSize: file.size) } @@ -1453,6 +1472,7 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode { rightBarButtonItem.accessibilityLabel = self.presentationData.strings.Gallery_VoiceOver_Stickers barButtonItems.append(rightBarButtonItem) } + if forceEnablePiP || (!isAnimated && !disablePlayerControls && !disablePictureInPicture) { let rightBarButtonItem = UIBarButtonItem(image: pictureInPictureButtonImage, style: .plain, target: self, action: #selector(self.pictureInPictureButtonPressed)) rightBarButtonItem.accessibilityLabel = self.presentationData.strings.Gallery_VoiceOver_PictureInPicture @@ -1497,6 +1517,12 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode { hasMoreButton = true } + if !isAnimated && !disablePlayerControls { + let settingsMenuItem = UIBarButtonItem(customDisplayNode: self.settingsBarButton)! + settingsMenuItem.accessibilityLabel = self.presentationData.strings.Settings_Title + barButtonItems.append(settingsMenuItem) + } + if hasMoreButton { let moreMenuItem = UIBarButtonItem(customDisplayNode: self.moreBarButton)! moreMenuItem.accessibilityLabel = self.presentationData.strings.Common_More @@ -2179,7 +2205,7 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode { let baseNavigationController = self.baseNavigationController() let mediaManager = self.context.sharedContext.mediaManager var expandImpl: (() -> Void)? - let overlayNode = OverlayUniversalVideoNode(postbox: self.context.account.postbox, audioSession: context.sharedContext.mediaManager.audioSession, manager: context.sharedContext.mediaManager.universalVideoManager, content: item.content, expand: { + let overlayNode = OverlayUniversalVideoNode(accountId: self.context.account.id, postbox: self.context.account.postbox, audioSession: context.sharedContext.mediaManager.audioSession, manager: context.sharedContext.mediaManager.universalVideoManager, content: item.content, expand: { expandImpl?() }, close: { [weak mediaManager] in mediaManager?.setOverlayVideoNode(nil) @@ -2281,7 +2307,7 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode { if #available(iOSApplicationExtension 15.0, iOS 15.0, *), AVPictureInPictureController.isPictureInPictureSupported(), isNativePictureInPictureSupported { self.disablePictureInPicturePlaceholder = true - let overlayVideoNode = UniversalVideoNode(postbox: self.context.account.postbox, audioSession: self.context.sharedContext.mediaManager.audioSession, manager: self.context.sharedContext.mediaManager.universalVideoManager, decoration: GalleryVideoDecoration(), content: item.content, priority: .overlay) + let overlayVideoNode = UniversalVideoNode(accountId: self.context.account.id, postbox: self.context.account.postbox, audioSession: self.context.sharedContext.mediaManager.audioSession, manager: self.context.sharedContext.mediaManager.universalVideoManager, decoration: GalleryVideoDecoration(), content: item.content, priority: .overlay) let absoluteRect = videoNode.view.convert(videoNode.view.bounds, to: nil) overlayVideoNode.frame = absoluteRect overlayVideoNode.updateLayout(size: absoluteRect.size, transition: .immediate) @@ -2364,7 +2390,7 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode { shouldBeDismissed = .single(false) } - let overlayNode = OverlayUniversalVideoNode(postbox: self.context.account.postbox, audioSession: context.sharedContext.mediaManager.audioSession, manager: context.sharedContext.mediaManager.universalVideoManager, content: item.content, shouldBeDismissed: shouldBeDismissed, expand: { + let overlayNode = OverlayUniversalVideoNode(accountId: self.context.account.id, postbox: self.context.account.postbox, audioSession: context.sharedContext.mediaManager.audioSession, manager: context.sharedContext.mediaManager.universalVideoManager, content: item.content, shouldBeDismissed: shouldBeDismissed, expand: { expandImpl?() }, close: { [weak mediaManager] in mediaManager?.setOverlayVideoNode(nil) @@ -2511,7 +2537,7 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode { self.moreBarButton.contextAction?(self.moreBarButton.containerNode, nil) } - private func openMoreMenu(sourceNode: ASDisplayNode, gesture: ContextGesture?) { + private func openMoreMenu(sourceNode: ContextReferenceContentNode, gesture: ContextGesture?, isSettings: Bool) { guard let controller = self.baseNavigationController()?.topViewController as? ViewController else { return } @@ -2520,12 +2546,12 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode { if case let .message(message, _) = self.item?.contentInfo, let _ = message.adAttribute { items = self.adMenuMainItems() } else { - items = self.contextMenuMainItems(dismiss: { + items = self.contextMenuMainItems(isSettings: isSettings, dismiss: { dismissImpl?() }) } - let contextController = ContextController(presentationData: self.presentationData.withUpdated(theme: defaultDarkColorPresentationTheme), source: .reference(HeaderContextReferenceContentSource(controller: controller, sourceNode: self.moreBarButton.referenceNode)), items: items |> map { ContextController.Items(content: .list($0)) }, gesture: gesture) + let contextController = ContextController(presentationData: self.presentationData.withUpdated(theme: defaultDarkColorPresentationTheme), source: .reference(HeaderContextReferenceContentSource(controller: controller, sourceNode: sourceNode)), items: items |> map { ContextController.Items(content: .list($0)) }, gesture: gesture) self.isShowingContextMenuPromise.set(true) controller.presentInGlobalOverlay(contextController) dismissImpl = { [weak contextController] in @@ -2676,7 +2702,7 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode { } - private func contextMenuMainItems(dismiss: @escaping () -> Void) -> Signal<[ContextMenuItem], NoError> { + private func contextMenuMainItems(isSettings: Bool, dismiss: @escaping () -> Void) -> Signal<[ContextMenuItem], NoError> { guard let videoNode = self.videoNode, let item = self.item else { return .single([]) } @@ -2697,172 +2723,172 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode { var items: [ContextMenuItem] = [] - var speedValue: String = strongSelf.presentationData.strings.PlaybackSpeed_Normal - var speedIconText: String = "1x" - var didSetSpeedValue = false - for (text, iconText, speed) in strongSelf.speedList(strings: strongSelf.presentationData.strings) { - if abs(speed - status.baseRate) < 0.01 { - speedValue = text - speedIconText = iconText - didSetSpeedValue = true - break - } - } - if !didSetSpeedValue && status.baseRate != 1.0 { - speedValue = String(format: "%.1fx", status.baseRate) - speedIconText = speedValue - } - - items.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.strings.PlaybackSpeed_Title, textLayout: .secondLineWithValue(speedValue), icon: { theme in - return optionsRateImage(rate: speedIconText, isLarge: false, color: theme.contextMenu.primaryColor) - }, action: { c, _ in - guard let strongSelf = self else { - c?.dismiss(completion: nil) - return - } - - c?.setItems(strongSelf.contextMenuSpeedItems(dismiss: dismiss) |> map { ContextController.Items(content: .list($0)) }, minHeight: nil, animated: true) - }))) - - items.append(.separator) - - if let videoQualityState = strongSelf.videoNode?.videoQualityState(), !videoQualityState.available.isEmpty { - //TODO:localize - - let qualityText: String - switch videoQualityState.preferred { - case .auto: - if videoQualityState.current != 0 { - qualityText = "Auto (\(videoQualityState.current)p)" - } else { - qualityText = "Auto" + if isSettings { + var speedValue: String = strongSelf.presentationData.strings.PlaybackSpeed_Normal + var speedIconText: String = "1x" + var didSetSpeedValue = false + for (text, iconText, speed) in strongSelf.speedList(strings: strongSelf.presentationData.strings) { + if abs(speed - status.baseRate) < 0.01 { + speedValue = text + speedIconText = iconText + didSetSpeedValue = true + break } - case let .quality(value): - qualityText = "\(value)p" + } + if !didSetSpeedValue && status.baseRate != 1.0 { + speedValue = String(format: "%.1fx", status.baseRate) + speedIconText = speedValue } - items.append(.action(ContextMenuActionItem(text: "Video Quality", textLayout: .secondLineWithValue(qualityText), icon: { _ in - return nil + items.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.strings.PlaybackSpeed_Title, textLayout: .secondLineWithValue(speedValue), icon: { theme in + return optionsRateImage(rate: speedIconText, isLarge: false, color: theme.contextMenu.primaryColor) }, action: { c, _ in guard let strongSelf = self else { c?.dismiss(completion: nil) return } - - c?.setItems(.single(ContextController.Items(content: .list(strongSelf.contextMenuVideoQualityItems(dismiss: dismiss)))), minHeight: nil, animated: true) + + c?.pushItems(items: strongSelf.contextMenuSpeedItems(dismiss: dismiss) |> map { ContextController.Items(content: .list($0)) }) }))) - items.append(.separator) - } - - if let (message, _, _) = strongSelf.contentInfo() { - let context = strongSelf.context - items.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.strings.SharedMedia_ViewInChat, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/GoToMessage"), color: theme.contextMenu.primaryColor)}, action: { [weak self] _, f in - guard let strongSelf = self, let peer = peer else { - return + if let videoQualityState = strongSelf.videoNode?.videoQualityState(), !videoQualityState.available.isEmpty { + items.append(.separator) + + //TODO:localize + + let qualityText: String + switch videoQualityState.preferred { + case .auto: + if videoQualityState.current != 0 { + qualityText = "Auto (\(videoQualityState.current)p)" + } else { + qualityText = "Auto" + } + case let .quality(value): + qualityText = "\(value)p" } - if let navigationController = strongSelf.baseNavigationController() { - strongSelf.beginCustomDismiss(true) + + items.append(.action(ContextMenuActionItem(text: "Video Quality", textLayout: .secondLineWithValue(qualityText), icon: { _ in + return nil + }, action: { c, _ in + guard let strongSelf = self else { + c?.dismiss(completion: nil) + return + } - context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: context, chatLocation: .peer(peer), subject: .message(id: .id(message.id), highlight: ChatControllerSubject.MessageHighlight(quote: nil), timecode: nil, setupReply: false))) - - Queue.mainQueue().after(0.3) { - strongSelf.completeCustomDismiss() + c?.pushItems(items: .single(ContextController.Items(content: .list(strongSelf.contextMenuVideoQualityItems(dismiss: dismiss))))) + }))) + } + } else { + if let (message, _, _) = strongSelf.contentInfo() { + let context = strongSelf.context + items.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.strings.SharedMedia_ViewInChat, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/GoToMessage"), color: theme.contextMenu.primaryColor)}, action: { [weak self] _, f in + guard let strongSelf = self, let peer = peer else { + return + } + if let navigationController = strongSelf.baseNavigationController() { + strongSelf.beginCustomDismiss(true) + + context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: context, chatLocation: .peer(peer), subject: .message(id: .id(message.id), highlight: ChatControllerSubject.MessageHighlight(quote: nil), timecode: nil, setupReply: false))) + + Queue.mainQueue().after(0.3) { + strongSelf.completeCustomDismiss() + } + } + f(.default) + }))) + } + + // if #available(iOS 11.0, *) { + // items.append(.action(ContextMenuActionItem(text: "AirPlay", textColor: .primary, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Media Gallery/AirPlay"), color: theme.contextMenu.primaryColor) }, action: { [weak self] _, f in + // f(.default) + // guard let strongSelf = self else { + // return + // } + // strongSelf.beginAirPlaySetup() + // }))) + // } + + if let (message, _, _) = strongSelf.contentInfo() { + for media in message.media { + if let webpage = media as? TelegramMediaWebpage, case let .Loaded(content) = webpage.content { + let url = content.url + + let item = OpenInItem.url(url: url) + let openText = strongSelf.presentationData.strings.Conversation_FileOpenIn + items.append(.action(ContextMenuActionItem(text: openText, textColor: .primary, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Share"), color: theme.contextMenu.primaryColor) }, action: { _, f in + f(.default) + + if let strongSelf = self, let controller = strongSelf.galleryController() { + var presentationData = strongSelf.context.sharedContext.currentPresentationData.with { $0 } + if !presentationData.theme.overallDarkAppearance { + presentationData = presentationData.withUpdated(theme: defaultDarkColorPresentationTheme) + } + let actionSheet = OpenInActionSheetController(context: strongSelf.context, forceTheme: presentationData.theme, item: item, openUrl: { [weak self] url in + if let strongSelf = self { + strongSelf.context.sharedContext.openExternalUrl(context: strongSelf.context, urlContext: .generic, url: url, forceExternal: true, presentationData: presentationData, navigationController: strongSelf.baseNavigationController(), dismissInput: {}) + } + }) + controller.present(actionSheet, in: .window(.root)) + } + }))) + break } } - f(.default) - }))) - } - -// if #available(iOS 11.0, *) { -// items.append(.action(ContextMenuActionItem(text: "AirPlay", textColor: .primary, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Media Gallery/AirPlay"), color: theme.contextMenu.primaryColor) }, action: { [weak self] _, f in -// f(.default) -// guard let strongSelf = self else { -// return -// } -// strongSelf.beginAirPlaySetup() -// }))) -// } - - if let (message, _, _) = strongSelf.contentInfo() { - for media in message.media { - if let webpage = media as? TelegramMediaWebpage, case let .Loaded(content) = webpage.content { - let url = content.url - - let item = OpenInItem.url(url: url) - let openText = strongSelf.presentationData.strings.Conversation_FileOpenIn - items.append(.action(ContextMenuActionItem(text: openText, textColor: .primary, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Share"), color: theme.contextMenu.primaryColor) }, action: { _, f in - f(.default) - - if let strongSelf = self, let controller = strongSelf.galleryController() { - var presentationData = strongSelf.context.sharedContext.currentPresentationData.with { $0 } - if !presentationData.theme.overallDarkAppearance { - presentationData = presentationData.withUpdated(theme: defaultDarkColorPresentationTheme) - } - let actionSheet = OpenInActionSheetController(context: strongSelf.context, forceTheme: presentationData.theme, item: item, openUrl: { [weak self] url in - if let strongSelf = self { - strongSelf.context.sharedContext.openExternalUrl(context: strongSelf.context, urlContext: .generic, url: url, forceExternal: true, presentationData: presentationData, navigationController: strongSelf.baseNavigationController(), dismissInput: {}) - } - }) - controller.present(actionSheet, in: .window(.root)) - } - }))) - break - } } - } - - if let (message, maybeFile, _) = strongSelf.contentInfo(), let file = maybeFile, !message.isCopyProtected() && !item.peerIsCopyProtected && message.paidContent == nil { - items.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.strings.Gallery_SaveVideo, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Download"), color: theme.actionSheet.primaryTextColor) }, action: { _, f in - f(.default) - - if let strongSelf = self { - switch strongSelf.fetchStatus { - case .Local: - let _ = (SaveToCameraRoll.saveToCameraRoll(context: strongSelf.context, postbox: strongSelf.context.account.postbox, userLocation: .peer(message.id.peerId), mediaReference: .message(message: MessageReference(message), media: file)) - |> deliverOnMainQueue).start(completed: { - guard let strongSelf = self else { - return - } + + if let (message, maybeFile, _) = strongSelf.contentInfo(), let file = maybeFile, !message.isCopyProtected() && !item.peerIsCopyProtected && message.paidContent == nil { + items.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.strings.Gallery_SaveVideo, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Download"), color: theme.actionSheet.primaryTextColor) }, action: { _, f in + f(.default) + + if let strongSelf = self { + switch strongSelf.fetchStatus { + case .Local: + let _ = (SaveToCameraRoll.saveToCameraRoll(context: strongSelf.context, postbox: strongSelf.context.account.postbox, userLocation: .peer(message.id.peerId), mediaReference: .message(message: MessageReference(message), media: file)) + |> deliverOnMainQueue).start(completed: { + guard let strongSelf = self else { + return + } + guard let controller = strongSelf.galleryController() else { + return + } + controller.present(UndoOverlayController(presentationData: strongSelf.presentationData, content: .mediaSaved(text: strongSelf.presentationData.strings.Gallery_VideoSaved), elevatedLayout: false, animateInAsReplacement: false, action: { _ in return false }), in: .window(.root)) + }) + default: guard let controller = strongSelf.galleryController() else { return } - controller.present(UndoOverlayController(presentationData: strongSelf.presentationData, content: .mediaSaved(text: strongSelf.presentationData.strings.Gallery_VideoSaved), elevatedLayout: false, animateInAsReplacement: false, action: { _ in return false }), in: .window(.root)) - }) - default: - guard let controller = strongSelf.galleryController() else { - return + controller.present(textAlertController(context: strongSelf.context, title: nil, text: strongSelf.presentationData.strings.Gallery_WaitForVideoDownoad, actions: [TextAlertAction(type: .defaultAction, title: strongSelf.presentationData.strings.Common_OK, action: { + })]), in: .window(.root)) } - controller.present(textAlertController(context: strongSelf.context, title: nil, text: strongSelf.presentationData.strings.Gallery_WaitForVideoDownoad, actions: [TextAlertAction(type: .defaultAction, title: strongSelf.presentationData.strings.Common_OK, action: { - })]), in: .window(.root)) } - } - }))) - } - - if let peer, let (message, _, _) = strongSelf.contentInfo(), canSendMessagesToPeer(peer._asPeer()) { - items.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.strings.Conversation_ContextMenuReply, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Reply"), color: theme.contextMenu.primaryColor)}, action: { [weak self] _, f in - if let self, let navigationController = self.baseNavigationController() { - self.beginCustomDismiss(true) - - context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: context, chatLocation: .peer(peer), subject: .message(id: .id(message.id), highlight: ChatControllerSubject.MessageHighlight(quote: nil), timecode: nil, setupReply: true))) - - Queue.mainQueue().after(0.3) { - self.completeCustomDismiss() + }))) + } + + if let peer, let (message, _, _) = strongSelf.contentInfo(), canSendMessagesToPeer(peer._asPeer()) { + items.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.strings.Conversation_ContextMenuReply, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Reply"), color: theme.contextMenu.primaryColor)}, action: { [weak self] _, f in + if let self, let navigationController = self.baseNavigationController() { + self.beginCustomDismiss(true) + + context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: context, chatLocation: .peer(peer), subject: .message(id: .id(message.id), highlight: ChatControllerSubject.MessageHighlight(quote: nil), timecode: nil, setupReply: true))) + + Queue.mainQueue().after(0.3) { + self.completeCustomDismiss() + } } - } - f(.default) - }))) - } - - if strongSelf.canDelete() { - items.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.strings.Common_Delete, textColor: .destructive, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Delete"), color: theme.contextMenu.destructiveColor) }, action: { _, f in - f(.default) - - if let strongSelf = self { - strongSelf.footerContentNode.deleteButtonPressed() - } - }))) + f(.default) + }))) + } + + if strongSelf.canDelete() { + items.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.strings.Common_Delete, textColor: .destructive, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Delete"), color: theme.contextMenu.destructiveColor) }, action: { _, f in + f(.default) + + if let strongSelf = self { + strongSelf.footerContentNode.deleteButtonPressed() + } + }))) + } } return items @@ -2887,11 +2913,7 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode { items.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.strings.Common_Back, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Back"), color: theme.actionSheet.primaryTextColor) }, iconPosition: .left, action: { c, _ in - guard let strongSelf = self else { - c?.dismiss(completion: nil) - return - } - c?.setItems(strongSelf.contextMenuMainItems(dismiss: dismiss) |> map { ContextController.Items(content: .list($0)) }, minHeight: nil, animated: true) + c?.popItems() }))) let sliderValuePromise = ValuePromise(nil) @@ -2948,12 +2970,8 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode { items.append(.action(ContextMenuActionItem(text: self.presentationData.strings.Common_Back, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Back"), color: theme.actionSheet.primaryTextColor) - }, iconPosition: .left, action: { [weak self] c, _ in - guard let self else { - c?.dismiss(completion: nil) - return - } - c?.setItems(self.contextMenuMainItems(dismiss: dismiss) |> map { ContextController.Items(content: .list($0)) }, minHeight: nil, animated: true) + }, iconPosition: .left, action: { c, _ in + c?.popItems() }))) do { @@ -2977,6 +2995,7 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode { return } videoNode.setVideoQuality(.auto) + self.settingsBarButton.setContent(.image(generateTintedImage(image: UIImage(bundleImageName: "Media Gallery/NavigationSettingsQAuto"), color: .white))) /*if let controller = strongSelf.galleryController() as? GalleryController { controller.updateSharedPlaybackRate(rate) @@ -3000,6 +3019,11 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode { return } videoNode.setVideoQuality(.quality(quality)) + if quality >= 700 { + self.settingsBarButton.setContent(.image(generateTintedImage(image: UIImage(bundleImageName: "Media Gallery/NavigationSettingsQHD"), color: .white))) + } else { + self.settingsBarButton.setContent(.image(generateTintedImage(image: UIImage(bundleImageName: "Media Gallery/NavigationSettingsQSD"), color: .white))) + } /*if let controller = strongSelf.galleryController() as? GalleryController { controller.updateSharedPlaybackRate(rate) @@ -3092,6 +3116,10 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode { }) } + @objc private func settingsButtonPressed() { + self.openMoreMenu(sourceNode: self.settingsBarButton.referenceNode, gesture: nil, isSettings: true) + } + override func adjustForPreviewing() { super.adjustForPreviewing() @@ -3112,6 +3140,12 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode { self.playbackRatePromise.set(self.playbackRate ?? 1.0) } + func updateVideoQuality(_ videoQuality: UniversalVideoContentVideoQuality) { + self.videoQuality = videoQuality + + self.videoNode?.setVideoQuality(videoQuality) + } + public func seekToStart() { self.videoNode?.seek(0.0) self.videoNode?.play() diff --git a/submodules/InstantPageUI/Sources/InstantPagePlayableVideoNode.swift b/submodules/InstantPageUI/Sources/InstantPagePlayableVideoNode.swift index df36f4c416..cdbc22dc5f 100644 --- a/submodules/InstantPageUI/Sources/InstantPagePlayableVideoNode.swift +++ b/submodules/InstantPageUI/Sources/InstantPagePlayableVideoNode.swift @@ -58,7 +58,7 @@ final class InstantPagePlayableVideoNode: ASDisplayNode, InstantPageNode, Galler fileValue = file } - self.videoNode = UniversalVideoNode(postbox: context.account.postbox, audioSession: context.sharedContext.mediaManager.audioSession, manager: context.sharedContext.mediaManager.universalVideoManager, decoration: GalleryVideoDecoration(), content: NativeVideoContent(id: .instantPage(webPage.webpageId, media.media.id!), userLocation: userLocation, fileReference: .webPage(webPage: WebpageReference(webPage), media: fileValue!), imageReference: imageReference, streamVideo: streamVideo ? .conservative : .none, loopVideo: true, enableSound: false, fetchAutomatically: true, placeholderColor: theme.pageBackgroundColor, storeAfterDownload: nil), priority: .embedded, autoplay: true) + self.videoNode = UniversalVideoNode(accountId: context.account.id, postbox: context.account.postbox, audioSession: context.sharedContext.mediaManager.audioSession, manager: context.sharedContext.mediaManager.universalVideoManager, decoration: GalleryVideoDecoration(), content: NativeVideoContent(id: .instantPage(webPage.webpageId, media.media.id!), userLocation: userLocation, fileReference: .webPage(webPage: WebpageReference(webPage), media: fileValue!), imageReference: imageReference, streamVideo: streamVideo ? .conservative : .none, loopVideo: true, enableSound: false, fetchAutomatically: true, placeholderColor: theme.pageBackgroundColor, storeAfterDownload: nil), priority: .embedded, autoplay: true) self.videoNode.isUserInteractionEnabled = false self.statusNode = RadialStatusNode(backgroundNodeColor: UIColor(white: 0.0, alpha: 0.6)) diff --git a/submodules/PasscodeUI/Sources/PasscodeLayout.swift b/submodules/PasscodeUI/Sources/PasscodeLayout.swift index 5819862754..c5032937c6 100644 --- a/submodules/PasscodeUI/Sources/PasscodeLayout.swift +++ b/submodules/PasscodeUI/Sources/PasscodeLayout.swift @@ -67,7 +67,7 @@ struct PasscodeKeyboardLayout { self.topOffset = 226.0 self.biometricsOffset = 30.0 self.deleteOffset = 20.0 - case .iPhoneX, .iPhone12Mini, .iPhone12, .iPhone13Mini, .iPhone13, .iPhone13Pro, .iPhone14Pro, .iPhone14ProZoomed, .iPhone14ProMaxZoomed: + case .iPhoneX, .iPhone12Mini, .iPhone12, .iPhone13Mini, .iPhone13, .iPhone13Pro, .iPhone14Pro, .iPhone14ProZoomed, .iPhone14ProMaxZoomed, .iPhone16Pro: self.buttonSize = 75.0 self.horizontalSecond = 103.0 self.horizontalThird = 206.0 @@ -78,7 +78,7 @@ struct PasscodeKeyboardLayout { self.topOffset = 294.0 self.biometricsOffset = 30.0 self.deleteOffset = 20.0 - case .iPhoneXSMax, .iPhoneXr, .iPhone12ProMax, .iPhone13ProMax, .iPhone14ProMax: + case .iPhoneXSMax, .iPhoneXr, .iPhone12ProMax, .iPhone13ProMax, .iPhone14ProMax, .iPhone16ProMax: self.buttonSize = 85.0 self.horizontalSecond = 115.0 self.horizontalThird = 230.0 @@ -151,11 +151,11 @@ public struct PasscodeLayout { self.titleOffset = 112.0 self.subtitleOffset = -6.0 self.inputFieldOffset = 156.0 - case .iPhoneX, .iPhone12Mini, .iPhone12, .iPhone13Mini, .iPhone13, .iPhone13Pro, .iPhone14Pro, .iPhone14ProZoomed, .iPhone14ProMaxZoomed: + case .iPhoneX, .iPhone12Mini, .iPhone12, .iPhone13Mini, .iPhone13, .iPhone13Pro, .iPhone14Pro, .iPhone14ProZoomed, .iPhone14ProMaxZoomed, .iPhone16Pro: self.titleOffset = 162.0 self.subtitleOffset = 0.0 self.inputFieldOffset = 206.0 - case .iPhoneXSMax, .iPhoneXr, .iPhone12ProMax, .iPhone13ProMax, .iPhone14ProMax: + case .iPhoneXSMax, .iPhoneXr, .iPhone12ProMax, .iPhone13ProMax, .iPhone14ProMax, .iPhone16ProMax: self.titleOffset = 180.0 self.subtitleOffset = 0.0 self.inputFieldOffset = 226.0 diff --git a/submodules/PeerAvatarGalleryUI/Sources/PeerAvatarImageGalleryItem.swift b/submodules/PeerAvatarGalleryUI/Sources/PeerAvatarImageGalleryItem.swift index 5d15ce66df..759746fba5 100644 --- a/submodules/PeerAvatarGalleryUI/Sources/PeerAvatarImageGalleryItem.swift +++ b/submodules/PeerAvatarGalleryUI/Sources/PeerAvatarImageGalleryItem.swift @@ -281,7 +281,7 @@ final class PeerAvatarImageGalleryItemNode: ZoomableContentGalleryItemNode { let mediaManager = self.context.sharedContext.mediaManager let videoFileReference = FileMediaReference.avatarList(peer: peerReference, media: TelegramMediaFile(fileId: EngineMedia.Id(namespace: Namespaces.Media.LocalFile, id: 0), partialReference: nil, resource: video.representation.resource, previewRepresentations: representations.map { $0.representation }, videoThumbnails: [], immediateThumbnailData: entry.immediateThumbnailData, mimeType: "video/mp4", size: nil, attributes: [.Animated, .Video(duration: 0, size: video.representation.dimensions, flags: [], preloadSize: nil, coverTime: nil, videoCodec: nil)], alternativeRepresentations: [])) let videoContent = NativeVideoContent(id: .profileVideo(id, category), userLocation: .other, fileReference: videoFileReference, streamVideo: isMediaStreamable(resource: video.representation.resource) ? .conservative : .none, loopVideo: true, enableSound: false, fetchAutomatically: true, onlyFullSizeThumbnail: true, useLargeThumbnail: true, continuePlayingWithoutSoundOnLostAudioSession: false, placeholderColor: .clear, storeAfterDownload: nil) - let videoNode = UniversalVideoNode(postbox: self.context.account.postbox, audioSession: mediaManager.audioSession, manager: mediaManager.universalVideoManager, decoration: GalleryVideoDecoration(), content: videoContent, priority: .overlay) + let videoNode = UniversalVideoNode(accountId: self.context.account.id, postbox: self.context.account.postbox, audioSession: mediaManager.audioSession, manager: mediaManager.universalVideoManager, decoration: GalleryVideoDecoration(), content: videoContent, priority: .overlay) videoNode.isUserInteractionEnabled = false videoNode.isHidden = true self.videoStartTimestamp = video.representation.startTimestamp diff --git a/submodules/PeerInfoAvatarListNode/Sources/PeerInfoAvatarListNode.swift b/submodules/PeerInfoAvatarListNode/Sources/PeerInfoAvatarListNode.swift index b9f3a37afc..dfb059861b 100644 --- a/submodules/PeerInfoAvatarListNode/Sources/PeerInfoAvatarListNode.swift +++ b/submodules/PeerInfoAvatarListNode/Sources/PeerInfoAvatarListNode.swift @@ -366,7 +366,7 @@ public final class PeerInfoAvatarListItemNode: ASDisplayNode { } let mediaManager = self.context.sharedContext.mediaManager - let videoNode = UniversalVideoNode(postbox: self.context.account.postbox, audioSession: mediaManager.audioSession, manager: mediaManager.universalVideoManager, decoration: GalleryVideoDecoration(), content: videoContent, priority: .secondaryOverlay) + let videoNode = UniversalVideoNode(accountId: self.context.account.id, postbox: self.context.account.postbox, audioSession: mediaManager.audioSession, manager: mediaManager.universalVideoManager, decoration: GalleryVideoDecoration(), content: videoContent, priority: .secondaryOverlay) videoNode.isUserInteractionEnabled = false videoNode.canAttachContent = true videoNode.isHidden = true diff --git a/submodules/Postbox/Sources/MediaBox.swift b/submodules/Postbox/Sources/MediaBox.swift index 06c4fbe654..ac98ad54e3 100644 --- a/submodules/Postbox/Sources/MediaBox.swift +++ b/submodules/Postbox/Sources/MediaBox.swift @@ -140,8 +140,8 @@ public final class MediaBox { private let statusQueue = Queue() private let concurrentQueue = Queue.concurrentDefaultQueue() - private let dataQueue = Queue(name: "MediaBox-Data") - private let dataFileManager: MediaBoxFileManager + public let dataQueue = Queue(name: "MediaBox-Data") + public let dataFileManager: MediaBoxFileManager private let cacheQueue = Queue() private let timeBasedCleanup: TimeBasedCleanup @@ -209,60 +209,6 @@ public final class MediaBox { self.dataFileManager = MediaBoxFileManager(queue: self.dataQueue) let _ = self.ensureDirectoryCreated - - //self.updateResourceIndex() - - /*#if DEBUG - self.dataQueue.async { - for _ in 0 ..< 5 { - let tempFile = TempBox.shared.tempFile(fileName: "file") - print("MediaBox test: file \(tempFile.path)") - let queue2 = Queue.concurrentDefaultQueue() - if let fileContext = MediaBoxFileContextV2Impl(queue: self.dataQueue, manager: self.dataFileManager, storageBox: self.storageBox, resourceId: tempFile.path.data(using: .utf8)!, path: tempFile.path + "_complete", partialPath: tempFile.path + "_partial", metaPath: tempFile.path + "_partial" + ".meta") { - let _ = fileContext.fetched( - range: 0 ..< Int64.max, - priority: .default, - fetch: { ranges in - return ranges - |> filter { !$0.isEmpty } - |> take(1) - |> castError(MediaResourceDataFetchError.self) - |> mapToSignal { _ in - return Signal { subscriber in - queue2.async { - subscriber.putNext(.resourceSizeUpdated(524288)) - } - queue2.async { - subscriber.putNext(.resourceSizeUpdated(393216)) - } - queue2.async { - subscriber.putNext(.resourceSizeUpdated(655360)) - } - queue2.async { - subscriber.putNext(.resourceSizeUpdated(169608)) - } - queue2.async { - subscriber.putNext(.dataPart(resourceOffset: 131072, data: Data(repeating: 0xbb, count: 38536), range: 0 ..< 38536, complete: true)) - } - queue2.async { - subscriber.putNext(.dataPart(resourceOffset: 0, data: Data(repeating: 0xaa, count: 131072), range: 0 ..< 131072, complete: false)) - } - - return EmptyDisposable - } - } - }, - error: { _ in - }, - completed: { - assert(try! Data(contentsOf: URL(fileURLWithPath: tempFile.path + "_complete")) == Data(repeating: 0xaa, count: 131072) + Data(repeating: 0xbb, count: 38536)) - let _ = fileContext.addReference() - } - ) - } - } - } - #endif*/ } public func setMaxStoreTimes(general: Int32, shortLived: Int32, gigabytesLimit: Int32) { @@ -641,21 +587,12 @@ public final class MediaBox { paths.partial + ".meta" ]) - #if true if let fileContext = MediaBoxFileContextV2Impl(queue: self.dataQueue, manager: self.dataFileManager, storageBox: self.storageBox, resourceId: id.stringRepresentation.data(using: .utf8)!, path: paths.complete, partialPath: paths.partial, metaPath: paths.partial + ".meta") { context = fileContext self.fileContexts[resourceId] = fileContext } else { return nil } - #else - if let fileContext = MediaBoxFileContextImpl(queue: self.dataQueue, manager: self.dataFileManager, storageBox: self.storageBox, resourceId: id.stringRepresentation.data(using: .utf8)!, path: paths.complete, partialPath: paths.partial, metaPath: paths.partial + ".meta") { - context = fileContext - self.fileContexts[resourceId] = fileContext - } else { - return nil - } - #endif } if let context = context { let index = context.addReference() diff --git a/submodules/Postbox/Sources/MediaBoxFileContextV2Impl.swift b/submodules/Postbox/Sources/MediaBoxFileContextV2Impl.swift index 6bc400325a..3d1e70b889 100644 --- a/submodules/Postbox/Sources/MediaBoxFileContextV2Impl.swift +++ b/submodules/Postbox/Sources/MediaBoxFileContextV2Impl.swift @@ -2,7 +2,7 @@ import Foundation import RangeSet import SwiftSignalKit -final class MediaBoxFileContextV2Impl: MediaBoxFileContext { +public final class MediaBoxFileContextV2Impl: MediaBoxFileContext { private final class RangeRequest { let value: Range let priority: MediaBoxFetchPriority @@ -99,7 +99,7 @@ final class MediaBoxFileContextV2Impl: MediaBoxFileContext { private final class PartialState { private let queue: Queue private let manager: MediaBoxFileManager - private let storageBox: StorageBox + private let storageBox: StorageBox? private let resourceId: Data private let partialPath: String private let fullPath: String @@ -124,7 +124,7 @@ final class MediaBoxFileContextV2Impl: MediaBoxFileContext { init( queue: Queue, manager: MediaBoxFileManager, - storageBox: StorageBox, + storageBox: StorageBox?, resourceId: Data, partialPath: String, fullPath: String, @@ -461,7 +461,7 @@ final class MediaBoxFileContextV2Impl: MediaBoxFileContext { self.fileMap.fill(range) self.fileMap.serialize(manager: self.manager, to: self.metaPath) - self.storageBox.update(id: self.resourceId, size: self.fileMap.sum) + self.storageBox?.update(id: self.resourceId, size: self.fileMap.sum) } else { postboxLog("MediaBoxFileContextV2Impl: error seeking file to \(resourceOffset) at \(self.partialPath)") } @@ -474,7 +474,7 @@ final class MediaBoxFileContextV2Impl: MediaBoxFileContext { private func processMovedFile() { if let size = fileSize(self.fullPath) { self.isComplete = true - self.storageBox.update(id: self.resourceId, size: size) + self.storageBox?.update(id: self.resourceId, size: size) } } @@ -623,7 +623,7 @@ final class MediaBoxFileContextV2Impl: MediaBoxFileContext { private let queue: Queue private let manager: MediaBoxFileManager - private let storageBox: StorageBox + private let storageBox: StorageBox? private let resourceId: Data private let path: String private let partialPath: String @@ -637,10 +637,10 @@ final class MediaBoxFileContextV2Impl: MediaBoxFileContext { return self.references.isEmpty } - init?( + public init?( queue: Queue, manager: MediaBoxFileManager, - storageBox: StorageBox, + storageBox: StorageBox?, resourceId: Data, path: String, partialPath: String, @@ -683,7 +683,7 @@ final class MediaBoxFileContextV2Impl: MediaBoxFileContext { } } - func data(range: Range, waitUntilAfterInitialFetch: Bool, next: @escaping (MediaResourceData) -> Void) -> Disposable { + public func data(range: Range, waitUntilAfterInitialFetch: Bool, next: @escaping (MediaResourceData) -> Void) -> Disposable { assert(self.queue.isCurrent()) if let size = fileSize(self.path) { @@ -708,7 +708,7 @@ final class MediaBoxFileContextV2Impl: MediaBoxFileContext { } } - func fetched( + public func fetched( range: Range, priority: MediaBoxFetchPriority, fetch: @escaping (Signal<[(Range, MediaBoxFetchPriority)], NoError>) -> Signal, @@ -734,7 +734,7 @@ final class MediaBoxFileContextV2Impl: MediaBoxFileContext { } } - func fetchedFullRange( + public func fetchedFullRange( fetch: @escaping (Signal<[(Range, MediaBoxFetchPriority)], NoError>) -> Signal, error: @escaping (MediaResourceDataFetchError) -> Void, completed: @escaping () -> Void @@ -758,7 +758,7 @@ final class MediaBoxFileContextV2Impl: MediaBoxFileContext { } } - func cancelFullRangeFetches() { + public func cancelFullRangeFetches() { assert(self.queue.isCurrent()) if let partialState = self.partialState { @@ -766,7 +766,7 @@ final class MediaBoxFileContextV2Impl: MediaBoxFileContext { } } - func rangeStatus(next: @escaping (RangeSet) -> Void, completed: @escaping () -> Void) -> Disposable { + public func rangeStatus(next: @escaping (RangeSet) -> Void, completed: @escaping () -> Void) -> Disposable { assert(self.queue.isCurrent()) if let size = fileSize(self.path) { @@ -781,7 +781,7 @@ final class MediaBoxFileContextV2Impl: MediaBoxFileContext { } } - func status(next: @escaping (MediaResourceStatus) -> Void, completed: @escaping () -> Void, size: Int64?) -> Disposable { + public func status(next: @escaping (MediaResourceStatus) -> Void, completed: @escaping () -> Void, size: Int64?) -> Disposable { assert(self.queue.isCurrent()) if let _ = fileSize(self.path) { diff --git a/submodules/Postbox/Sources/MediaBoxFileManager.swift b/submodules/Postbox/Sources/MediaBoxFileManager.swift index bc963b8e4c..22799bc0a5 100644 --- a/submodules/Postbox/Sources/MediaBoxFileManager.swift +++ b/submodules/Postbox/Sources/MediaBoxFileManager.swift @@ -2,13 +2,13 @@ import Foundation import SwiftSignalKit import ManagedFile -final class MediaBoxFileManager { - enum Mode { +public final class MediaBoxFileManager { + public enum Mode { case read case readwrite } - enum AccessError: Error { + public enum AccessError: Error { case generic } @@ -129,7 +129,7 @@ final class MediaBoxFileManager { private var nextItemId: Int = 0 private let maxOpenFiles: Int - init(queue: Queue?) { + public init(queue: Queue?) { self.queue = queue self.maxOpenFiles = 16 } diff --git a/submodules/PremiumUI/Sources/PhoneDemoComponent.swift b/submodules/PremiumUI/Sources/PhoneDemoComponent.swift index 34b2ff7a8e..c3bb78f7ee 100644 --- a/submodules/PremiumUI/Sources/PhoneDemoComponent.swift +++ b/submodules/PremiumUI/Sources/PhoneDemoComponent.swift @@ -233,7 +233,7 @@ private final class PhoneView: UIView { hintDimensions: CGSize(width: 1170, height: 1754), storeAfterDownload: nil ) - let videoNode = UniversalVideoNode(postbox: context.account.postbox, audioSession: context.sharedContext.mediaManager.audioSession, manager: context.sharedContext.mediaManager.universalVideoManager, decoration: VideoDecoration(), content: videoContent, priority: .embedded) + let videoNode = UniversalVideoNode(accountId: context.account.id, postbox: context.account.postbox, audioSession: context.sharedContext.mediaManager.audioSession, manager: context.sharedContext.mediaManager.universalVideoManager, decoration: VideoDecoration(), content: videoContent, priority: .embedded) videoNode.canAttachContent = true self.videoNode = videoNode diff --git a/submodules/ShareController/Sources/ShareLoadingContainerNode.swift b/submodules/ShareController/Sources/ShareLoadingContainerNode.swift index ebf18f121b..3b5bbc6448 100644 --- a/submodules/ShareController/Sources/ShareLoadingContainerNode.swift +++ b/submodules/ShareController/Sources/ShareLoadingContainerNode.swift @@ -283,7 +283,7 @@ public final class ShareProlongedLoadingContainerNode: ASDisplayNode, ShareConte let videoContent = NativeVideoContent(id: .message(1, EngineMedia.Id(namespace: 0, id: 1)), userLocation: .other, fileReference: .standalone(media: dummyFile), streamVideo: .none, loopVideo: true, enableSound: false, fetchAutomatically: true, onlyFullSizeThumbnail: false, continuePlayingWithoutSoundOnLostAudioSession: false, placeholderColor: .black, storeAfterDownload: nil) - let videoNode = UniversalVideoNode(postbox: postbox, audioSession: mediaManager.audioSession, manager: mediaManager.universalVideoManager, decoration: decoration, content: videoContent, priority: .embedded) + let videoNode = UniversalVideoNode(accountId: AccountRecordId(rawValue: 0), postbox: postbox, audioSession: mediaManager.audioSession, manager: mediaManager.universalVideoManager, decoration: decoration, content: videoContent, priority: .embedded) videoNode.frame = CGRect(origin: CGPoint(), size: CGSize(width: 2.0, height: 2.0)) videoNode.alpha = 0.01 self.videoNode = videoNode diff --git a/submodules/TelegramApi/Sources/Api0.swift b/submodules/TelegramApi/Sources/Api0.swift index 9faafe7bc0..aa22fb967b 100644 --- a/submodules/TelegramApi/Sources/Api0.swift +++ b/submodules/TelegramApi/Sources/Api0.swift @@ -631,6 +631,7 @@ fileprivate let parsers: [Int32 : (BufferReader) -> Any?] = { dict[-2083123262] = { return Api.MessageReplies.parse_messageReplies($0) } dict[-1346631205] = { return Api.MessageReplyHeader.parse_messageReplyHeader($0) } dict[240843065] = { return Api.MessageReplyHeader.parse_messageReplyStoryHeader($0) } + dict[2030298073] = { return Api.MessageReportOption.parse_messageReportOption($0) } dict[1163625789] = { return Api.MessageViews.parse_messageViews($0) } dict[975236280] = { return Api.MessagesFilter.parse_inputMessagesFilterChatPhotos($0) } dict[-530392189] = { return Api.MessagesFilter.parse_inputMessagesFilterContacts($0) } @@ -803,6 +804,9 @@ fileprivate let parsers: [Int32 : (BufferReader) -> Any?] = { dict[777640226] = { return Api.ReportReason.parse_inputReportReasonPornography($0) } dict[1490799288] = { return Api.ReportReason.parse_inputReportReasonSpam($0) } dict[505595789] = { return Api.ReportReason.parse_inputReportReasonViolence($0) } + dict[1862904881] = { return Api.ReportResult.parse_reportResultAddComment($0) } + dict[-253435722] = { return Api.ReportResult.parse_reportResultChooseOption($0) } + dict[-1917633461] = { return Api.ReportResult.parse_reportResultReported($0) } dict[865857388] = { return Api.RequestPeerType.parse_requestPeerTypeBroadcast($0) } dict[-906990053] = { return Api.RequestPeerType.parse_requestPeerTypeChat($0) } dict[1597737472] = { return Api.RequestPeerType.parse_requestPeerTypeUser($0) } @@ -898,7 +902,7 @@ fileprivate let parsers: [Int32 : (BufferReader) -> Any?] = { 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[-294313259] = { return Api.StarsTransaction.parse_starsTransaction($0) } + dict[178185410] = { 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) } @@ -1853,6 +1857,8 @@ public extension Api { _1.serialize(buffer, boxed) case let _1 as Api.MessageReplyHeader: _1.serialize(buffer, boxed) + case let _1 as Api.MessageReportOption: + _1.serialize(buffer, boxed) case let _1 as Api.MessageViews: _1.serialize(buffer, boxed) case let _1 as Api.MessagesFilter: @@ -1969,6 +1975,8 @@ public extension Api { _1.serialize(buffer, boxed) case let _1 as Api.ReportReason: _1.serialize(buffer, boxed) + case let _1 as Api.ReportResult: + _1.serialize(buffer, boxed) case let _1 as Api.RequestPeerType: _1.serialize(buffer, boxed) case let _1 as Api.RequestedPeer: diff --git a/submodules/TelegramApi/Sources/Api16.swift b/submodules/TelegramApi/Sources/Api16.swift index 4652a76a82..38015e3b82 100644 --- a/submodules/TelegramApi/Sources/Api16.swift +++ b/submodules/TelegramApi/Sources/Api16.swift @@ -482,6 +482,46 @@ public extension Api { } } +public extension Api { + enum MessageReportOption: TypeConstructorDescription { + case messageReportOption(text: String, option: Buffer) + + public func serialize(_ buffer: Buffer, _ boxed: Swift.Bool) { + switch self { + case .messageReportOption(let text, let option): + if boxed { + buffer.appendInt32(2030298073) + } + serializeString(text, buffer: buffer, boxed: false) + serializeBytes(option, buffer: buffer, boxed: false) + break + } + } + + public func descriptionFields() -> (String, [(String, Any)]) { + switch self { + case .messageReportOption(let text, let option): + return ("messageReportOption", [("text", text as Any), ("option", option as Any)]) + } + } + + public static func parse_messageReportOption(_ reader: BufferReader) -> MessageReportOption? { + var _1: String? + _1 = parseString(reader) + var _2: Buffer? + _2 = parseBytes(reader) + let _c1 = _1 != nil + let _c2 = _2 != nil + if _c1 && _c2 { + return Api.MessageReportOption.messageReportOption(text: _1!, option: _2!) + } + else { + return nil + } + } + + } +} public extension Api { enum MessageViews: TypeConstructorDescription { case messageViews(flags: Int32, views: Int32?, forwards: Int32?, replies: Api.MessageReplies?) @@ -902,87 +942,3 @@ public extension Api { } } -public extension Api { - enum NotificationSound: TypeConstructorDescription { - case notificationSoundDefault - case notificationSoundLocal(title: String, data: String) - case notificationSoundNone - case notificationSoundRingtone(id: Int64) - - public func serialize(_ buffer: Buffer, _ boxed: Swift.Bool) { - switch self { - case .notificationSoundDefault: - if boxed { - buffer.appendInt32(-1746354498) - } - - break - case .notificationSoundLocal(let title, let data): - if boxed { - buffer.appendInt32(-2096391452) - } - serializeString(title, buffer: buffer, boxed: false) - serializeString(data, buffer: buffer, boxed: false) - break - case .notificationSoundNone: - if boxed { - buffer.appendInt32(1863070943) - } - - break - case .notificationSoundRingtone(let id): - if boxed { - buffer.appendInt32(-9666487) - } - serializeInt64(id, buffer: buffer, boxed: false) - break - } - } - - public func descriptionFields() -> (String, [(String, Any)]) { - switch self { - case .notificationSoundDefault: - return ("notificationSoundDefault", []) - case .notificationSoundLocal(let title, let data): - return ("notificationSoundLocal", [("title", title as Any), ("data", data as Any)]) - case .notificationSoundNone: - return ("notificationSoundNone", []) - case .notificationSoundRingtone(let id): - return ("notificationSoundRingtone", [("id", id as Any)]) - } - } - - public static func parse_notificationSoundDefault(_ reader: BufferReader) -> NotificationSound? { - return Api.NotificationSound.notificationSoundDefault - } - public static func parse_notificationSoundLocal(_ reader: BufferReader) -> NotificationSound? { - var _1: String? - _1 = parseString(reader) - var _2: String? - _2 = parseString(reader) - let _c1 = _1 != nil - let _c2 = _2 != nil - if _c1 && _c2 { - return Api.NotificationSound.notificationSoundLocal(title: _1!, data: _2!) - } - else { - return nil - } - } - public static func parse_notificationSoundNone(_ reader: BufferReader) -> NotificationSound? { - return Api.NotificationSound.notificationSoundNone - } - public static func parse_notificationSoundRingtone(_ reader: BufferReader) -> NotificationSound? { - var _1: Int64? - _1 = reader.readInt64() - let _c1 = _1 != nil - if _c1 { - return Api.NotificationSound.notificationSoundRingtone(id: _1!) - } - else { - return nil - } - } - - } -} diff --git a/submodules/TelegramApi/Sources/Api17.swift b/submodules/TelegramApi/Sources/Api17.swift index 278c569f5c..11ea4e2a43 100644 --- a/submodules/TelegramApi/Sources/Api17.swift +++ b/submodules/TelegramApi/Sources/Api17.swift @@ -1,3 +1,87 @@ +public extension Api { + enum NotificationSound: TypeConstructorDescription { + case notificationSoundDefault + case notificationSoundLocal(title: String, data: String) + case notificationSoundNone + case notificationSoundRingtone(id: Int64) + + public func serialize(_ buffer: Buffer, _ boxed: Swift.Bool) { + switch self { + case .notificationSoundDefault: + if boxed { + buffer.appendInt32(-1746354498) + } + + break + case .notificationSoundLocal(let title, let data): + if boxed { + buffer.appendInt32(-2096391452) + } + serializeString(title, buffer: buffer, boxed: false) + serializeString(data, buffer: buffer, boxed: false) + break + case .notificationSoundNone: + if boxed { + buffer.appendInt32(1863070943) + } + + break + case .notificationSoundRingtone(let id): + if boxed { + buffer.appendInt32(-9666487) + } + serializeInt64(id, buffer: buffer, boxed: false) + break + } + } + + public func descriptionFields() -> (String, [(String, Any)]) { + switch self { + case .notificationSoundDefault: + return ("notificationSoundDefault", []) + case .notificationSoundLocal(let title, let data): + return ("notificationSoundLocal", [("title", title as Any), ("data", data as Any)]) + case .notificationSoundNone: + return ("notificationSoundNone", []) + case .notificationSoundRingtone(let id): + return ("notificationSoundRingtone", [("id", id as Any)]) + } + } + + public static func parse_notificationSoundDefault(_ reader: BufferReader) -> NotificationSound? { + return Api.NotificationSound.notificationSoundDefault + } + public static func parse_notificationSoundLocal(_ reader: BufferReader) -> NotificationSound? { + var _1: String? + _1 = parseString(reader) + var _2: String? + _2 = parseString(reader) + let _c1 = _1 != nil + let _c2 = _2 != nil + if _c1 && _c2 { + return Api.NotificationSound.notificationSoundLocal(title: _1!, data: _2!) + } + else { + return nil + } + } + public static func parse_notificationSoundNone(_ reader: BufferReader) -> NotificationSound? { + return Api.NotificationSound.notificationSoundNone + } + public static func parse_notificationSoundRingtone(_ reader: BufferReader) -> NotificationSound? { + var _1: Int64? + _1 = reader.readInt64() + let _c1 = _1 != nil + if _c1 { + return Api.NotificationSound.notificationSoundRingtone(id: _1!) + } + else { + return nil + } + } + + } +} public extension Api { enum NotifyPeer: TypeConstructorDescription { case notifyBroadcasts diff --git a/submodules/TelegramApi/Sources/Api21.swift b/submodules/TelegramApi/Sources/Api21.swift index 73eedd5544..2e4266d3f5 100644 --- a/submodules/TelegramApi/Sources/Api21.swift +++ b/submodules/TelegramApi/Sources/Api21.swift @@ -134,6 +134,88 @@ public extension Api { } } +public extension Api { + enum ReportResult: TypeConstructorDescription { + case reportResultAddComment(flags: Int32, option: Buffer) + case reportResultChooseOption(title: String, options: [Api.MessageReportOption]) + case reportResultReported + + public func serialize(_ buffer: Buffer, _ boxed: Swift.Bool) { + switch self { + case .reportResultAddComment(let flags, let option): + if boxed { + buffer.appendInt32(1862904881) + } + serializeInt32(flags, buffer: buffer, boxed: false) + serializeBytes(option, buffer: buffer, boxed: false) + break + case .reportResultChooseOption(let title, let options): + if boxed { + buffer.appendInt32(-253435722) + } + serializeString(title, buffer: buffer, boxed: false) + buffer.appendInt32(481674261) + buffer.appendInt32(Int32(options.count)) + for item in options { + item.serialize(buffer, true) + } + break + case .reportResultReported: + if boxed { + buffer.appendInt32(-1917633461) + } + + break + } + } + + public func descriptionFields() -> (String, [(String, Any)]) { + switch self { + case .reportResultAddComment(let flags, let option): + return ("reportResultAddComment", [("flags", flags as Any), ("option", option as Any)]) + case .reportResultChooseOption(let title, let options): + return ("reportResultChooseOption", [("title", title as Any), ("options", options as Any)]) + case .reportResultReported: + return ("reportResultReported", []) + } + } + + public static func parse_reportResultAddComment(_ reader: BufferReader) -> ReportResult? { + var _1: Int32? + _1 = reader.readInt32() + var _2: Buffer? + _2 = parseBytes(reader) + let _c1 = _1 != nil + let _c2 = _2 != nil + if _c1 && _c2 { + return Api.ReportResult.reportResultAddComment(flags: _1!, option: _2!) + } + else { + return nil + } + } + public static func parse_reportResultChooseOption(_ reader: BufferReader) -> ReportResult? { + var _1: String? + _1 = parseString(reader) + var _2: [Api.MessageReportOption]? + if let _ = reader.readInt32() { + _2 = Api.parseVector(reader, elementSignature: 0, elementType: Api.MessageReportOption.self) + } + let _c1 = _1 != nil + let _c2 = _2 != nil + if _c1 && _c2 { + return Api.ReportResult.reportResultChooseOption(title: _1!, options: _2!) + } + else { + return nil + } + } + public static func parse_reportResultReported(_ reader: BufferReader) -> ReportResult? { + return Api.ReportResult.reportResultReported + } + + } +} public extension Api { enum RequestPeerType: TypeConstructorDescription { case requestPeerTypeBroadcast(flags: Int32, hasUsername: Api.Bool?, userAdminRights: Api.ChatAdminRights?, botAdminRights: Api.ChatAdminRights?) diff --git a/submodules/TelegramApi/Sources/Api23.swift b/submodules/TelegramApi/Sources/Api23.swift index 5868481552..a89148bf05 100644 --- a/submodules/TelegramApi/Sources/Api23.swift +++ b/submodules/TelegramApi/Sources/Api23.swift @@ -1002,13 +1002,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?, giveawayPostId: 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?, stargift: Api.StarGift?) 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, let giveawayPostId): + 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, let stargift): if boxed { - buffer.appendInt32(-294313259) + buffer.appendInt32(178185410) } serializeInt32(flags, buffer: buffer, boxed: false) serializeString(id, buffer: buffer, boxed: false) @@ -1029,14 +1029,15 @@ public extension Api { }} 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)} + if Int(flags) & Int(1 << 14) != 0 {stargift!.serialize(buffer, true)} 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, 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)]) + 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, let stargift): + 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), ("stargift", stargift as Any)]) } } @@ -1077,6 +1078,10 @@ public extension Api { if Int(_1!) & Int(1 << 12) != 0 {_14 = reader.readInt32() } var _15: Int32? if Int(_1!) & Int(1 << 13) != 0 {_15 = reader.readInt32() } + var _16: Api.StarGift? + if Int(_1!) & Int(1 << 14) != 0 {if let signature = reader.readInt32() { + _16 = Api.parse(reader, signature: signature) as? Api.StarGift + } } let _c1 = _1 != nil let _c2 = _2 != nil let _c3 = _3 != nil @@ -1092,8 +1097,9 @@ public extension Api { let _c13 = (Int(_1!) & Int(1 << 9) == 0) || _13 != nil let _c14 = (Int(_1!) & Int(1 << 12) == 0) || _14 != nil 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) + let _c16 = (Int(_1!) & Int(1 << 14) == 0) || _16 != nil + if _c1 && _c2 && _c3 && _c4 && _c5 && _c6 && _c7 && _c8 && _c9 && _c10 && _c11 && _c12 && _c13 && _c14 && _c15 && _c16 { + 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, stargift: _16) } else { return nil diff --git a/submodules/TelegramApi/Sources/Api36.swift b/submodules/TelegramApi/Sources/Api36.swift index 740c313af2..ed03072b36 100644 --- a/submodules/TelegramApi/Sources/Api36.swift +++ b/submodules/TelegramApi/Sources/Api36.swift @@ -7380,22 +7380,22 @@ public extension Api.functions.messages { } } public extension Api.functions.messages { - static func report(peer: Api.InputPeer, id: [Int32], reason: Api.ReportReason, message: String) -> (FunctionDescription, Buffer, DeserializeFunctionResponse) { + static func report(peer: Api.InputPeer, id: [Int32], option: Buffer, message: String) -> (FunctionDescription, Buffer, DeserializeFunctionResponse) { let buffer = Buffer() - buffer.appendInt32(-1991005362) + buffer.appendInt32(-59199589) peer.serialize(buffer, true) buffer.appendInt32(481674261) buffer.appendInt32(Int32(id.count)) for item in id { serializeInt32(item, buffer: buffer, boxed: false) } - reason.serialize(buffer, true) + serializeBytes(option, buffer: buffer, boxed: false) serializeString(message, buffer: buffer, boxed: false) - return (FunctionDescription(name: "messages.report", parameters: [("peer", String(describing: peer)), ("id", String(describing: id)), ("reason", String(describing: reason)), ("message", String(describing: message))]), buffer, DeserializeFunctionResponse { (buffer: Buffer) -> Api.Bool? in + return (FunctionDescription(name: "messages.report", parameters: [("peer", String(describing: peer)), ("id", String(describing: id)), ("option", String(describing: option)), ("message", String(describing: message))]), buffer, DeserializeFunctionResponse { (buffer: Buffer) -> Api.ReportResult? in let reader = BufferReader(buffer) - var result: Api.Bool? + var result: Api.ReportResult? if let signature = reader.readInt32() { - result = Api.parse(reader, signature: signature) as? Api.Bool + result = Api.parse(reader, signature: signature) as? Api.ReportResult } return result }) @@ -10787,22 +10787,22 @@ public extension Api.functions.stories { } } public extension Api.functions.stories { - static func report(peer: Api.InputPeer, id: [Int32], reason: Api.ReportReason, message: String) -> (FunctionDescription, Buffer, DeserializeFunctionResponse) { + static func report(peer: Api.InputPeer, id: [Int32], option: Buffer, message: String) -> (FunctionDescription, Buffer, DeserializeFunctionResponse) { let buffer = Buffer() - buffer.appendInt32(421788300) + buffer.appendInt32(433646405) peer.serialize(buffer, true) buffer.appendInt32(481674261) buffer.appendInt32(Int32(id.count)) for item in id { serializeInt32(item, buffer: buffer, boxed: false) } - reason.serialize(buffer, true) + serializeBytes(option, buffer: buffer, boxed: false) serializeString(message, buffer: buffer, boxed: false) - return (FunctionDescription(name: "stories.report", parameters: [("peer", String(describing: peer)), ("id", String(describing: id)), ("reason", String(describing: reason)), ("message", String(describing: message))]), buffer, DeserializeFunctionResponse { (buffer: Buffer) -> Api.Bool? in + return (FunctionDescription(name: "stories.report", parameters: [("peer", String(describing: peer)), ("id", String(describing: id)), ("option", String(describing: option)), ("message", String(describing: message))]), buffer, DeserializeFunctionResponse { (buffer: Buffer) -> Api.ReportResult? in let reader = BufferReader(buffer) - var result: Api.Bool? + var result: Api.ReportResult? if let signature = reader.readInt32() { - result = Api.parse(reader, signature: signature) as? Api.Bool + result = Api.parse(reader, signature: signature) as? Api.ReportResult } return result }) diff --git a/submodules/TelegramCallsUI/BUILD b/submodules/TelegramCallsUI/BUILD index eb0553d7a1..55988887b0 100644 --- a/submodules/TelegramCallsUI/BUILD +++ b/submodules/TelegramCallsUI/BUILD @@ -115,6 +115,7 @@ swift_library( "//submodules/TelegramUI/Components/LottieComponent", "//submodules/TelegramUI/Components/Stories/PeerListItemComponent", "//submodules/TelegramUI/Components/BackButtonComponent", + "//submodules/TelegramUI/Components/AnimatedTextComponent", "//submodules/DirectMediaImageCache", "//submodules/FastBlur", ], diff --git a/submodules/TelegramCallsUI/Sources/PresentationCallManager.swift b/submodules/TelegramCallsUI/Sources/PresentationCallManager.swift index de7135864c..527a5a3074 100644 --- a/submodules/TelegramCallsUI/Sources/PresentationCallManager.swift +++ b/submodules/TelegramCallsUI/Sources/PresentationCallManager.swift @@ -634,7 +634,7 @@ public final class PresentationCallManagerImpl: PresentationCallManager { } } - private func requestScheduleGroupCall(accountContext: AccountContext, peerId: PeerId, internalId: CallSessionInternalId = CallSessionInternalId()) -> Signal { + private func requestScheduleGroupCall(accountContext: AccountContext, peerId: PeerId, internalId: CallSessionInternalId = CallSessionInternalId(), parentController: ViewController) -> Signal { let (presentationData, present, openSettings) = self.getDeviceAccessData() let isVideo = false @@ -668,7 +668,7 @@ public final class PresentationCallManagerImpl: PresentationCallManager { accountContext.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: peerId)) ) |> deliverOnMainQueue - |> mapToSignal { [weak self] accessEnabled, peer -> Signal in + |> mapToSignal { [weak self, weak parentController] accessEnabled, peer -> Signal in guard let strongSelf = self else { return .single(false) } @@ -681,46 +681,98 @@ public final class PresentationCallManagerImpl: PresentationCallManager { if let peer = peer, case let .channel(channel) = peer, case .broadcast = channel.info { isChannel = true } - - let call = PresentationGroupCallImpl( - accountContext: accountContext, - audioSession: strongSelf.audioSession, - callKitIntegration: nil, - getDeviceAccessData: strongSelf.getDeviceAccessData, - initialCall: nil, - internalId: internalId, - peerId: peerId, - isChannel: isChannel, - invite: nil, - joinAsPeerId: nil, - isStream: false - ) - strongSelf.updateCurrentGroupCall(call) - strongSelf.currentGroupCallPromise.set(.single(call)) - strongSelf.hasActiveGroupCallsPromise.set(true) - strongSelf.removeCurrentGroupCallDisposable.set((call.canBeRemoved - |> filter { $0 } - |> take(1) - |> deliverOnMainQueue).start(next: { [weak call] value in - guard let strongSelf = self, let call = call else { - return + + if shouldUseV2VideoChatImpl(context: accountContext) { + if let parentController { + parentController.push(ScheduleVideoChatSheetScreen( + context: accountContext, + scheduleAction: { timestamp in + guard let self else { + return + } + + let call = PresentationGroupCallImpl( + accountContext: accountContext, + audioSession: self.audioSession, + callKitIntegration: nil, + getDeviceAccessData: self.getDeviceAccessData, + initialCall: nil, + internalId: internalId, + peerId: peerId, + isChannel: isChannel, + invite: nil, + joinAsPeerId: nil, + isStream: false + ) + call.schedule(timestamp: timestamp) + + self.updateCurrentGroupCall(call) + self.currentGroupCallPromise.set(.single(call)) + self.hasActiveGroupCallsPromise.set(true) + self.removeCurrentGroupCallDisposable.set((call.canBeRemoved + |> filter { $0 } + |> take(1) + |> deliverOnMainQueue).start(next: { [weak self, weak call] value in + guard let self, let call else { + return + } + if value { + if self.currentGroupCall === call { + self.updateCurrentGroupCall(nil) + self.currentGroupCallPromise.set(.single(nil)) + self.hasActiveGroupCallsPromise.set(false) + } + } + })) + } + )) } - if value { - if strongSelf.currentGroupCall === call { - strongSelf.updateCurrentGroupCall(nil) - strongSelf.currentGroupCallPromise.set(.single(nil)) - strongSelf.hasActiveGroupCallsPromise.set(false) + + return .single(true) + } else { + let call = PresentationGroupCallImpl( + accountContext: accountContext, + audioSession: strongSelf.audioSession, + callKitIntegration: nil, + getDeviceAccessData: strongSelf.getDeviceAccessData, + initialCall: nil, + internalId: internalId, + peerId: peerId, + isChannel: isChannel, + invite: nil, + joinAsPeerId: nil, + isStream: false + ) + strongSelf.updateCurrentGroupCall(call) + strongSelf.currentGroupCallPromise.set(.single(call)) + strongSelf.hasActiveGroupCallsPromise.set(true) + strongSelf.removeCurrentGroupCallDisposable.set((call.canBeRemoved + |> filter { $0 } + |> take(1) + |> deliverOnMainQueue).start(next: { [weak call] value in + guard let strongSelf = self, let call = call else { + return } - } - })) + if value { + if strongSelf.currentGroupCall === call { + strongSelf.updateCurrentGroupCall(nil) + strongSelf.currentGroupCallPromise.set(.single(nil)) + strongSelf.hasActiveGroupCallsPromise.set(false) + } + } + })) + } return .single(true) } } - public func scheduleGroupCall(context: AccountContext, peerId: PeerId, endCurrentIfAny: Bool) -> RequestScheduleGroupCallResult { - let begin: () -> Void = { [weak self] in - let _ = self?.requestScheduleGroupCall(accountContext: context, peerId: peerId).start() + public func scheduleGroupCall(context: AccountContext, peerId: PeerId, endCurrentIfAny: Bool, parentController: ViewController) -> RequestScheduleGroupCallResult { + let begin: () -> Void = { [weak self, weak parentController] in + guard let parentController else { + return + } + let _ = self?.requestScheduleGroupCall(accountContext: context, peerId: peerId, parentController: parentController).start() } if let currentGroupCall = self.currentGroupCallValue { diff --git a/submodules/TelegramCallsUI/Sources/PresentationGroupCall.swift b/submodules/TelegramCallsUI/Sources/PresentationGroupCall.swift index 4bcace59a3..f990bdf289 100644 --- a/submodules/TelegramCallsUI/Sources/PresentationGroupCall.swift +++ b/submodules/TelegramCallsUI/Sources/PresentationGroupCall.swift @@ -268,7 +268,8 @@ private extension PresentationGroupCallState { scheduleTimestamp: scheduleTimestamp, subscribedToScheduled: subscribedToScheduled, isVideoEnabled: false, - isVideoWatchersLimitReached: false + isVideoWatchersLimitReached: false, + hasVideo: false ) } } @@ -2971,11 +2972,12 @@ public final class PresentationGroupCallImpl: PresentationGroupCall { self.updateLocalVideoState() } + self.stateValue.hasVideo = self.hasVideo } public func disableVideo() { self.hasVideo = false - self.useFrontCamera = true; + self.useFrontCamera = true if let _ = self.videoCapturer { self.videoCapturer = nil self.isVideoMutedDisposable.set(nil) @@ -2984,6 +2986,7 @@ public final class PresentationGroupCallImpl: PresentationGroupCall { self.updateLocalVideoState() } + self.stateValue.hasVideo = self.hasVideo } private func updateLocalVideoState() { diff --git a/submodules/TelegramCallsUI/Sources/ScheduleVideoChatSheetScreen.swift b/submodules/TelegramCallsUI/Sources/ScheduleVideoChatSheetScreen.swift new file mode 100644 index 0000000000..a6445e05ff --- /dev/null +++ b/submodules/TelegramCallsUI/Sources/ScheduleVideoChatSheetScreen.swift @@ -0,0 +1,540 @@ +import Foundation +import UIKit +import Display +import ComponentFlow +import ViewControllerComponent +import AccountContext +import SheetComponent +import ButtonComponent +import TelegramCore +import AnimatedTextComponent +import MultilineTextComponent +import BalancedTextComponent +import TelegramPresentationData +import TelegramStringFormatting +import Markdown +import HierarchyTrackingLayer + +private let purple = UIColor(rgb: 0x3252ef) +private let pink = UIColor(rgb: 0xef436c) + +private final class ScheduleVideoChatSheetContentComponent: Component { + typealias EnvironmentType = ViewControllerComponentContainer.Environment + + let scheduleAction: (Int32) -> Void + let dismiss: () -> Void + + init( + scheduleAction: @escaping (Int32) -> Void, + dismiss: @escaping () -> Void + ) { + self.scheduleAction = scheduleAction + self.dismiss = dismiss + } + + static func ==(lhs: ScheduleVideoChatSheetContentComponent, rhs: ScheduleVideoChatSheetContentComponent) -> Bool { + return true + } + + final class View: UIView { + private let hierarchyTrackingLayer: HierarchyTrackingLayer + + private let button = ComponentView() + private let buttonBackgroundLayer: SimpleGradientLayer + + private let cancelButton = ComponentView() + + private let title = ComponentView() + private let mainText = ComponentView() + private var pickerView: UIDatePicker? + + private let calendar = Calendar(identifier: .gregorian) + private let dateFormatter: DateFormatter + + private var component: ScheduleVideoChatSheetContentComponent? + private weak var state: EmptyComponentState? + + override init(frame: CGRect) { + self.dateFormatter = DateFormatter() + self.dateFormatter.timeStyle = .none + self.dateFormatter.dateStyle = .short + self.dateFormatter.timeZone = TimeZone.current + + self.hierarchyTrackingLayer = HierarchyTrackingLayer() + + self.buttonBackgroundLayer = SimpleGradientLayer() + self.buttonBackgroundLayer.type = .radial + self.buttonBackgroundLayer.colors = [pink.cgColor, purple.cgColor, purple.cgColor] + self.buttonBackgroundLayer.locations = [0.0, 0.85, 1.0] + self.buttonBackgroundLayer.startPoint = CGPoint(x: 1.0, y: 0.0) + let radius = CGSize(width: 1.0, height: 2.0) + let endEndPoint = CGPoint(x: (self.buttonBackgroundLayer.startPoint.x + radius.width) * 1.0, y: (self.buttonBackgroundLayer.startPoint.y + radius.height) * 1.0) + self.buttonBackgroundLayer.endPoint = endEndPoint + self.buttonBackgroundLayer.cornerRadius = 10.0 + + super.init(frame: frame) + + self.layer.addSublayer(self.hierarchyTrackingLayer) + self.hierarchyTrackingLayer.isInHierarchyUpdated = { [weak self] value in + guard let self else { + return + } + if value { + self.updateAnimations() + } + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + } + + @objc private func scheduleDatePickerUpdated() { + self.state?.updated(transition: .immediate) + } + + private func updateSchedulePickerLimits() { + let timeZone = TimeZone(secondsFromGMT: 0)! + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = timeZone + let currentDate = Date() + var components = calendar.dateComponents(Set([.era, .year, .month, .day, .hour, .minute, .second]), from: currentDate) + components.second = 0 + + let roundedDate = calendar.date(from: components)! + let next1MinDate = calendar.date(byAdding: .minute, value: 1, to: roundedDate) + + let minute = components.minute ?? 0 + components.minute = 0 + let roundedToHourDate = calendar.date(from: components)! + components.hour = 0 + + let roundedToMidnightDate = calendar.date(from: components)! + let nextTwoHourDate = calendar.date(byAdding: .hour, value: minute > 30 ? 4 : 3, to: roundedToHourDate) + let maxDate = calendar.date(byAdding: .day, value: 8, to: roundedToMidnightDate) + + if let date = calendar.date(byAdding: .day, value: 365, to: currentDate) { + self.pickerView?.maximumDate = date + } + if let next1MinDate = next1MinDate, let nextTwoHourDate = nextTwoHourDate { + self.pickerView?.minimumDate = next1MinDate + self.pickerView?.maximumDate = maxDate + self.pickerView?.date = nextTwoHourDate + } + } + + private func updateAnimations() { + if let _ = self.buttonBackgroundLayer.animation(forKey: "movement") { + } else { + let previousValue = self.buttonBackgroundLayer.startPoint + let previousEndValue = self.buttonBackgroundLayer.endPoint + let newValue = CGPoint(x: CGFloat.random(in: 0.65 ..< 0.85), y: CGFloat.random(in: 0.1 ..< 0.45)) + self.buttonBackgroundLayer.startPoint = newValue + + let radius = CGSize(width: 1.0, height: 2.0) + let newEndValue = CGPoint(x: (self.buttonBackgroundLayer.startPoint.x + radius.width) * 1.0, y: (self.buttonBackgroundLayer.startPoint.y + radius.height) * 1.0) + + CATransaction.begin() + + let animation = CABasicAnimation(keyPath: "startPoint") + animation.duration = Double.random(in: 0.8 ..< 1.4) + animation.fromValue = previousValue + animation.toValue = newValue + + CATransaction.setCompletionBlock { [weak self] in + guard let self else { + return + } + if self.hierarchyTrackingLayer.isInHierarchy { + self.updateAnimations() + } + } + + self.buttonBackgroundLayer.add(animation, forKey: "movement") + + let endAnimation = CABasicAnimation(keyPath: "endPoint") + endAnimation.duration = animation.duration + endAnimation.fromValue = previousEndValue + endAnimation.toValue = newEndValue + + self.buttonBackgroundLayer.add(animation, forKey: "movementEnd") + + CATransaction.commit() + } + } + + func update(component: ScheduleVideoChatSheetContentComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + let previousComponent = self.component + let _ = previousComponent + + self.component = component + self.state = state + + let environment = environment[EnvironmentType.self].value + + let sideInset: CGFloat = 16.0 + + var contentHeight: CGFloat = 0.0 + contentHeight += 16.0 + + //TODO:localize + let titleString = NSMutableAttributedString() + titleString.append(NSAttributedString(string: "Schedule Video Chat", font: Font.semibold(17.0), textColor: environment.theme.list.itemPrimaryTextColor)) + + let titleSize = self.title.update( + transition: .immediate, + component: AnyComponent(MultilineTextComponent( + text: .plain(titleString), + maximumNumberOfLines: 1 + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 1000.0) + ) + if let titleView = self.title.view { + if titleView.superview == nil { + self.addSubview(titleView) + } + transition.setFrame(view: titleView, frame: CGRect(origin: CGPoint(x: floor((availableSize.width - titleSize.width) * 0.5), y: contentHeight), size: titleSize)) + } + contentHeight += titleSize.height + contentHeight += 16.0 + + let pickerView: UIDatePicker + if let current = self.pickerView { + pickerView = current + } else { + let textColor = UIColor.white + UILabel.setDateLabel(textColor) + + pickerView = UIDatePicker() + pickerView.timeZone = TimeZone(secondsFromGMT: 0) + pickerView.datePickerMode = .countDownTimer + pickerView.datePickerMode = .dateAndTime + pickerView.locale = Locale.current + pickerView.timeZone = TimeZone.current + pickerView.minuteInterval = 1 + self.addSubview(pickerView) + pickerView.addTarget(self, action: #selector(self.scheduleDatePickerUpdated), for: .valueChanged) + if #available(iOS 13.4, *) { + pickerView.preferredDatePickerStyle = .wheels + } + pickerView.setValue(textColor, forKey: "textColor") + self.pickerView = pickerView + self.addSubview(pickerView) + + self.updateSchedulePickerLimits() + } + + let pickerFrame = CGRect(origin: CGPoint(x: sideInset, y: contentHeight), size: CGSize(width: availableSize.width - sideInset * 2.0, height: 216.0)) + transition.setFrame(view: pickerView, frame: pickerFrame) + contentHeight += pickerFrame.height + contentHeight += 26.0 + + let date = pickerView.date + let calendar = Calendar(identifier: .gregorian) + let currentTimestamp = Int32(CFAbsoluteTimeGetCurrent() + kCFAbsoluteTimeIntervalSince1970) + let timestamp = Int32(date.timeIntervalSince1970) + let time = stringForMessageTimestamp(timestamp: timestamp, dateTimeFormat: PresentationDateTimeFormat()) + let buttonTitle: String + if calendar.isDateInToday(date) { + buttonTitle = environment.strings.ScheduleVoiceChat_ScheduleToday(time).string + } else if calendar.isDateInTomorrow(date) { + buttonTitle = environment.strings.ScheduleVoiceChat_ScheduleTomorrow(time).string + } else { + buttonTitle = environment.strings.ScheduleVoiceChat_ScheduleOn(self.dateFormatter.string(from: date), time).string + } + + let delta = timestamp - currentTimestamp + + let isGroup = "".isEmpty + let intervalString = scheduledTimeIntervalString(strings: environment.strings, value: max(60, delta)) + + let text: String = isGroup ? environment.strings.ScheduleVoiceChat_GroupText(intervalString).string : environment.strings.ScheduleLiveStream_ChannelText(intervalString).string + + let mainText = NSMutableAttributedString() + mainText.append(parseMarkdownIntoAttributedString(text, attributes: MarkdownAttributes( + body: MarkdownAttributeSet( + font: Font.regular(14.0), + textColor: UIColor(rgb: 0x8e8e93) + ), + bold: MarkdownAttributeSet( + font: Font.semibold(14.0), + textColor: UIColor(rgb: 0x8e8e93) + ), + link: MarkdownAttributeSet( + font: Font.regular(14.0), + textColor: environment.theme.list.itemAccentColor, + additionalAttributes: [:] + ), + linkAttribute: { attributes in + return ("URL", "") + } + ))) + + let mainTextSize = self.mainText.update( + transition: .immediate, + component: AnyComponent(BalancedTextComponent( + text: .plain(mainText), + horizontalAlignment: .center, + maximumNumberOfLines: 0, + lineSpacing: 0.2 + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 1000.0) + ) + if let mainTextView = self.mainText.view { + if mainTextView.superview == nil { + self.addSubview(mainTextView) + } + transition.setFrame(view: mainTextView, frame: CGRect(origin: CGPoint(x: floor((availableSize.width - mainTextSize.width) * 0.5), y: contentHeight), size: mainTextSize)) + } + contentHeight += mainTextSize.height + contentHeight += 10.0 + + var buttonContents: [AnyComponentWithIdentity] = [] + buttonContents.append(AnyComponentWithIdentity(id: AnyHashable(0 as Int), component: AnyComponent( + Text(text: buttonTitle, font: Font.semibold(17.0), color: environment.theme.list.itemCheckColors.foregroundColor) + ))) + let buttonTransition = transition + let buttonSize = self.button.update( + transition: buttonTransition, + component: AnyComponent(ButtonComponent( + background: ButtonComponent.Background( + color: .clear, + foreground: .white, + pressedColor: UIColor(white: 1.0, alpha: 0.1) + ), + content: AnyComponentWithIdentity(id: AnyHashable(0 as Int), component: AnyComponent( + HStack(buttonContents, spacing: 5.0) + )), + isEnabled: true, + tintWhenDisabled: false, + displaysProgress: false, + action: { [weak self] in + guard let self, let component = self.component, let pickerView = self.pickerView else { + return + } + component.scheduleAction(Int32(pickerView.date.timeIntervalSince1970)) + } + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 50.0) + ) + let buttonFrame = CGRect(origin: CGPoint(x: sideInset, y: contentHeight), size: buttonSize) + if let buttonView = self.button.view { + if buttonView.superview == nil { + self.layer.addSublayer(self.buttonBackgroundLayer) + self.addSubview(buttonView) + } + transition.setFrame(layer: self.buttonBackgroundLayer, frame: buttonFrame) + transition.setFrame(view: buttonView, frame: buttonFrame) + } + contentHeight += buttonSize.height + contentHeight += 10.0 + + let cancelButtonSize = self.cancelButton.update( + transition: buttonTransition, + component: AnyComponent(ButtonComponent( + background: ButtonComponent.Background( + color: UIColor(rgb: 0x2B2B2F), + foreground: .white, + pressedColor: UIColor(rgb: 0x2B2B2F).withMultipliedAlpha(0.8) + ), + content: AnyComponentWithIdentity(id: AnyHashable(0 as Int), component: AnyComponent( + Text(text: "Cancel", font: Font.semibold(17.0), color: environment.theme.list.itemPrimaryTextColor) + )), + isEnabled: true, + tintWhenDisabled: false, + displaysProgress: false, + action: { [weak self] in + guard let self, let component = self.component else { + return + } + component.dismiss() + } + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 50.0) + ) + let cancelButtonFrame = CGRect(origin: CGPoint(x: sideInset, y: contentHeight), size: cancelButtonSize) + if let cancelButtonView = self.cancelButton.view { + if cancelButtonView.superview == nil { + self.addSubview(cancelButtonView) + } + transition.setFrame(view: cancelButtonView, frame: cancelButtonFrame) + } + contentHeight += cancelButtonSize.height + + if environment.safeInsets.bottom.isZero { + contentHeight += 16.0 + } else { + contentHeight += environment.safeInsets.bottom + 14.0 + } + + self.updateAnimations() + + return CGSize(width: availableSize.width, height: contentHeight) + } + } + + func makeView() -> View { + return View(frame: CGRect()) + } + + func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } +} + +private final class ScheduleVideoChatSheetScreenComponent: Component { + typealias EnvironmentType = ViewControllerComponentContainer.Environment + + let context: AccountContext + let scheduleAction: (Int32) -> Void + + init( + context: AccountContext, + scheduleAction: @escaping (Int32) -> Void + ) { + self.context = context + self.scheduleAction = scheduleAction + } + + static func ==(lhs: ScheduleVideoChatSheetScreenComponent, rhs: ScheduleVideoChatSheetScreenComponent) -> Bool { + if lhs.context !== rhs.context { + return false + } + return true + } + + final class View: UIView { + private let sheet = ComponentView<(ViewControllerComponentContainer.Environment, SheetComponentEnvironment)>() + private let sheetAnimateOut = ActionSlot>() + + private var component: ScheduleVideoChatSheetScreenComponent? + private var environment: EnvironmentType? + + override init(frame: CGRect) { + super.init(frame: frame) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func update(component: ScheduleVideoChatSheetScreenComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + self.component = component + + let environment = environment[ViewControllerComponentContainer.Environment.self].value + self.environment = environment + + let sheetEnvironment = SheetComponentEnvironment( + isDisplaying: environment.isVisible, + isCentered: environment.metrics.widthClass == .regular, + hasInputHeight: !environment.inputHeight.isZero, + regularMetricsSize: CGSize(width: 430.0, height: 900.0), + dismiss: { [weak self] _ in + guard let self, let environment = self.environment else { + return + } + self.sheetAnimateOut.invoke(Action { _ in + if let controller = environment.controller() { + controller.dismiss(completion: nil) + } + }) + } + ) + let _ = self.sheet.update( + transition: transition, + component: AnyComponent(SheetComponent( + content: AnyComponent(ScheduleVideoChatSheetContentComponent( + scheduleAction: { [weak self] timestamp in + guard let self else { + return + } + self.sheetAnimateOut.invoke(Action { [weak self] _ in + guard let self, let component = self.component else { + return + } + if let controller = self.environment?.controller() { + controller.dismiss(completion: nil) + } + + component.scheduleAction(timestamp) + }) + }, + dismiss: { [weak self] in + guard let self else { + return + } + self.sheetAnimateOut.invoke(Action { [weak self] _ in + guard let self else { + return + } + if let controller = self.environment?.controller() { + controller.dismiss(completion: nil) + } + }) + } + )), + backgroundColor: .color(UIColor(rgb: 0x1C1C1E)), + animateOut: self.sheetAnimateOut + )), + environment: { + environment + sheetEnvironment + }, + containerSize: availableSize + ) + if let sheetView = self.sheet.view { + if sheetView.superview == nil { + self.addSubview(sheetView) + } + transition.setFrame(view: sheetView, 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) + } +} + +public class ScheduleVideoChatSheetScreen: ViewControllerComponentContainer { + public init(context: AccountContext, scheduleAction: @escaping (Int32) -> Void) { + super.init(context: context, component: ScheduleVideoChatSheetScreenComponent( + context: context, + scheduleAction: scheduleAction + ), navigationBarAppearance: .none, theme: .dark) + + self.statusBar.statusBarStyle = .Ignore + self.navigationPresentation = .flatModal + self.blocksBackgroundWhenInOverlay = true + } + + required public init(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + } + + override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { + super.containerLayoutUpdated(layout, transition: transition) + } + + override public func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + self.view.disablesInteractiveModalDismiss = true + } +} diff --git a/submodules/TelegramCallsUI/Sources/VideoChatActionButtonComponent.swift b/submodules/TelegramCallsUI/Sources/VideoChatActionButtonComponent.swift index 13c69b2cf2..e965986f30 100644 --- a/submodules/TelegramCallsUI/Sources/VideoChatActionButtonComponent.swift +++ b/submodules/TelegramCallsUI/Sources/VideoChatActionButtonComponent.swift @@ -34,11 +34,13 @@ final class VideoChatActionButtonComponent: Component { case audio(audio: Audio) case video case leave + case switchVideo } case audio(audio: Audio) case video(isActive: Bool) case leave + case switchVideo fileprivate var iconType: IconType { switch self { @@ -57,6 +59,8 @@ final class VideoChatActionButtonComponent: Component { return .video case .leave: return .leave + case .switchVideo: + return .switchVideo } } } @@ -66,6 +70,7 @@ final class VideoChatActionButtonComponent: Component { case muted case unmuted case raiseHand + case scheduled } let strings: PresentationStrings @@ -156,7 +161,7 @@ final class VideoChatActionButtonComponent: Component { backgroundColor = !isActive ? UIColor(rgb: 0x002E5D) : UIColor(rgb: 0x027FFF) case .unmuted: backgroundColor = !isActive ? UIColor(rgb: 0x124B21) : UIColor(rgb: 0x34C659) - case .raiseHand: + case .raiseHand, .scheduled: backgroundColor = UIColor(rgb: 0x3252EF) } iconDiameter = 60.0 @@ -169,10 +174,23 @@ final class VideoChatActionButtonComponent: Component { backgroundColor = !isActive ? UIColor(rgb: 0x002E5D) : UIColor(rgb: 0x027FFF) case .unmuted: backgroundColor = !isActive ? UIColor(rgb: 0x124B21) : UIColor(rgb: 0x34C659) - case .raiseHand: + case .raiseHand, .scheduled: backgroundColor = UIColor(rgb: 0x3252EF) } iconDiameter = 60.0 + case .switchVideo: + titleText = "" + switch component.microphoneState { + case .connecting: + backgroundColor = UIColor(white: 0.1, alpha: 1.0) + case .muted: + backgroundColor = UIColor(rgb: 0x027FFF) + case .unmuted: + backgroundColor = UIColor(rgb: 0x34C659) + case .raiseHand, .scheduled: + backgroundColor = UIColor(rgb: 0x3252EF) + } + iconDiameter = 54.0 case .leave: titleText = "leave" backgroundColor = UIColor(rgb: 0x47191E) @@ -203,6 +221,8 @@ final class VideoChatActionButtonComponent: Component { self.contentImage = UIImage(bundleImageName: iconName)?.precomposed().withRenderingMode(.alwaysTemplate) case .video: self.contentImage = UIImage(bundleImageName: "Call/CallCameraButton")?.precomposed().withRenderingMode(.alwaysTemplate) + case .switchVideo: + self.contentImage = UIImage(bundleImageName: "Call/CallSwitchCameraButton")?.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) @@ -247,7 +267,11 @@ final class VideoChatActionButtonComponent: Component { } else { tintTransition = .immediate } - tintTransition.setTintColor(layer: self.background.layer, color: backgroundColor) + let previousTintColor = self.background.tintColor + self.background.tintColor = backgroundColor + if let previousTintColor, previousTintColor != backgroundColor { + tintTransition.animateTintColor(layer: self.background.layer, from: previousTintColor, to: 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 { @@ -274,7 +298,9 @@ final class VideoChatActionButtonComponent: Component { if iconView.superview == nil { self.addSubview(iconView) } - transition.setFrame(view: iconView, frame: iconFrame) + transition.setPosition(view: iconView, position: iconFrame.center) + transition.setBounds(view: iconView, bounds: CGRect(origin: CGPoint(), size: iconFrame.size)) + transition.setScale(view: iconView, scale: availableSize.width / 56.0) } return size diff --git a/submodules/TelegramCallsUI/Sources/VideoChatExpandedSpeakingToastComponent.swift b/submodules/TelegramCallsUI/Sources/VideoChatExpandedSpeakingToastComponent.swift new file mode 100644 index 0000000000..8170418a48 --- /dev/null +++ b/submodules/TelegramCallsUI/Sources/VideoChatExpandedSpeakingToastComponent.swift @@ -0,0 +1,185 @@ +import Foundation +import UIKit +import Display +import ComponentFlow +import MultilineTextComponent +import AvatarNode +import TelegramPresentationData +import AccountContext +import TelegramCore +import Markdown +import TextFormat + +final class VideoChatExpandedSpeakingToastComponent: Component { + let context: AccountContext + let peer: EnginePeer + let strings: PresentationStrings + let theme: PresentationTheme + let action: (EnginePeer) -> Void + + init( + context: AccountContext, + peer: EnginePeer, + strings: PresentationStrings, + theme: PresentationTheme, + action: @escaping (EnginePeer) -> Void + ) { + self.context = context + self.peer = peer + self.strings = strings + self.theme = theme + self.action = action + } + + static func ==(lhs: VideoChatExpandedSpeakingToastComponent, rhs: VideoChatExpandedSpeakingToastComponent) -> Bool { + if lhs.context !== rhs.context { + return false + } + if lhs.peer != rhs.peer { + return false + } + if lhs.strings !== rhs.strings { + return false + } + if lhs.theme !== rhs.theme { + return false + } + return true + } + + final class View: HighlightTrackingButton { + private let background = ComponentView() + private let title = ComponentView() + private var avatarNode: AvatarNode? + + private var component: VideoChatExpandedSpeakingToastComponent? + private var isUpdating: Bool = false + + override init(frame: CGRect) { + super.init(frame: frame) + + self.addTarget(self, action: #selector(self.pressed), for: .touchUpInside) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc private func pressed() { + if let component = self.component { + component.action(component.peer) + } + } + + func update(component: VideoChatExpandedSpeakingToastComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + self.isUpdating = true + defer { + self.isUpdating = false + } + + self.component = component + + let avatarLeftInset: CGFloat = 3.0 + let avatarVerticalInset: CGFloat = 3.0 + let avatarSpacing: CGFloat = 12.0 + let rightInset: CGFloat = 16.0 + let avatarWidth: CGFloat = 32.0 + + let presentationData = component.context.sharedContext.currentPresentationData.with({ $0 }) + let bodyAttributes = MarkdownAttributeSet(font: Font.regular(15.0), textColor: .white, additionalAttributes: [:]) + let boldAttributes = MarkdownAttributeSet(font: Font.semibold(15.0), textColor: .white, additionalAttributes: [:]) + let titleText = addAttributesToStringWithRanges(component.strings.VoiceChat_ParticipantIsSpeaking(component.peer.displayTitle(strings: component.strings, displayOrder: presentationData.nameDisplayOrder))._tuple, body: bodyAttributes, argumentAttributes: [0: boldAttributes]) + + let titleSize = self.title.update( + transition: .immediate, + component: AnyComponent(MultilineTextComponent( + text: .plain(titleText) + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - avatarLeftInset - avatarWidth - avatarSpacing - rightInset, height: 100.0) + ) + + let size = CGSize(width: avatarLeftInset + avatarWidth + avatarSpacing + titleSize.width + rightInset, height: avatarWidth + avatarVerticalInset * 2.0) + + let _ = self.background.update( + transition: transition, + component: AnyComponent(FilledRoundedRectangleComponent( + color: UIColor(white: 0.0, alpha: 0.9), + cornerRadius: size.height * 0.5, + smoothCorners: false + )), + environment: {}, + containerSize: size + ) + let backgroundFrame = CGRect(origin: CGPoint(), size: size) + if let backgroundView = self.background.view { + if backgroundView.superview == nil { + backgroundView.isUserInteractionEnabled = false + self.addSubview(backgroundView) + } + transition.setFrame(view: backgroundView, frame: backgroundFrame) + } + + let titleFrame = CGRect(origin: CGPoint(x: avatarLeftInset + avatarWidth + avatarSpacing, 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 avatarNode: AvatarNode + if let current = self.avatarNode { + avatarNode = current + } else { + avatarNode = AvatarNode(font: avatarPlaceholderFont(size: 15.0)) + self.avatarNode = avatarNode + self.addSubview(avatarNode.view) + avatarNode.isUserInteractionEnabled = false + } + + let avatarSize = CGSize(width: avatarWidth, height: avatarWidth) + + let clipStyle: AvatarNodeClipStyle + if case let .channel(channel) = component.peer, channel.flags.contains(.isForum) { + clipStyle = .roundedRect + } else { + clipStyle = .round + } + + if component.peer.smallProfileImage != nil { + avatarNode.setPeerV2( + context: component.context, + theme: component.theme, + peer: component.peer, + authorOfMessage: nil, + overrideImage: nil, + emptyColor: nil, + clipStyle: .round, + synchronousLoad: false, + displayDimensions: avatarSize + ) + } else { + avatarNode.setPeer(context: component.context, theme: component.theme, peer: component.peer, clipStyle: clipStyle, synchronousLoad: false, displayDimensions: avatarSize) + } + + let avatarFrame = CGRect(origin: CGPoint(x: avatarLeftInset, y: avatarVerticalInset), size: avatarSize) + transition.setPosition(view: avatarNode.view, position: avatarFrame.center) + transition.setBounds(view: avatarNode.view, bounds: CGRect(origin: CGPoint(), size: avatarFrame.size)) + avatarNode.updateSize(size: avatarSize) + + 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 index 9e7c87b5bb..d7db6cc15f 100644 --- a/submodules/TelegramCallsUI/Sources/VideoChatMicButtonComponent.swift +++ b/submodules/TelegramCallsUI/Sources/VideoChatMicButtonComponent.swift @@ -175,11 +175,17 @@ private final class GlowView: UIView { } final class VideoChatMicButtonComponent: Component { + enum ScheduledState: Equatable { + case start + case toggleSubscription(isSubscribed: Bool) + } + enum Content: Equatable { case connecting case muted case unmuted(pushToTalk: Bool) - case raiseHand + case raiseHand(isRaised: Bool) + case scheduled(state: ScheduledState) } let call: PresentationGroupCall @@ -187,19 +193,22 @@ final class VideoChatMicButtonComponent: Component { let isCollapsed: Bool let updateUnmutedStateIsPushToTalk: (Bool?) -> Void let raiseHand: () -> Void + let scheduleAction: () -> Void init( call: PresentationGroupCall, content: Content, isCollapsed: Bool, updateUnmutedStateIsPushToTalk: @escaping (Bool?) -> Void, - raiseHand: @escaping () -> Void + raiseHand: @escaping () -> Void, + scheduleAction: @escaping () -> Void ) { self.call = call self.content = content self.isCollapsed = isCollapsed self.updateUnmutedStateIsPushToTalk = updateUnmutedStateIsPushToTalk self.raiseHand = raiseHand + self.scheduleAction = scheduleAction } static func ==(lhs: VideoChatMicButtonComponent, rhs: VideoChatMicButtonComponent) -> Bool { @@ -217,6 +226,7 @@ final class VideoChatMicButtonComponent: Component { private var disappearingBackgrounds: [UIImageView] = [] private var progressIndicator: RadialStatusNode? private let title = ComponentView() + private var subtitle: ComponentView? private let icon: VoiceChatActionButtonIconNode private var glowView: GlowView? @@ -245,7 +255,7 @@ final class VideoChatMicButtonComponent: Component { self.beginTrackingTimestamp = CFAbsoluteTimeGetCurrent() if let component = self.component { switch component.content { - case .connecting, .unmuted, .raiseHand: + case .connecting, .unmuted, .raiseHand, .scheduled: self.beginTrackingWasPushToTalk = false case .muted: self.beginTrackingWasPushToTalk = true @@ -291,6 +301,8 @@ final class VideoChatMicButtonComponent: Component { self.icon.playRandomAnimation() component.raiseHand() + case .scheduled: + component.scheduleAction() } } } @@ -311,6 +323,7 @@ final class VideoChatMicButtonComponent: Component { let alphaTransition: ComponentTransition = transition.animation.isImmediate ? .immediate : .easeInOut(duration: 0.2) let titleText: String + var subtitleText: String? var isEnabled = true switch component.content { case .connecting: @@ -320,8 +333,25 @@ final class VideoChatMicButtonComponent: Component { titleText = "Unmute" case let .unmuted(isPushToTalk): titleText = isPushToTalk ? "You are Live" : "Tap to Mute" - case .raiseHand: - titleText = "Raise Hand" + case let .raiseHand(isRaised): + if isRaised { + titleText = "You asked to speak" + subtitleText = "We let the speakers know" + } else { + titleText = "Muted by Admin" + subtitleText = "Tap if you want to speak" + } + case let .scheduled(state): + switch state { + case .start: + titleText = "Start Now" + case let .toggleSubscription(isSubscribed): + if isSubscribed { + titleText = "Clear Reminder" + } else { + titleText = "Set Reminder" + } + } } self.isEnabled = isEnabled @@ -331,7 +361,7 @@ final class VideoChatMicButtonComponent: Component { text: .plain(NSAttributedString(string: titleText, font: Font.regular(15.0), textColor: .white)) )), environment: {}, - containerSize: CGSize(width: 120.0, height: 100.0) + containerSize: CGSize(width: 180.0, height: 100.0) ) let size = CGSize(width: availableSize.width, height: availableSize.height) @@ -390,12 +420,14 @@ final class VideoChatMicButtonComponent: Component { case .connecting: context.setFillColor(UIColor(white: 0.1, alpha: 1.0).cgColor) context.fill(CGRect(origin: CGPoint(), size: size)) - case .muted, .unmuted, .raiseHand: + case .muted, .unmuted, .raiseHand, .scheduled: 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 if case .scheduled = component.content { + colors = [UIColor(rgb: 0x3252EF), UIColor(rgb: 0xC64688)] } else { colors = [UIColor(rgb: 0x33C659), UIColor(rgb: 0x0BA8A5)] } @@ -446,7 +478,10 @@ final class VideoChatMicButtonComponent: Component { 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) + var titleFrame = CGRect(origin: CGPoint(x: floor((size.width - titleSize.width) * 0.5), y: size.height + 16.0), size: titleSize) + if subtitleText != nil { + titleFrame.origin.y -= 5.0 + } if let titleView = self.title.view { if titleView.superview == nil { titleView.isUserInteractionEnabled = false @@ -457,6 +492,47 @@ final class VideoChatMicButtonComponent: Component { alphaTransition.setAlpha(view: titleView, alpha: component.isCollapsed ? 0.0 : 1.0) } + if let subtitleText { + let subtitle: ComponentView + var subtitleTransition = transition + if let current = self.subtitle { + subtitle = current + } else { + subtitleTransition = subtitleTransition.withAnimation(.none) + subtitle = ComponentView() + self.subtitle = subtitle + } + let subtitleSize = subtitle.update( + transition: .immediate, + component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString(string: subtitleText, font: Font.regular(13.0), textColor: .white)) + )), + environment: {}, + containerSize: CGSize(width: 180.0, height: 100.0) + ) + let subtitleFrame = CGRect(origin: CGPoint(x: floor((size.width - subtitleSize.width) * 0.5), y: titleFrame.maxY + 1.0), size: subtitleSize) + if let subtitleView = subtitle.view { + if subtitleView.superview == nil { + subtitleView.isUserInteractionEnabled = false + self.addSubview(subtitleView) + + subtitleView.alpha = 0.0 + transition.animateScale(view: subtitleView, from: 0.001, to: 1.0) + } + subtitleTransition.setPosition(view: subtitleView, position: subtitleFrame.center) + subtitleView.bounds = CGRect(origin: CGPoint(), size: subtitleFrame.size) + alphaTransition.setAlpha(view: subtitleView, alpha: component.isCollapsed ? 0.0 : 1.0) + } + } else if let subtitle = self.subtitle { + self.subtitle = nil + if let subtitleView = subtitle.view { + transition.setScale(view: subtitleView, scale: 0.001) + alphaTransition.setAlpha(view: subtitleView, alpha: 0.0, completion: { [weak subtitleView] _ in + subtitleView?.removeFromSuperview() + }) + } + } + if self.icon.view.superview == nil { self.icon.view.isUserInteractionEnabled = false self.addSubview(self.icon.view) @@ -477,10 +553,21 @@ final class VideoChatMicButtonComponent: Component { self.icon.enqueueState(.unmute) case .raiseHand: self.icon.enqueueState(.hand) + case let .scheduled(state): + switch state { + case .start: + self.icon.enqueueState(.start) + case let .toggleSubscription(isSubscribed): + if isSubscribed { + self.icon.enqueueState(.unsubscribe) + } else { + self.icon.enqueueState(.subscribe) + } + } } switch component.content { - case .muted, .unmuted, .raiseHand: + case .muted, .unmuted, .raiseHand, .scheduled: let blobSize = CGRect(origin: CGPoint(), size: CGSize(width: 116.0, height: 116.0)).insetBy(dx: -40.0, dy: -40.0).size let blobTintTransition: ComponentTransition @@ -512,6 +599,8 @@ final class VideoChatMicButtonComponent: Component { blobsColor = UIColor(rgb: 0x0086FF) } else if case .raiseHand = component.content { blobsColor = UIColor(rgb: 0x914BAD) + } else if case .scheduled = component.content { + blobsColor = UIColor(rgb: 0x914BAD) } else { blobsColor = UIColor(rgb: 0x33C758) } @@ -528,7 +617,7 @@ final class VideoChatMicButtonComponent: Component { blobView.updateLevel(CGFloat(value), immediately: false) }) } - case .connecting, .muted, .raiseHand: + case .connecting, .muted, .raiseHand, .scheduled: if let audioLevelDisposable = self.audioLevelDisposable { self.audioLevelDisposable = nil audioLevelDisposable.dispose() @@ -561,6 +650,8 @@ final class VideoChatMicButtonComponent: Component { glowColor = UIColor(rgb: 0x0086FF) } else if case .raiseHand = component.content { glowColor = UIColor(rgb: 0x3252EF) + } else if case .scheduled = component.content { + glowColor = UIColor(rgb: 0x3252EF) } else { glowColor = UIColor(rgb: 0x33C758) } diff --git a/submodules/TelegramCallsUI/Sources/VideoChatMuteIconComponent.swift b/submodules/TelegramCallsUI/Sources/VideoChatMuteIconComponent.swift index 3bc92c4bcf..f50c490402 100644 --- a/submodules/TelegramCallsUI/Sources/VideoChatMuteIconComponent.swift +++ b/submodules/TelegramCallsUI/Sources/VideoChatMuteIconComponent.swift @@ -62,6 +62,7 @@ final class VideoChatMuteIconComponent: Component { self.isUpdating = false } + let previousComponent = self.component self.component = component if case let .mute(isFilled, isMuted) = component.content { @@ -77,7 +78,10 @@ final class VideoChatMuteIconComponent: Component { 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) + if let previousComponent, previousComponent.content == component.content, previousComponent.color == component.color { + } else { + 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 diff --git a/submodules/TelegramCallsUI/Sources/VideoChatParticipantAvatarComponent.swift b/submodules/TelegramCallsUI/Sources/VideoChatParticipantAvatarComponent.swift index 2369a08d5c..bcc2c08954 100644 --- a/submodules/TelegramCallsUI/Sources/VideoChatParticipantAvatarComponent.swift +++ b/submodules/TelegramCallsUI/Sources/VideoChatParticipantAvatarComponent.swift @@ -136,6 +136,7 @@ final class VideoChatParticipantAvatarComponent: Component { let peer: EnginePeer let myPeerId: EnginePeer.Id let isSpeaking: Bool + let isMutedForMe: Bool let theme: PresentationTheme init( @@ -143,12 +144,14 @@ final class VideoChatParticipantAvatarComponent: Component { peer: EnginePeer, myPeerId: EnginePeer.Id, isSpeaking: Bool, + isMutedForMe: Bool, theme: PresentationTheme ) { self.call = call self.peer = peer self.myPeerId = myPeerId self.isSpeaking = isSpeaking + self.isMutedForMe = isMutedForMe self.theme = theme } @@ -159,10 +162,13 @@ final class VideoChatParticipantAvatarComponent: Component { if lhs.peer != rhs.peer { return false } + if lhs.myPeerId != rhs.myPeerId { + return false + } if lhs.isSpeaking != rhs.isSpeaking { return false } - if lhs.myPeerId != rhs.myPeerId { + if lhs.isMutedForMe != rhs.isMutedForMe { return false } if lhs.theme !== rhs.theme { @@ -259,7 +265,15 @@ final class VideoChatParticipantAvatarComponent: Component { } else { tintTransition = .immediate } - tintTransition.setTintColor(layer: blobView.blobsLayer, color: component.isSpeaking ? UIColor(rgb: 0x33C758) : component.theme.list.itemAccentColor) + let tintColor: UIColor + if component.isMutedForMe { + tintColor = UIColor(rgb: 0xff3b30) + } else if component.isSpeaking { + tintColor = UIColor(rgb: 0x33C758) + } else { + tintColor = component.theme.list.itemAccentColor + } + tintTransition.setTintColor(layer: blobView.blobsLayer, color: tintColor) } if component.peer.smallProfileImage != nil { @@ -283,7 +297,7 @@ final class VideoChatParticipantAvatarComponent: Component { transition.setBounds(view: avatarNode.view, bounds: CGRect(origin: CGPoint(), size: avatarFrame.size)) avatarNode.updateSize(size: avatarSize) - let blobScale: CGFloat = 1.5 + let blobScale: CGFloat = 2.0 if self.audioLevelDisposable == nil { struct Level { @@ -362,7 +376,15 @@ final class VideoChatParticipantAvatarComponent: Component { avatarNode.layer.transform = CATransform3DMakeScale(1.0 + additionalScale, 1.0 + additionalScale, 1.0) } - ComponentTransition.immediate.setTintColor(layer: blobView.blobsLayer, color: component.isSpeaking ? UIColor(rgb: 0x33C758) : component.theme.list.itemAccentColor) + let tintColor: UIColor + if component.isMutedForMe { + tintColor = UIColor(rgb: 0xff3b30) + } else if component.isSpeaking { + tintColor = UIColor(rgb: 0x33C758) + } else { + tintColor = component.theme.list.itemAccentColor + } + ComponentTransition.immediate.setTintColor(layer: blobView.blobsLayer, color: tintColor) } if blobView.alpha == 0.0 { diff --git a/submodules/TelegramCallsUI/Sources/VideoChatParticipantStatusComponent.swift b/submodules/TelegramCallsUI/Sources/VideoChatParticipantStatusComponent.swift index 5b480bac46..7375afb149 100644 --- a/submodules/TelegramCallsUI/Sources/VideoChatParticipantStatusComponent.swift +++ b/submodules/TelegramCallsUI/Sources/VideoChatParticipantStatusComponent.swift @@ -121,7 +121,9 @@ final class VideoChatParticipantStatusComponent: Component { } if let iconView = muteStatusView.iconView { let iconTintColor: UIColor - if component.isSpeaking { + if let muteState = component.muteState, muteState.mutedByYou { + iconTintColor = UIColor(rgb: 0xff3b30) + } else if component.isSpeaking { iconTintColor = UIColor(rgb: 0x33C758) } else { if let muteState = component.muteState { diff --git a/submodules/TelegramCallsUI/Sources/VideoChatParticipantVideoComponent.swift b/submodules/TelegramCallsUI/Sources/VideoChatParticipantVideoComponent.swift index d295c40a7f..7fd4ae4af7 100644 --- a/submodules/TelegramCallsUI/Sources/VideoChatParticipantVideoComponent.swift +++ b/submodules/TelegramCallsUI/Sources/VideoChatParticipantVideoComponent.swift @@ -352,7 +352,10 @@ final class VideoChatParticipantVideoComponent: Component { alphaTransition.setAlpha(view: titleView, alpha: controlsAlpha) } - let videoDescription = component.isPresentation ? component.participant.presentationDescription : component.participant.videoDescription + var videoDescription = component.isPresentation ? component.participant.presentationDescription : component.participant.videoDescription + if component.isPresentation && component.isMyPeer { + videoDescription = nil + } var isEffectivelyPaused = false if let videoDescription, videoDescription.isPaused { diff --git a/submodules/TelegramCallsUI/Sources/VideoChatParticipantsComponent.swift b/submodules/TelegramCallsUI/Sources/VideoChatParticipantsComponent.swift index c5c197f6e2..e7e0c24831 100644 --- a/submodules/TelegramCallsUI/Sources/VideoChatParticipantsComponent.swift +++ b/submodules/TelegramCallsUI/Sources/VideoChatParticipantsComponent.swift @@ -117,6 +117,13 @@ final class VideoChatParticipantsComponent: Component { } } + final class EventCycleState { + var ignoreScrolling: Bool = false + + init() { + } + } + let call: PresentationGroupCall let participants: Participants? let speakingParticipants: Set @@ -132,6 +139,7 @@ final class VideoChatParticipantsComponent: Component { let updateIsMainParticipantPinned: (Bool) -> Void let updateIsExpandedUIHidden: (Bool) -> Void let openInviteMembers: () -> Void + let visibleParticipantsUpdated: (Set) -> Void init( call: PresentationGroupCall, @@ -148,7 +156,8 @@ final class VideoChatParticipantsComponent: Component { updateMainParticipant: @escaping (VideoParticipantKey?, Bool?) -> Void, updateIsMainParticipantPinned: @escaping (Bool) -> Void, updateIsExpandedUIHidden: @escaping (Bool) -> Void, - openInviteMembers: @escaping () -> Void + openInviteMembers: @escaping () -> Void, + visibleParticipantsUpdated: @escaping (Set) -> Void ) { self.call = call self.participants = participants @@ -165,6 +174,7 @@ final class VideoChatParticipantsComponent: Component { self.updateIsMainParticipantPinned = updateIsMainParticipantPinned self.updateIsExpandedUIHidden = updateIsExpandedUIHidden self.openInviteMembers = openInviteMembers + self.visibleParticipantsUpdated = visibleParticipantsUpdated } static func ==(lhs: VideoChatParticipantsComponent, rhs: VideoChatParticipantsComponent) -> Bool { @@ -477,7 +487,7 @@ final class VideoChatParticipantsComponent: Component { 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.scrollClippingFrame = CGRect(origin: CGPoint(x: self.listFrame.minX + layout.mainColumn.insets.left, y: layout.mainColumn.insets.top), size: CGSize(width: listWidth - layout.mainColumn.insets.left - layout.mainColumn.insets.right, height: containerSize.height - layout.mainColumn.insets.top)) 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)) @@ -599,6 +609,7 @@ final class VideoChatParticipantsComponent: Component { final class View: UIView, UIScrollViewDelegate { private let scrollViewClippingContainer: SolidRoundedCornersContainer private let scrollView: ScrollView + private let scrollViewBottomShadowView: UIImageView private let separateVideoScrollViewClippingContainer: SolidRoundedCornersContainer private let separateVideoScrollView: ScrollView @@ -622,6 +633,7 @@ final class VideoChatParticipantsComponent: Component { private let expandedGridItemContainer: UIView private var expandedControlsView: ComponentView? private var expandedThumbnailsView: ComponentView? + private var expandedSpeakingToast: ComponentView? private var listItemViews: [EnginePeer.Id: ListItem] = [:] private let listItemViewContainer: UIView @@ -635,9 +647,13 @@ final class VideoChatParticipantsComponent: Component { private var currentLoadMoreToken: String? + private var mainScrollViewEventCycleState: EventCycleState? + private var separateVideoScrollViewEventCycleState: EventCycleState? + override init(frame: CGRect) { self.scrollViewClippingContainer = SolidRoundedCornersContainer() self.scrollView = ScrollView() + self.scrollViewBottomShadowView = UIImageView() self.separateVideoScrollViewClippingContainer = SolidRoundedCornersContainer() self.separateVideoScrollView = ScrollView() @@ -687,6 +703,7 @@ final class VideoChatParticipantsComponent: Component { self.scrollViewClippingContainer.addSubview(self.scrollView) self.addSubview(self.scrollViewClippingContainer) self.addSubview(self.scrollViewClippingContainer.cornersView) + self.addSubview(self.scrollViewBottomShadowView) self.separateVideoScrollViewClippingContainer.addSubview(self.separateVideoScrollView) self.addSubview(self.separateVideoScrollViewClippingContainer) @@ -765,10 +782,46 @@ final class VideoChatParticipantsComponent: Component { func scrollViewDidScroll(_ scrollView: UIScrollView) { if !self.ignoreScrolling { + if scrollView == self.scrollView { + if let eventCycleState = self.mainScrollViewEventCycleState { + if eventCycleState.ignoreScrolling { + self.ignoreScrolling = true + scrollView.contentOffset = CGPoint() + self.ignoreScrolling = false + return + } + } + } else if scrollView == self.separateVideoScrollView { + if let eventCycleState = self.separateVideoScrollViewEventCycleState { + if eventCycleState.ignoreScrolling { + self.ignoreScrolling = true + scrollView.contentOffset = CGPoint() + self.ignoreScrolling = false + return + } + } + } + self.updateScrolling(transition: .immediate) } } + func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer) { + if scrollView == self.scrollView { + if let eventCycleState = self.mainScrollViewEventCycleState { + if eventCycleState.ignoreScrolling { + targetContentOffset.pointee.y = 0.0 + } + } + } else if scrollView == self.separateVideoScrollView { + if let eventCycleState = self.separateVideoScrollViewEventCycleState { + if eventCycleState.ignoreScrolling { + targetContentOffset.pointee.y = 0.0 + } + } + } + } + private func updateScrolling(transition: ComponentTransition) { guard let component = self.component, let itemLayout = self.itemLayout else { return @@ -832,11 +885,18 @@ final class VideoChatParticipantsComponent: Component { var validGridItemIds: [VideoParticipantKey] = [] var validGridItemIndices: [Int] = [] + var clippedScrollViewBounds = self.scrollView.bounds + clippedScrollViewBounds.origin.y += component.layout.mainColumn.insets.top + clippedScrollViewBounds.size.height -= component.layout.mainColumn.insets.top + component.layout.mainColumn.insets.bottom + let visibleGridItemRange: (minIndex: Int, maxIndex: Int) + let clippedVisibleGridItemRange: (minIndex: Int, maxIndex: Int) if itemLayout.layout.videoColumn == nil { visibleGridItemRange = itemLayout.visibleGridItemRange(for: self.scrollView.bounds) + clippedVisibleGridItemRange = itemLayout.visibleGridItemRange(for: clippedScrollViewBounds) } else { visibleGridItemRange = itemLayout.visibleGridItemRange(for: self.separateVideoScrollView.bounds) + clippedVisibleGridItemRange = visibleGridItemRange } if visibleGridItemRange.maxIndex >= visibleGridItemRange.minIndex { for index in visibleGridItemRange.minIndex ... visibleGridItemRange.maxIndex { @@ -852,6 +912,8 @@ final class VideoChatParticipantsComponent: Component { validGridItemIndices.append(index) } } + + var visibleParticipants: [EnginePeer.Id] = [] for index in validGridItemIndices { let videoParticipant = self.gridParticipants[index] @@ -879,6 +941,10 @@ final class VideoChatParticipantsComponent: Component { } } + if isItemExpanded || (index >= clippedVisibleGridItemRange.minIndex && index <= clippedVisibleGridItemRange.maxIndex) { + visibleParticipants.append(videoParticipant.key.id) + } + var suppressItemExpansionCollapseAnimation = false if isItemExpanded { if let previousExpandedItemId, previousExpandedItemId != videoParticipantKey { @@ -1066,11 +1132,16 @@ final class VideoChatParticipantsComponent: Component { var validListItemIds: [EnginePeer.Id] = [] let visibleListItemRange = itemLayout.visibleListItemRange(for: self.scrollView.bounds) + let clippedVisibleListItemRange = itemLayout.visibleListItemRange(for: clippedScrollViewBounds) if visibleListItemRange.maxIndex >= visibleListItemRange.minIndex { for i in visibleListItemRange.minIndex ... visibleListItemRange.maxIndex { let participant = self.listParticipants[i] validListItemIds.append(participant.peer.id) + if i >= clippedVisibleListItemRange.minIndex && i <= clippedVisibleListItemRange.maxIndex { + visibleParticipants.append(participant.peer.id) + } + var itemTransition = transition let itemView: ListItem if let current = self.listItemViews[participant.peer.id] { @@ -1083,13 +1154,26 @@ final class VideoChatParticipantsComponent: Component { let itemFrame = itemLayout.listItemFrame(at: i) + var isMutedForMe = false + if let muteState = participant.muteState, muteState.mutedByYou { + isMutedForMe = true + } + let subtitle: PeerListItemComponent.Subtitle if participant.peer.id == component.call.accountContext.account.peerId { subtitle = PeerListItemComponent.Subtitle(text: "this is you", color: .accent) + } else if let muteState = participant.muteState, muteState.mutedByYou { + subtitle = PeerListItemComponent.Subtitle(text: "muted for you", color: .destructive) } else if component.speakingParticipants.contains(participant.peer.id) { - subtitle = PeerListItemComponent.Subtitle(text: "speaking", color: .constructive) + if let volume = participant.volume, volume != 10000 { + subtitle = PeerListItemComponent.Subtitle(text: "\(volume / 100)% speaking", color: .constructive) + } else { + subtitle = PeerListItemComponent.Subtitle(text: "speaking", color: .constructive) + } + } else if let about = participant.about, !about.isEmpty { + subtitle = PeerListItemComponent.Subtitle(text: about, color: .neutral) } else { - subtitle = PeerListItemComponent.Subtitle(text: participant.about ?? "listening", color: .neutral) + subtitle = PeerListItemComponent.Subtitle(text: "listening", color: .neutral) } let rightAccessoryComponent: AnyComponent = AnyComponent(VideoChatParticipantStatusComponent( @@ -1113,6 +1197,7 @@ final class VideoChatParticipantsComponent: Component { peer: EnginePeer(participant.peer), myPeerId: component.participants?.myPeerId ?? component.call.accountContext.account.peerId, isSpeaking: component.speakingParticipants.contains(participant.peer.id), + isMutedForMe: isMutedForMe, theme: component.theme )), peer: EnginePeer(participant.peer), @@ -1412,12 +1497,86 @@ final class VideoChatParticipantsComponent: Component { } } + if let expandedVideoState = component.expandedVideoState, expandedVideoState.isMainParticipantPinned, let participants = component.participants, !component.speakingParticipants.isEmpty, let firstOther = component.speakingParticipants.first(where: { $0 != expandedVideoState.mainParticipant.id }), let speakingPeer = participants.participants.first(where: { $0.peer.id == firstOther })?.peer { + let expandedSpeakingToast: ComponentView + var expandedSpeakingToastTransition = transition + if let current = self.expandedSpeakingToast { + expandedSpeakingToast = current + } else { + expandedSpeakingToastTransition = expandedSpeakingToastTransition.withAnimation(.none) + expandedSpeakingToast = ComponentView() + self.expandedSpeakingToast = expandedSpeakingToast + } + let expandedSpeakingToastSize = expandedSpeakingToast.update( + transition: expandedSpeakingToastTransition, + component: AnyComponent(VideoChatExpandedSpeakingToastComponent( + context: component.call.accountContext, + peer: EnginePeer(speakingPeer), + strings: component.strings, + theme: component.theme, + action: { [weak self] peer in + guard let self, let component = self.component, let participants = component.participants else { + return + } + guard let participant = participants.participants.first(where: { $0.peer.id == peer.id }) else { + return + } + var key: VideoParticipantKey? + if participant.presentationDescription != nil { + key = VideoParticipantKey(id: peer.id, isPresentation: true) + } else if participant.videoDescription != nil { + key = VideoParticipantKey(id: peer.id, isPresentation: false) + } + if let key { + component.updateMainParticipant(key, nil) + } + } + )), + environment: {}, + containerSize: itemLayout.expandedGrid.itemContainerFrame().size + ) + let expandedSpeakingToastFrame = CGRect(origin: CGPoint(x: floor((itemLayout.expandedGrid.itemContainerFrame().size.width - expandedSpeakingToastSize.width) * 0.5), y: 44.0), size: expandedSpeakingToastSize) + if let expandedSpeakingToastView = expandedSpeakingToast.view { + var animateIn = false + if expandedSpeakingToastView.superview == nil { + animateIn = true + self.expandedGridItemContainer.addSubview(expandedSpeakingToastView) + } + expandedSpeakingToastTransition.setFrame(view: expandedSpeakingToastView, frame: expandedSpeakingToastFrame) + + if animateIn { + alphaTransition.animateAlpha(view: expandedSpeakingToastView, from: 0.0, to: 1.0) + transition.animateScale(view: expandedSpeakingToastView, from: 0.6, to: 1.0) + } + } + } else { + if let expandedSpeakingToast = self.expandedSpeakingToast { + self.expandedSpeakingToast = nil + if let expandedSpeakingToastView = expandedSpeakingToast.view { + alphaTransition.setAlpha(view: expandedSpeakingToastView, alpha: 0.0, completion: { [weak expandedSpeakingToastView] _ in + expandedSpeakingToastView?.removeFromSuperview() + }) + transition.setScale(view: expandedSpeakingToastView, scale: 0.6) + } + } + } + if let participants = component.participants, let loadMoreToken = participants.loadMoreToken, visibleListItemRange.maxIndex >= self.listParticipants.count - 5 { if self.currentLoadMoreToken != loadMoreToken { self.currentLoadMoreToken = loadMoreToken component.call.loadMoreMembers(token: loadMoreToken) } } + + component.visibleParticipantsUpdated(Set(visibleParticipants)) + } + + func setEventCycleState(scrollView: UIScrollView, eventCycleState: EventCycleState?) { + if scrollView == self.scrollView { + self.mainScrollViewEventCycleState = eventCycleState + } else if scrollView == self.separateVideoScrollView { + self.separateVideoScrollViewEventCycleState = eventCycleState + } } func update(component: VideoChatParticipantsComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { @@ -1482,11 +1641,16 @@ final class VideoChatParticipantsComponent: Component { var listParticipants: [GroupCallParticipantsContext.Participant] = [] if let participants = component.participants { for participant in participants.participants { + var isFullyMuted = false + if let muteState = participant.muteState, !muteState.canUnmute { + isFullyMuted = true + } + 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 { + if participant.peer.id == participants.myPeerId { gridParticipants.insert(videoParticipant, at: 0) } else { gridParticipants.append(videoParticipant) @@ -1495,14 +1659,14 @@ final class VideoChatParticipantsComponent: Component { if participant.presentationDescription != nil { hasVideo = true let videoParticipant = VideoParticipant(participant: participant, isPresentation: true) - if participant.peer.id == component.call.accountContext.account.peerId { + if participant.peer.id == participants.myPeerId { gridParticipants.insert(videoParticipant, at: 0) } else { gridParticipants.append(videoParticipant) } } if !hasVideo || component.layout.videoColumn != nil { - if participant.peer.id == component.call.accountContext.account.peerId { + if participant.peer.id == participants.myPeerId && !isFullyMuted { listParticipants.insert(participant, at: 0) } else { listParticipants.append(participant) @@ -1594,6 +1758,37 @@ final class VideoChatParticipantsComponent: Component { smoothCorners: false ), transition: transition) + if self.scrollViewBottomShadowView.image == nil { + let height: CGFloat = 80.0 + let baseGradientAlpha: CGFloat = 1.0 + let numSteps = 8 + let firstStep = 0 + let firstLocation = 0.0 + let colors = (0 ..< numSteps).map { i -> UIColor in + if i < firstStep { + return UIColor(white: 1.0, alpha: 1.0) + } else { + let step: CGFloat = CGFloat(i - firstStep) / CGFloat(numSteps - firstStep - 1) + let value: CGFloat = 1.0 - bezierPoint(0.42, 0.0, 0.58, 1.0, step) + return UIColor(white: 0.0, alpha: baseGradientAlpha * value) + } + } + let locations = (0 ..< numSteps).map { i -> CGFloat in + if i < firstStep { + return 0.0 + } else { + let step: CGFloat = CGFloat(i - firstStep) / CGFloat(numSteps - firstStep - 1) + return (firstLocation + (1.0 - firstLocation) * step) + } + } + + self.scrollViewBottomShadowView.image = generateGradientImage(size: CGSize(width: 8.0, height: height), colors: colors.reversed(), locations: locations.reversed().map { 1.0 - $0 })!.withRenderingMode(.alwaysTemplate).stretchableImage(withLeftCapWidth: 0, topCapHeight: Int(height - 1.0)) + self.scrollViewBottomShadowView.tintColor = .black + } + let scrollViewBottomShadowOverflow: CGFloat = 30.0 + let scrollViewBottomShadowFrame = CGRect(origin: CGPoint(x: itemLayout.scrollClippingFrame.minX, y: itemLayout.scrollClippingFrame.maxY - component.layout.mainColumn.insets.bottom - scrollViewBottomShadowOverflow), size: CGSize(width: itemLayout.scrollClippingFrame.width, height: component.layout.mainColumn.insets.bottom + scrollViewBottomShadowOverflow)) + transition.setFrame(view: self.scrollViewBottomShadowView, frame: scrollViewBottomShadowFrame) + 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) diff --git a/submodules/TelegramCallsUI/Sources/VideoChatScheduledInfoComponent.swift b/submodules/TelegramCallsUI/Sources/VideoChatScheduledInfoComponent.swift new file mode 100644 index 0000000000..47e5a2407b --- /dev/null +++ b/submodules/TelegramCallsUI/Sources/VideoChatScheduledInfoComponent.swift @@ -0,0 +1,260 @@ +import Foundation +import UIKit +import Display +import ComponentFlow +import MultilineTextComponent +import TelegramPresentationData +import TelegramStringFormatting +import HierarchyTrackingLayer +import AnimatedTextComponent + +private let purple = UIColor(rgb: 0x3252ef) +private let pink = UIColor(rgb: 0xef436c) + +private let latePurple = UIColor(rgb: 0x974aa9) +private let latePink = UIColor(rgb: 0xf0436c) + +private func textItemsForTimeout(value: Int32) -> [AnimatedTextComponent.Item] { + if value < 3600 { + let minutes = value / 60 + let seconds = value % 60 + + var items: [AnimatedTextComponent.Item] = [] + items.append(AnimatedTextComponent.Item(id: AnyHashable(11), content: .number(Int(minutes), minDigits: 1))) + items.append(AnimatedTextComponent.Item(id: AnyHashable(12), content: .text(":"))) + items.append(AnimatedTextComponent.Item(id: AnyHashable(13), content: .number(Int(seconds), minDigits: 2))) + + return items + } else { + let hours = value / 3600 + let minutes = (value % 3600) / 60 + let seconds = value % 60 + + var items: [AnimatedTextComponent.Item] = [] + items.append(AnimatedTextComponent.Item(id: AnyHashable(9), content: .number(Int(hours), minDigits: 1))) + items.append(AnimatedTextComponent.Item(id: AnyHashable(10), content: .text(":"))) + items.append(AnimatedTextComponent.Item(id: AnyHashable(11), content: .number(Int(minutes), minDigits: 2))) + items.append(AnimatedTextComponent.Item(id: AnyHashable(12), content: .text(":"))) + items.append(AnimatedTextComponent.Item(id: AnyHashable(13), content: .number(Int(seconds), minDigits: 2))) + + return items + } +} + +final class VideoChatScheduledInfoComponent: Component { + let timestamp: Int32 + let strings: PresentationStrings + + init( + timestamp: Int32, + strings: PresentationStrings + ) { + self.timestamp = timestamp + self.strings = strings + } + + static func ==(lhs: VideoChatScheduledInfoComponent, rhs: VideoChatScheduledInfoComponent) -> Bool { + if lhs.timestamp != rhs.timestamp { + return false + } + if lhs.strings !== rhs.strings { + return false + } + return true + } + + final class View: UIView { + private let title = ComponentView() + private let countdownText = ComponentView() + private let dateText = ComponentView() + + private let countdownContainerView: UIView + private let countdownMaskView: UIView + private let countdownGradientLayer: SimpleGradientLayer + private let hierarchyTrackingLayer: HierarchyTrackingLayer + + private var component: VideoChatScheduledInfoComponent? + private weak var state: EmptyComponentState? + private var isUpdating: Bool = false + + private var countdownTimer: Foundation.Timer? + + override init(frame: CGRect) { + self.countdownContainerView = UIView() + self.countdownMaskView = UIView() + + self.countdownGradientLayer = SimpleGradientLayer() + self.countdownGradientLayer.type = .radial + self.countdownGradientLayer.colors = [pink.cgColor, purple.cgColor, purple.cgColor] + self.countdownGradientLayer.locations = [0.0, 0.85, 1.0] + self.countdownGradientLayer.startPoint = CGPoint(x: 1.0, y: 0.0) + self.countdownGradientLayer.endPoint = CGPoint(x: 0.0, y: 1.0) + + self.hierarchyTrackingLayer = HierarchyTrackingLayer() + + super.init(frame: frame) + + self.layer.addSublayer(self.hierarchyTrackingLayer) + + self.countdownContainerView.layer.addSublayer(self.countdownGradientLayer) + self.addSubview(self.countdownContainerView) + + self.countdownContainerView.mask = self.countdownMaskView + + self.hierarchyTrackingLayer.isInHierarchyUpdated = { [weak self] value in + guard let self else { + return + } + if value { + self.updateAnimations() + } else { + self.countdownTimer?.invalidate() + self.countdownTimer = nil + } + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + self.countdownTimer?.invalidate() + } + + private func updateAnimations() { + if let _ = self.countdownGradientLayer.animation(forKey: "movement") { + } else { + let previousValue = self.countdownGradientLayer.startPoint + let newValue = CGPoint(x: CGFloat.random(in: 0.65 ..< 0.85), y: CGFloat.random(in: 0.1 ..< 0.45)) + self.countdownGradientLayer.startPoint = newValue + + CATransaction.begin() + + let animation = CABasicAnimation(keyPath: "startPoint") + animation.duration = Double.random(in: 0.8 ..< 1.4) + animation.fromValue = previousValue + animation.toValue = newValue + + CATransaction.setCompletionBlock { [weak self] in + guard let self else { + return + } + if self.hierarchyTrackingLayer.isInHierarchy { + self.updateAnimations() + } + } + + self.countdownGradientLayer.add(animation, forKey: "movement") + CATransaction.commit() + } + + if self.countdownTimer == nil { + self.countdownTimer = Foundation.Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true, block: { [weak self] _ in + guard let self else { + return + } + self.state?.updated(transition: .easeInOut(duration: 0.2)) + }) + } + } + + func update(component: VideoChatScheduledInfoComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + self.isUpdating = true + defer { + self.isUpdating = false + } + + self.component = component + self.state = state + + let titleSize = self.title.update( + transition: .immediate, + component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString(string: "Starts in", font: Font.with(size: 23.0, design: .round, weight: .semibold), textColor: .white)) + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - 16.0 * 2.0, height: 200.0) + ) + + let remainingSeconds: Int32 = max(0, component.timestamp - Int32(Date().timeIntervalSince1970)) + var items: [AnimatedTextComponent.Item] = [] + if remainingSeconds >= 86400 { + let countdownText = scheduledTimeIntervalString(strings: component.strings, value: remainingSeconds) + items.append(AnimatedTextComponent.Item(id: AnyHashable(0), content: .text(countdownText))) + } else { + items = textItemsForTimeout(value: remainingSeconds) + } + + let countdownTextSize = self.countdownText.update( + transition: transition, + component: AnyComponent(AnimatedTextComponent( + font: Font.with(size: 68.0, design: .round, weight: .semibold, traits: [.monospacedNumbers]), + color: .white, + items: items + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - 16.0 * 2.0, height: 400.0) + ) + + let dateText = humanReadableStringForTimestamp(strings: component.strings, dateTimeFormat: PresentationDateTimeFormat(), timestamp: component.timestamp, alwaysShowTime: true).string + + let dateTextSize = self.dateText.update( + transition: .immediate, + component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString(string: dateText, font: Font.with(size: 23.0, design: .round, weight: .semibold), textColor: .white)) + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - 16.0 * 2.0, height: 400.0) + ) + + let titleSpacing: CGFloat = 5.0 + let dateSpacing: CGFloat = 5.0 + + let contentHeight: CGFloat = titleSize.height + titleSpacing + countdownTextSize.height + dateSpacing + dateTextSize.height + + let titleFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - titleSize.width) * 0.5), y: floor((availableSize.height - contentHeight) * 0.5)), size: titleSize) + let countdownTextFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - countdownTextSize.width) * 0.5), y: titleFrame.maxY + titleSpacing), size: countdownTextSize) + let dateTextFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - dateTextSize.width) * 0.5), y: countdownTextFrame.maxY + dateSpacing), size: dateTextSize) + + 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) + } + + if let countdownTextView = self.countdownText.view { + if countdownTextView.superview == nil { + self.countdownMaskView.addSubview(countdownTextView) + } + transition.setFrame(view: countdownTextView, frame: CGRect(origin: CGPoint(), size: countdownTextFrame.size)) + } + + transition.setFrame(view: self.countdownContainerView, frame: countdownTextFrame) + transition.setFrame(view: self.countdownMaskView, frame: CGRect(origin: CGPoint(), size: countdownTextFrame.size)) + transition.setFrame(layer: self.countdownGradientLayer, frame: CGRect(origin: CGPoint(), size: countdownTextFrame.size)) + + if let dateTextView = self.dateText.view { + if dateTextView.superview == nil { + self.addSubview(dateTextView) + } + transition.setPosition(view: dateTextView, position: dateTextFrame.center) + dateTextView.bounds = CGRect(origin: CGPoint(), size: dateTextFrame.size) + } + + self.updateAnimations() + + 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/VideoChatScreen.swift b/submodules/TelegramCallsUI/Sources/VideoChatScreen.swift index a193b72312..2bc963014d 100644 --- a/submodules/TelegramCallsUI/Sources/VideoChatScreen.swift +++ b/submodules/TelegramCallsUI/Sources/VideoChatScreen.swift @@ -41,15 +41,22 @@ final class VideoChatScreenComponent: Component { return true } - private struct PanGestureState { - var offsetFraction: CGFloat + private final class PanState { + var fraction: CGFloat + weak var scrollView: UIScrollView? + var startContentOffsetY: CGFloat = 0.0 + var accumulatedOffset: CGFloat = 0.0 + var dismissedTooltips: Bool = false + var didLockScrolling: Bool = false + var contentOffset: CGFloat? - init(offsetFraction: CGFloat) { - self.offsetFraction = offsetFraction + init(fraction: CGFloat, scrollView: UIScrollView?) { + self.fraction = fraction + self.scrollView = scrollView } } - final class View: UIView { + final class View: UIView, UIGestureRecognizerDelegate { let containerView: UIView var component: VideoChatScreenComponent? @@ -57,7 +64,7 @@ final class VideoChatScreenComponent: Component { weak var state: EmptyComponentState? var isUpdating: Bool = false - private var panGestureState: PanGestureState? + private var verticalPanState: PanState? var notifyDismissedInteractivelyOnPanGestureApply: Bool = false var completionOnPanGestureApply: (() -> Void)? @@ -69,10 +76,12 @@ final class VideoChatScreenComponent: Component { var navigationSidebarButton: ComponentView? let videoButton = ComponentView() + var switchVideoButton: ComponentView? let leaveButton = ComponentView() let microphoneButton = ComponentView() let participants = ComponentView() + var scheduleInfo: ComponentView? var reconnectedAsEventsDisposable: Disposable? @@ -94,6 +103,9 @@ final class VideoChatScreenComponent: Component { var members: PresentationGroupCallMembers? var membersDisposable: Disposable? + var speakingParticipantPeers: [EnginePeer] = [] + var visibleParticipants: Set = Set() + let isPresentedValue = ValuePromise(false, ignoreRepeated: true) var applicationStateDisposable: Disposable? @@ -116,9 +128,11 @@ final class VideoChatScreenComponent: Component { self.addSubview(self.containerView) - self.addGestureRecognizer(UIPanGestureRecognizer(target: self, action: #selector(self.panGesture(_:)))) + let panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(self.panGesture(_:))) + panGestureRecognizer.delegate = self + self.addGestureRecognizer(panGestureRecognizer) - self.panGestureState = PanGestureState(offsetFraction: 1.0) + self.verticalPanState = PanState(fraction: 1.0, scrollView: nil) } required init?(coder: NSCoder) { @@ -138,37 +152,159 @@ final class VideoChatScreenComponent: Component { } func animateIn() { - self.panGestureState = PanGestureState(offsetFraction: 1.0) + self.verticalPanState = PanState(fraction: 1.0, scrollView: nil) self.state?.updated(transition: .immediate) - self.panGestureState = nil + self.verticalPanState = nil self.state?.updated(transition: .spring(duration: 0.5)) } func animateOut(completion: @escaping () -> Void) { - self.panGestureState = PanGestureState(offsetFraction: 1.0) + self.verticalPanState = PanState(fraction: 1.0, scrollView: nil) self.completionOnPanGestureApply = completion self.state?.updated(transition: .spring(duration: 0.5)) } + @objc public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRequireFailureOf otherGestureRecognizer: UIGestureRecognizer) -> Bool { + if gestureRecognizer is UITapGestureRecognizer { + if otherGestureRecognizer is UIPanGestureRecognizer { + return true + } + return false + } else { + return false + } + } + + public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { + if gestureRecognizer is UIPanGestureRecognizer { + if let otherGestureRecognizer = otherGestureRecognizer as? UIPanGestureRecognizer { + if otherGestureRecognizer.view is UIScrollView { + return true + } + if let participantsView = self.participants.view as? VideoChatParticipantsComponent.View { + if otherGestureRecognizer.view === participantsView { + return true + } + } + } + return false + } else { + return false + } + } + + @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) + let fraction = max(0.0, translation.y / self.bounds.height) + if let verticalPanState = self.verticalPanState { + verticalPanState.fraction = fraction + } else { + var targetScrollView: UIScrollView? + if case .began = recognizer.state, let participantsView = self.participants.view as? VideoChatParticipantsComponent.View { + if let hitResult = participantsView.hitTest(self.convert(recognizer.location(in: self), to: participantsView), with: nil) { + func findTargetScrollView(target: UIView, minParent: UIView) -> UIScrollView? { + if target === participantsView { + return nil + } + if let target = target as? UIScrollView { + return target + } + if let parent = target.superview { + return findTargetScrollView(target: parent, minParent: minParent) + } else { + return nil + } + } + targetScrollView = findTargetScrollView(target: hitResult, minParent: participantsView) + } + } + self.verticalPanState = PanState(fraction: fraction, scrollView: targetScrollView) + if let targetScrollView { + self.verticalPanState?.contentOffset = targetScrollView.contentOffset.y + self.verticalPanState?.startContentOffsetY = recognizer.translation(in: self).y + } + } + + if let verticalPanState = self.verticalPanState { + /*if abs(verticalPanState.fraction) >= 0.1 && !verticalPanState.dismissedTooltips { + verticalPanState.dismissedTooltips = true + self.dismissAllTooltips() + }*/ + + if let scrollView = verticalPanState.scrollView { + let relativeTranslationY = recognizer.translation(in: self).y - verticalPanState.startContentOffsetY + let overflowY = scrollView.contentOffset.y - relativeTranslationY + + if !verticalPanState.didLockScrolling { + if scrollView.contentOffset.y == 0.0 { + verticalPanState.didLockScrolling = true + } + if let previousContentOffset = verticalPanState.contentOffset, (previousContentOffset < 0.0) != (scrollView.contentOffset.y < 0.0) { + verticalPanState.didLockScrolling = true + } + } + + var resetContentOffset = false + if verticalPanState.didLockScrolling { + verticalPanState.accumulatedOffset += -overflowY + + if verticalPanState.accumulatedOffset < 0.0 { + verticalPanState.accumulatedOffset = 0.0 + } + if scrollView.contentOffset.y < 0.0 { + resetContentOffset = true + } + } else { + verticalPanState.accumulatedOffset += -overflowY + verticalPanState.accumulatedOffset = max(0.0, verticalPanState.accumulatedOffset) + } + + if verticalPanState.accumulatedOffset > 0.0 || resetContentOffset { + scrollView.contentOffset = CGPoint() + + if let participantsView = self.participants.view as? VideoChatParticipantsComponent.View { + let eventCycleState = VideoChatParticipantsComponent.EventCycleState() + eventCycleState.ignoreScrolling = true + participantsView.setEventCycleState(scrollView: scrollView, eventCycleState: eventCycleState) + + DispatchQueue.main.async { [weak scrollView, weak participantsView] in + guard let participantsView, let scrollView else { + return + } + participantsView.setEventCycleState(scrollView: scrollView, eventCycleState: nil) + } + } + } + + verticalPanState.contentOffset = scrollView.contentOffset.y + verticalPanState.startContentOffsetY = recognizer.translation(in: self).y + } + + self.state?.updated(transition: .immediate) + } } case .cancelled, .ended: - if !self.bounds.height.isZero { + if !self.bounds.height.isZero, let verticalPanState = self.verticalPanState { let translation = recognizer.translation(in: self) - let panGestureState = PanGestureState(offsetFraction: translation.y / self.bounds.height) + verticalPanState.fraction = max(0.0, translation.y / self.bounds.height) + + let effectiveFraction: CGFloat + if verticalPanState.scrollView != nil { + effectiveFraction = verticalPanState.accumulatedOffset / self.bounds.height + } else { + effectiveFraction = verticalPanState.fraction + } 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.verticalPanState = nil + if effectiveFraction > 0.6 || (effectiveFraction > 0.0 && velocity.y >= 100.0) { + self.verticalPanState = PanState(fraction: effectiveFraction < 0.0 ? -1.0 : 1.0, scrollView: nil) self.notifyDismissedInteractivelyOnPanGestureApply = true if let controller = self.environment?.controller() as? VideoChatScreenV2Impl { controller.notifyDismissed() @@ -346,6 +482,15 @@ final class VideoChatScreenComponent: Component { guard let component = self.component, let environment = self.environment else { return } + guard let callState = self.callState else { + return + } + if case .connecting = callState.networkState { + return + } + if let muteState = callState.muteState, !muteState.canUnmute { + return + } HapticFeedback().impact(.light) if component.call.hasVideo { @@ -555,12 +700,52 @@ final class VideoChatScreenComponent: Component { } } + private func onVisibleParticipantsUpdated(ids: Set) { + if self.visibleParticipants == ids { + return + } + self.visibleParticipants = ids + self.updateTitleSpeakingStatus() + } + + private func updateTitleSpeakingStatus() { + guard let titleView = self.title.view as? VideoChatTitleComponent.View else { + return + } + + if self.speakingParticipantPeers.isEmpty { + titleView.updateActivityStatus(value: nil, transition: .easeInOut(duration: 0.2)) + } else { + var titleSpeakingStatusValue = "" + for participant in self.speakingParticipantPeers { + if !self.visibleParticipants.contains(participant.id) { + if !titleSpeakingStatusValue.isEmpty { + titleSpeakingStatusValue.append(", ") + } + titleSpeakingStatusValue.append(participant.compactDisplayTitle) + } + } + if titleSpeakingStatusValue.isEmpty { + titleView.updateActivityStatus(value: nil, transition: .easeInOut(duration: 0.2)) + } else { + titleView.updateActivityStatus(value: titleSpeakingStatusValue, transition: .easeInOut(duration: 0.2)) + } + } + } + func update(component: VideoChatScreenComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { self.isUpdating = true defer { self.isUpdating = false } + let alphaTransition: ComponentTransition + if transition.animation.isImmediate { + alphaTransition = .immediate + } else { + alphaTransition = .easeInOut(duration: 0.25) + } + let environment = environment[ViewControllerComponentContainer.Environment.self].value let themeUpdated = self.environment?.theme !== environment.theme @@ -577,7 +762,7 @@ final class VideoChatScreenComponent: Component { if self.members != members { var members = members - #if DEBUG && false + #if DEBUG && true if let membersValue = members { var participants = membersValue.participants for i in 1 ... 20 { @@ -632,25 +817,7 @@ final class VideoChatScreenComponent: Component { #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 - }) + let participants = membersValue.participants members = PresentationGroupCallMembers( participants: participants, speakingParticipants: membersValue.speakingParticipants, @@ -738,6 +905,19 @@ final class VideoChatScreenComponent: Component { if !self.isUpdating { self.state?.updated(transition: .spring(duration: 0.4)) } + + var speakingParticipantPeers: [EnginePeer] = [] + if let members, !members.speakingParticipants.isEmpty { + for participant in members.participants { + if members.speakingParticipants.contains(participant.peer.id) { + speakingParticipantPeers.append(EnginePeer(participant.peer)) + } + } + } + if self.speakingParticipantPeers != speakingParticipantPeers { + self.speakingParticipantPeers = speakingParticipantPeers + self.updateTitleSpeakingStatus() + } } }) @@ -890,8 +1070,12 @@ final class VideoChatScreenComponent: Component { } var containerOffset: CGFloat = 0.0 - if let panGestureState = self.panGestureState { - containerOffset = panGestureState.offsetFraction * availableSize.height + if let verticalPanState = self.verticalPanState { + if verticalPanState.scrollView != nil { + containerOffset = verticalPanState.accumulatedOffset + } else { + containerOffset = verticalPanState.fraction * availableSize.height + } self.containerView.layer.cornerRadius = environment.deviceMetrics.screenCornerRadius } @@ -899,7 +1083,7 @@ final class VideoChatScreenComponent: Component { guard let self, completed else { return } - if self.panGestureState == nil { + if self.verticalPanState == nil { self.containerView.layer.cornerRadius = 0.0 } if self.notifyDismissedInteractivelyOnPanGestureApply { @@ -1058,10 +1242,16 @@ final class VideoChatScreenComponent: Component { } 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))) + if let callState = self.callState { + if callState.networkState == .connected, let members = self.members { + idleTitleStatusText = environment.strings.VoiceChat_Panel_Members(Int32(max(1, members.totalCount))) + } else if callState.scheduleTimestamp != nil { + idleTitleStatusText = "scheduled" + } else { + idleTitleStatusText = "connecting..." + } } else { - idleTitleStatusText = "connecting..." + idleTitleStatusText = " " } let titleSize = self.title.update( transition: transition, @@ -1127,11 +1317,26 @@ final class VideoChatScreenComponent: Component { } } - let buttonsSideInset: CGFloat = 42.0 + let actionButtonPlacementArea: (x: CGFloat, width: CGFloat) + if isTwoColumnLayout { + actionButtonPlacementArea = (availableSize.width - sideInset - mainColumnWidth, mainColumnWidth) + } else { + actionButtonPlacementArea = (0.0, availableSize.width) + } + + let buttonsSideInset: CGFloat = 26.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)) + let remainingButtonsSpace: CGFloat = actionButtonPlacementArea.width - buttonsSideInset * 2.0 - buttonsWidth + + let effectiveMaxActionMicrophoneButtonSpacing: CGFloat + if areButtonsCollapsed { + effectiveMaxActionMicrophoneButtonSpacing = 80.0 + } else { + effectiveMaxActionMicrophoneButtonSpacing = maxActionMicrophoneButtonSpacing + } + + let actionMicrophoneButtonSpacing = min(effectiveMaxActionMicrophoneButtonSpacing, 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)) @@ -1158,7 +1363,7 @@ final class VideoChatScreenComponent: Component { } } - let microphoneButtonFrame: CGRect + var microphoneButtonFrame: CGRect if areButtonsCollapsed { microphoneButtonFrame = expandedMicrophoneButtonFrame } else { @@ -1179,8 +1384,41 @@ final class VideoChatScreenComponent: Component { 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)) + var leftActionButtonFrame = CGRect(origin: CGPoint(x: microphoneButtonFrame.minX - actionMicrophoneButtonSpacing - actionButtonDiameter, y: microphoneButtonFrame.minY + floor((microphoneButtonFrame.height - actionButtonDiameter) * 0.5)), size: CGSize(width: actionButtonDiameter, height: actionButtonDiameter)) + var rightActionButtonFrame = CGRect(origin: CGPoint(x: microphoneButtonFrame.maxX + actionMicrophoneButtonSpacing, y: microphoneButtonFrame.minY + floor((microphoneButtonFrame.height - actionButtonDiameter) * 0.5)), size: CGSize(width: actionButtonDiameter, height: actionButtonDiameter)) + + var additionalLeftActionButtonFrame: CGRect? + if let callState = self.callState, callState.hasVideo { + let additionalButtonDiameter: CGFloat + if areButtonsCollapsed { + additionalButtonDiameter = actionButtonDiameter + } else { + additionalButtonDiameter = floor(actionButtonDiameter * 0.64) + } + + if areButtonsCollapsed { + let buttonCount: CGFloat = 4.0 + + let buttonsWidth: CGFloat = actionButtonDiameter * buttonCount + let remainingButtonsSpace: CGFloat = actionButtonPlacementArea.width - buttonsSideInset * 2.0 - buttonsWidth + let maxSpacing: CGFloat = 80.0 + let effectiveSpacing = min(maxSpacing, floor(remainingButtonsSpace / (buttonCount - 1.0))) + + let totalButtonsWidth: CGFloat = buttonsWidth + (buttonCount - 1.0) * effectiveSpacing + let totalButtonsX: CGFloat = actionButtonPlacementArea.x + floor((actionButtonPlacementArea.width - totalButtonsWidth) * 0.5) + additionalLeftActionButtonFrame = CGRect(origin: CGPoint(x: totalButtonsX + CGFloat(0.0) * (actionButtonDiameter + effectiveSpacing), y: leftActionButtonFrame.minY), size: CGSize(width: actionButtonDiameter, height: actionButtonDiameter)) + leftActionButtonFrame = CGRect(origin: CGPoint(x: totalButtonsX + CGFloat(1.0) * (actionButtonDiameter + effectiveSpacing), y: leftActionButtonFrame.minY), size: CGSize(width: actionButtonDiameter, height: actionButtonDiameter)) + microphoneButtonFrame = CGRect(origin: CGPoint(x: totalButtonsX + CGFloat(2.0) * (actionButtonDiameter + effectiveSpacing), y: leftActionButtonFrame.minY), size: CGSize(width: actionButtonDiameter, height: actionButtonDiameter)) + rightActionButtonFrame = CGRect(origin: CGPoint(x: totalButtonsX + CGFloat(3.0) * (actionButtonDiameter + effectiveSpacing), y: leftActionButtonFrame.minY), size: CGSize(width: actionButtonDiameter, height: actionButtonDiameter)) + } else { + let additionalButtonSpacing = 12.0 + let totalLeftButtonHeight: CGFloat = leftActionButtonFrame.height + additionalButtonSpacing + additionalButtonDiameter + let totalLeftButtonOriginY: CGFloat = leftActionButtonFrame.minY + floor((leftActionButtonFrame.height - totalLeftButtonHeight) * 0.5) + leftActionButtonFrame.origin.y = totalLeftButtonOriginY + additionalButtonDiameter + additionalButtonSpacing + + additionalLeftActionButtonFrame = CGRect(origin: CGPoint(x: leftActionButtonFrame.minX + floor((leftActionButtonFrame.width - additionalButtonDiameter) * 0.5), y: leftActionButtonFrame.minY - additionalButtonSpacing - additionalButtonDiameter), size: CGSize(width: additionalButtonDiameter, height: additionalButtonDiameter)) + } + } let participantsSize = availableSize @@ -1316,6 +1554,12 @@ final class VideoChatScreenComponent: Component { return } self.openInviteMembers() + }, + visibleParticipantsUpdated: { [weak self] visibleParticipants in + guard let self else { + return + } + self.onVisibleParticipantsUpdated(ids: visibleParticipants) } )), environment: {}, @@ -1324,35 +1568,88 @@ final class VideoChatScreenComponent: Component { let participantsFrame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: participantsSize) if let participantsView = self.participants.view { if participantsView.superview == nil { + participantsView.layer.allowsGroupOpacity = true self.containerView.addSubview(participantsView) } transition.setFrame(view: participantsView, frame: participantsFrame) + var participantsAlpha: CGFloat = 1.0 + if let callState = self.callState, callState.scheduleTimestamp != nil { + participantsAlpha = 0.0 + } + alphaTransition.setAlpha(view: participantsView, alpha: participantsAlpha) + } + + if let callState = self.callState, let scheduleTimestamp = callState.scheduleTimestamp { + let scheduleInfo: ComponentView + var scheduleInfoTransition = transition + if let current = self.scheduleInfo { + scheduleInfo = current + } else { + scheduleInfoTransition = scheduleInfoTransition.withAnimation(.none) + scheduleInfo = ComponentView() + self.scheduleInfo = scheduleInfo + } + let scheduleInfoSize = scheduleInfo.update( + transition: scheduleInfoTransition, + component: AnyComponent(VideoChatScheduledInfoComponent( + timestamp: scheduleTimestamp, + strings: environment.strings + )), + environment: {}, + containerSize: participantsSize + ) + let scheduleInfoFrame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: scheduleInfoSize) + if let scheduleInfoView = scheduleInfo.view { + if scheduleInfoView.superview == nil { + scheduleInfoView.isUserInteractionEnabled = false + self.containerView.addSubview(scheduleInfoView) + } + scheduleInfoTransition.setFrame(view: scheduleInfoView, frame: scheduleInfoFrame) + } + } else if let scheduleInfo = self.scheduleInfo { + self.scheduleInfo = nil + if let scheduleInfoView = scheduleInfo.view { + alphaTransition.setAlpha(view: scheduleInfoView, alpha: 0.0, completion: { [weak scheduleInfoView] _ in + scheduleInfoView?.removeFromSuperview() + }) + } } 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 + if callState.scheduleTimestamp != nil { + let scheduledState: VideoChatMicButtonComponent.ScheduledState + if callState.canManageCall { + scheduledState = .start + } else { + scheduledState = .toggleSubscription(isSubscribed: callState.subscribedToScheduled) + } + micButtonContent = .scheduled(state: scheduledState) + actionButtonMicrophoneState = .scheduled + } else { + switch callState.networkState { + case .connecting: + micButtonContent = .connecting + actionButtonMicrophoneState = .connecting + case .connected: + if let muteState = callState.muteState { + if muteState.canUnmute { + if self.isPushToTalkActive { + micButtonContent = .unmuted(pushToTalk: self.isPushToTalkActive) + actionButtonMicrophoneState = .unmuted + } else { + micButtonContent = .muted + actionButtonMicrophoneState = .muted + } } else { - micButtonContent = .muted - actionButtonMicrophoneState = .muted + micButtonContent = .raiseHand(isRaised: callState.raisedHand) + actionButtonMicrophoneState = .raiseHand } } else { - micButtonContent = .raiseHand - actionButtonMicrophoneState = .raiseHand + micButtonContent = .unmuted(pushToTalk: false) + actionButtonMicrophoneState = .unmuted } - } else { - micButtonContent = .unmuted(pushToTalk: false) - actionButtonMicrophoneState = .unmuted } } } else { @@ -1412,6 +1709,23 @@ final class VideoChatScreenComponent: Component { if !callState.raisedHand { component.call.raiseHand() } + }, + scheduleAction: { [weak self] in + guard let self, let component = self.component else { + return + } + guard let callState = self.callState else { + return + } + guard callState.scheduleTimestamp != nil else { + return + } + + if callState.canManageCall { + component.call.startScheduled() + } else { + component.call.toggleScheduledSubscription(!callState.subscribedToScheduled) + } } )), environment: {}, @@ -1455,7 +1769,7 @@ final class VideoChatScreenComponent: Component { videoButtonContent = .audio(audio: buttonAudio) } else { //TODO:release - videoButtonContent = .video(isActive: false) + videoButtonContent = .video(isActive: self.callState?.hasVideo ?? false) } let _ = self.videoButton.update( transition: transition, @@ -1490,6 +1804,62 @@ final class VideoChatScreenComponent: Component { transition.setBounds(view: videoButtonView, bounds: CGRect(origin: CGPoint(), size: leftActionButtonFrame.size)) } + if let additionalLeftActionButtonFrame { + let switchVideoButton: ComponentView + var switchVideoButtonTransition = transition + if let current = self.switchVideoButton { + switchVideoButton = current + } else { + switchVideoButtonTransition = switchVideoButtonTransition.withAnimation(.none) + switchVideoButton = ComponentView() + self.switchVideoButton = switchVideoButton + } + + let switchVideoButtonContent: VideoChatActionButtonComponent.Content = .switchVideo + + let _ = switchVideoButton.update( + transition: switchVideoButtonTransition, + component: AnyComponent(PlainButtonComponent( + content: AnyComponent(VideoChatActionButtonComponent( + strings: environment.strings, + content: switchVideoButtonContent, + microphoneState: actionButtonMicrophoneState, + isCollapsed: areButtonsCollapsed + )), + effectAlignment: .center, + action: { [weak self] in + guard let self, let component = self.component else { + return + } + component.call.switchVideoCamera() + }, + animateAlpha: false + )), + environment: {}, + containerSize: additionalLeftActionButtonFrame.size + ) + if let switchVideoButtonView = switchVideoButton.view { + var animateIn = false + if switchVideoButtonView.superview == nil { + self.containerView.addSubview(switchVideoButtonView) + animateIn = true + } + switchVideoButtonTransition.setFrame(view: switchVideoButtonView, frame: additionalLeftActionButtonFrame) + if animateIn { + alphaTransition.animateAlpha(view: switchVideoButtonView, from: 0.0, to: 1.0) + transition.animateScale(view: switchVideoButtonView, from: 0.001, to: 1.0) + } + } + } else if let switchVideoButton = self.switchVideoButton { + self.switchVideoButton = nil + if let switchVideoButtonView = switchVideoButton.view { + alphaTransition.setAlpha(view: switchVideoButtonView, alpha: 0.0, completion: { [weak switchVideoButtonView] _ in + switchVideoButtonView?.removeFromSuperview() + }) + transition.setScale(view: switchVideoButtonView, scale: 0.001) + } + } + let _ = self.leaveButton.update( transition: transition, component: AnyComponent(PlainButtonComponent( @@ -1657,9 +2027,11 @@ final class VideoChatScreenV2Impl: ViewControllerComponentContainer, VoiceChatCo } self.isAnimatingDismiss = false self.superDismiss() + completion?() }) } else { self.superDismiss() + completion?() } } } diff --git a/submodules/TelegramCallsUI/Sources/VideoChatScreenParticipantContextMenu.swift b/submodules/TelegramCallsUI/Sources/VideoChatScreenParticipantContextMenu.swift index 6ee08d0a90..dd81e23a91 100644 --- a/submodules/TelegramCallsUI/Sources/VideoChatScreenParticipantContextMenu.swift +++ b/submodules/TelegramCallsUI/Sources/VideoChatScreenParticipantContextMenu.swift @@ -248,8 +248,8 @@ extension VideoChatScreenComponent.View { } let context = component.call.accountContext - environment.controller()?.dismiss(completion: { [weak navigationController] in - Queue.mainQueue().after(0.3) { + controller.dismiss(completion: { [weak navigationController] in + Queue.mainQueue().after(0.1) { guard let navigationController else { return } diff --git a/submodules/TelegramCallsUI/Sources/VideoChatTitleComponent.swift b/submodules/TelegramCallsUI/Sources/VideoChatTitleComponent.swift index 0f13e9d815..6b36289463 100644 --- a/submodules/TelegramCallsUI/Sources/VideoChatTitleComponent.swift +++ b/submodules/TelegramCallsUI/Sources/VideoChatTitleComponent.swift @@ -5,6 +5,7 @@ import ComponentFlow import MultilineTextComponent import TelegramPresentationData import HierarchyTrackingLayer +import ChatTitleActivityNode final class VideoChatTitleComponent: Component { let title: String @@ -43,12 +44,17 @@ final class VideoChatTitleComponent: Component { final class View: UIView { private let hierarchyTrackingLayer: HierarchyTrackingLayer private let title = ComponentView() - private var status: ComponentView? + private let status = ComponentView() private var recordingImageView: UIImageView? + + private var activityStatusNode: ChatTitleActivityNode? private var component: VideoChatTitleComponent? private var isUpdating: Bool = false + private var currentActivityStatus: String? + private var currentSize: CGSize? + override init(frame: CGRect) { self.hierarchyTrackingLayer = HierarchyTrackingLayer() @@ -81,6 +87,64 @@ final class VideoChatTitleComponent: Component { } } + func updateActivityStatus(value: String?, transition: ComponentTransition) { + if self.currentActivityStatus == value { + return + } + self.currentActivityStatus = value + + guard let currentSize = self.currentSize, let statusView = self.status.view else { + return + } + + let alphaTransition: ComponentTransition + if transition.animation.isImmediate { + alphaTransition = .immediate + } else { + alphaTransition = .easeInOut(duration: 0.2) + } + + if let value { + let activityStatusNode: ChatTitleActivityNode + if let current = self.activityStatusNode { + activityStatusNode = current + } else { + activityStatusNode = ChatTitleActivityNode() + self.activityStatusNode = activityStatusNode + } + + let _ = activityStatusNode.transitionToState(.recordingVoice(NSAttributedString(string: value, font: Font.regular(13.0), textColor: UIColor(rgb: 0x34c759)), UIColor(rgb: 0x34c759)), animation: .none) + let activityStatusSize = activityStatusNode.updateLayout(CGSize(width: currentSize.width, height: 100.0), alignment: .center) + let activityStatusFrame = CGRect(origin: CGPoint(x: floor((currentSize.width - activityStatusSize.width) * 0.5), y: statusView.center.y - activityStatusSize.height * 0.5), size: activityStatusSize) + + let activityStatusNodeView = activityStatusNode.view + activityStatusNodeView.center = activityStatusFrame.center + activityStatusNodeView.bounds = CGRect(origin: CGPoint(), size: activityStatusFrame.size) + if activityStatusNodeView.superview == nil { + self.addSubview(activityStatusNode.view) + ComponentTransition.immediate.setTransform(view: activityStatusNodeView, transform: CATransform3DMakeTranslation(0.0, -10.0, 0.0)) + activityStatusNodeView.alpha = 0.0 + } + transition.setTransform(view: activityStatusNodeView, transform: CATransform3DIdentity) + alphaTransition.setAlpha(view: activityStatusNodeView, alpha: 1.0) + + transition.setTransform(view: statusView, transform: CATransform3DMakeTranslation(0.0, 10.0, 0.0)) + alphaTransition.setAlpha(view: statusView, alpha: 0.0) + } else { + if let activityStatusNode = self.activityStatusNode { + self.activityStatusNode = nil + let activityStatusNodeView = activityStatusNode.view + transition.setTransform(view: activityStatusNodeView, transform: CATransform3DMakeTranslation(0.0, -10.0, 0.0)) + alphaTransition.setAlpha(view: activityStatusNodeView, alpha: 0.0, completion: { [weak activityStatusNodeView] _ in + activityStatusNodeView?.removeFromSuperview() + }) + } + + transition.setTransform(view: statusView, transform: CATransform3DIdentity) + alphaTransition.setAlpha(view: statusView, alpha: 1.0) + } + } + func update(component: VideoChatTitleComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { self.isUpdating = true defer { @@ -100,19 +164,12 @@ final class VideoChatTitleComponent: Component { 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( + let statusSize = self.status.update( transition: .immediate, component: statusComponent, environment: {}, @@ -131,7 +188,7 @@ final class VideoChatTitleComponent: Component { } 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 let statusView = self.status.view { if statusView.superview == nil { self.addSubview(statusView) } @@ -165,6 +222,8 @@ final class VideoChatTitleComponent: Component { } } + self.currentSize = size + return size } } diff --git a/submodules/TelegramCallsUI/Sources/VoiceChatController.swift b/submodules/TelegramCallsUI/Sources/VoiceChatController.swift index 533e8b89ca..db52366c58 100644 --- a/submodules/TelegramCallsUI/Sources/VoiceChatController.swift +++ b/submodules/TelegramCallsUI/Sources/VoiceChatController.swift @@ -7097,7 +7097,7 @@ final class VoiceChatContextReferenceContentSource: ContextReferenceContentSourc } } -private func calculateUseV2(context: AccountContext) -> Bool { +public func shouldUseV2VideoChatImpl(context: AccountContext) -> Bool { var useV2 = true if context.sharedContext.immediateExperimentalUISettings.disableCallV2 { useV2 = false @@ -7109,7 +7109,7 @@ private func calculateUseV2(context: AccountContext) -> Bool { } public func makeVoiceChatControllerInitialData(sharedContext: SharedAccountContext, accountContext: AccountContext, call: PresentationGroupCall) -> Signal { - let useV2 = calculateUseV2(context: accountContext) + let useV2 = shouldUseV2VideoChatImpl(context: accountContext) if useV2 { return VideoChatScreenV2Impl.initialData(call: call) |> map { $0 as Any } @@ -7119,7 +7119,7 @@ public func makeVoiceChatControllerInitialData(sharedContext: SharedAccountConte } public func makeVoiceChatController(sharedContext: SharedAccountContext, accountContext: AccountContext, call: PresentationGroupCall, initialData: Any) -> VoiceChatController { - let useV2 = calculateUseV2(context: accountContext) + let useV2 = shouldUseV2VideoChatImpl(context: accountContext) if useV2 { return VideoChatScreenV2Impl(initialData: initialData as! VideoChatScreenV2Impl.InitialData, call: call) diff --git a/submodules/TelegramCore/Sources/Network/FetchedMediaResource.swift b/submodules/TelegramCore/Sources/Network/FetchedMediaResource.swift index 113edc7caf..af95079f06 100644 --- a/submodules/TelegramCore/Sources/Network/FetchedMediaResource.swift +++ b/submodules/TelegramCore/Sources/Network/FetchedMediaResource.swift @@ -184,6 +184,12 @@ private func findMediaResource(media: Media, previousMedia: Media?, resource: Me return representation.resource } } + + for alternativeRepresentation in file.alternativeRepresentations { + if let result = findMediaResource(media: alternativeRepresentation, previousMedia: previousMedia, resource: resource) { + return result + } + } } } else if let webPage = media as? TelegramMediaWebpage, case let .Loaded(content) = webPage.content { if let image = content.image, let result = findMediaResource(media: image, previousMedia: previousMedia, resource: resource) { @@ -254,6 +260,12 @@ func findMediaResourceById(media: Media, resourceId: MediaResourceId) -> Telegra return representation.resource } } + + for alternativeRepresentation in file.alternativeRepresentations { + if let result = findMediaResourceById(media: alternativeRepresentation, resourceId: resourceId) { + return result + } + } } else if let webPage = media as? TelegramMediaWebpage, case let .Loaded(content) = webPage.content { if let image = content.image, let result = findMediaResourceById(media: image, resourceId: resourceId) { return result diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Messages/ReportContent.swift b/submodules/TelegramCore/Sources/TelegramEngine/Messages/ReportContent.swift new file mode 100644 index 0000000000..b79ed7c30c --- /dev/null +++ b/submodules/TelegramCore/Sources/TelegramEngine/Messages/ReportContent.swift @@ -0,0 +1,82 @@ +import Foundation +import Postbox +import SwiftSignalKit +import TelegramApi +import MtProtoKit + +public enum ReportContentResult { + public struct Option: Equatable { + public let text: String + public let option: Data + } + + case options(title: String, options: [Option]) + case addComment(optional: Bool, option: Data) + case reported +} + +public enum ReportContentError { + case generic + case messageIdRequired +} + +public enum ReportContentSubject: Equatable { + case peer(EnginePeer.Id) + case messages([EngineMessage.Id]) + case stories(EnginePeer.Id, [Int32]) + + var peerId: EnginePeer.Id { + switch self { + case let .peer(peerId): + return peerId + case let .messages(messageIds): + return messageIds.first!.peerId + case let .stories(peerId, _): + return peerId + } + } +} + +func _internal_reportContent(account: Account, subject: ReportContentSubject, option: Data?, message: String?) -> Signal { + return account.postbox.transaction { transaction -> Signal in + guard let peer = transaction.getPeer(subject.peerId), let inputPeer = apiInputPeer(peer) else { + return .fail(.generic) + } + + let request: Signal + if case let .stories(_, ids) = subject { + request = account.network.request(Api.functions.stories.report(peer: inputPeer, id: ids, option: Buffer(data: option), message: message ?? "")) + } else { + var ids: [Int32] = [] + if case let .messages(messageIds) = subject { + ids = messageIds.map { $0.id } + } + request = account.network.request(Api.functions.messages.report(peer: inputPeer, id: ids, option: Buffer(data: option), message: message ?? "")) + } + + return request + |> mapError { error -> ReportContentError in + if error.errorDescription == "MESSAGE_ID_REQUIRED" { + return .messageIdRequired + } + return .generic + } + |> map { result -> ReportContentResult in + switch result { + case let .reportResultChooseOption(title, options): + return .options(title: title, options: options.map { + switch $0 { + case let .messageReportOption(text, option): + return ReportContentResult.Option(text: text, option: option.makeData()) + } + }) + case let .reportResultAddComment(flags, option): + return .addComment(optional: (flags & (1 << 0)) != 0, option: option.makeData()) + case .reportResultReported: + return .reported + } + } + } + |> castError(ReportContentError.self) + |> switchToLatest +} diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift b/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift index 22de0f6824..5933679e31 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift @@ -1463,6 +1463,10 @@ public extension TelegramEngine { return _internal_reportAdMessage(account: self.account, peerId: peerId, opaqueId: opaqueId, option: option) } + public func reportContent(subject: ReportContentSubject, option: Data?, message: String?) -> Signal { + return _internal_reportContent(account: self.account, subject: subject, option: option, message: message) + } + public func updateExtendedMedia(messageIds: [EngineMessage.Id]) -> Signal { return _internal_updateExtendedMedia(account: self.account, messageIds: messageIds) } diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Payments/StarGifts.swift b/submodules/TelegramCore/Sources/TelegramEngine/Payments/StarGifts.swift index 2ebfdbddb5..b297f7ba13 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Payments/StarGifts.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Payments/StarGifts.swift @@ -238,6 +238,7 @@ private final class ProfileGiftsContextImpl { private let peerId: PeerId private let disposable = MetaDisposable() + private let actionDisposable = MetaDisposable() private var gifts: [ProfileGiftsContext.State.StarGift] = [] private var count: Int32? @@ -258,6 +259,7 @@ private final class ProfileGiftsContextImpl { deinit { self.disposable.dispose() + self.actionDisposable.dispose() } func loadMore() { @@ -315,6 +317,27 @@ private final class ProfileGiftsContextImpl { } } + func updateStarGiftAddedToProfile(messageId: EngineMessage.Id, added: Bool) { + self.actionDisposable.set( + _internal_updateStarGiftAddedToProfile(account: self.account, messageId: messageId, added: added).startStrict() + ) + if let index = self.gifts.firstIndex(where: { $0.messageId == messageId }) { + self.gifts[index] = self.gifts[index].withSavedToProfile(added) + } + self.pushState() + } + + func convertStarGift(messageId: EngineMessage.Id) { + self.actionDisposable.set( + _internal_convertStarGift(account: self.account, messageId: messageId).startStrict() + ) + if let count = self.count { + self.count = max(0, count - 1) + } + self.gifts.removeAll(where: { $0.messageId == messageId }) + self.pushState() + } + private func pushState() { self.stateValue.set(.single(ProfileGiftsContext.State(gifts: self.gifts, count: self.count, dataState: self.dataState))) } @@ -332,6 +355,20 @@ public final class ProfileGiftsContext { public let nameHidden: Bool public let savedToProfile: Bool public let convertStars: Int64? + + public func withSavedToProfile(_ savedToProfile: Bool) -> StarGift { + return StarGift( + gift: self.gift, + fromPeer: self.fromPeer, + date: self.date, + text: self.text, + entities: self.entities, + messageId: self.messageId, + nameHidden: self.nameHidden, + savedToProfile: savedToProfile, + convertStars: self.convertStars + ) + } } public enum DataState: Equatable { @@ -373,6 +410,18 @@ public final class ProfileGiftsContext { impl.loadMore() } } + + public func updateStarGiftAddedToProfile(messageId: EngineMessage.Id, added: Bool) { + self.impl.with { impl in + impl.updateStarGiftAddedToProfile(messageId: messageId, added: added) + } + } + + public func convertStarGift(messageId: EngineMessage.Id) { + self.impl.with { impl in + impl.convertStarGift(messageId: messageId) + } + } } private extension ProfileGiftsContext.State.StarGift { diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Payments/Stars.swift b/submodules/TelegramCore/Sources/TelegramEngine/Payments/Stars.swift index f3b738d87a..6ae977572f 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Payments/Stars.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Payments/Stars.swift @@ -468,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, giveawayMessageId: 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, starGift: 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)) @@ -490,7 +490,7 @@ 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, giveawayPostId): + case let .starsTransaction(apiFlags, id, stars, date, transactionPeer, title, description, photo, transactionDate, transactionUrl, _, messageId, extendedMedia, subscriptionPeriod, giveawayPostId, starGift): let parsedPeer: StarsContext.State.Transaction.Peer var paidMessageId: MessageId? var giveawayMessageId: MessageId? @@ -544,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, giveawayMessageId: giveawayMessageId, 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, starGift: starGift.flatMap { StarGift(apiStarGift: $0) }) } } } @@ -613,6 +613,7 @@ public final class StarsContext { public let giveawayMessageId: MessageId? public let media: [Media] public let subscriptionPeriod: Int32? + public let starGift: StarGift? public init( flags: Flags, @@ -628,7 +629,8 @@ public final class StarsContext { paidMessageId: MessageId?, giveawayMessageId: MessageId?, media: [Media], - subscriptionPeriod: Int32? + subscriptionPeriod: Int32?, + starGift: StarGift? ) { self.flags = flags self.id = id @@ -644,6 +646,7 @@ public final class StarsContext { self.giveawayMessageId = giveawayMessageId self.media = media self.subscriptionPeriod = subscriptionPeriod + self.starGift = starGift } public static func == (lhs: Transaction, rhs: Transaction) -> Bool { @@ -689,6 +692,9 @@ public final class StarsContext { if lhs.subscriptionPeriod != rhs.subscriptionPeriod { return false } + if lhs.starGift != rhs.starGift { + return false + } return true } } diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Peers/ReportPeer.swift b/submodules/TelegramCore/Sources/TelegramEngine/Peers/ReportPeer.swift index 04ffbec511..5d84874fbd 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Peers/ReportPeer.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Peers/ReportPeer.swift @@ -148,42 +148,44 @@ func _internal_reportPeerPhoto(account: Account, peerId: PeerId, reason: ReportR } func _internal_reportPeerMessages(account: Account, messageIds: [MessageId], reason: ReportReason, message: String) -> Signal { - return account.postbox.transaction { transaction -> Signal in - let groupedIds = messagesIdsGroupedByPeerId(messageIds) - let signals = groupedIds.values.compactMap { ids -> Signal? in - guard let peerId = ids.first?.peerId, let peer = transaction.getPeer(peerId), let inputPeer = apiInputPeer(peer) else { - return nil - } - return account.network.request(Api.functions.messages.report(peer: inputPeer, id: ids.map { $0.id }, reason: reason.apiReason, message: message)) - |> `catch` { _ -> Signal in - return .single(.boolFalse) - } - |> mapToSignal { _ -> Signal in - return .complete() - } - } - - return combineLatest(signals) - |> mapToSignal { _ -> Signal in - return .complete() - } - } |> switchToLatest + return .complete() +// return account.postbox.transaction { transaction -> Signal in +// let groupedIds = messagesIdsGroupedByPeerId(messageIds) +// let signals = groupedIds.values.compactMap { ids -> Signal? in +// guard let peerId = ids.first?.peerId, let peer = transaction.getPeer(peerId), let inputPeer = apiInputPeer(peer) else { +// return nil +// } +// return account.network.request(Api.functions.messages.report(peer: inputPeer, id: ids.map { $0.id }, reason: reason.apiReason, message: message)) +// |> `catch` { _ -> Signal in +// return .single(.boolFalse) +// } +// |> mapToSignal { _ -> Signal in +// return .complete() +// } +// } +// +// return combineLatest(signals) +// |> mapToSignal { _ -> Signal in +// return .complete() +// } +// } |> switchToLatest } func _internal_reportPeerStory(account: Account, peerId: PeerId, storyId: Int32, reason: ReportReason, message: String) -> Signal { - return account.postbox.transaction { transaction -> Signal in - if let peer = transaction.getPeer(peerId), let inputPeer = apiInputPeer(peer) { - return account.network.request(Api.functions.stories.report(peer: inputPeer, id: [storyId], reason: reason.apiReason, message: message)) - |> `catch` { _ -> Signal in - return .single(.boolFalse) - } - |> mapToSignal { _ -> Signal in - return .complete() - } - } else { - return .complete() - } - } |> switchToLatest + return .complete() +// return account.postbox.transaction { transaction -> Signal in +// if let peer = transaction.getPeer(peerId), let inputPeer = apiInputPeer(peer) { +// return account.network.request(Api.functions.stories.report(peer: inputPeer, id: [storyId], reason: reason.apiReason, message: message)) +// |> `catch` { _ -> Signal in +// return .single(.boolFalse) +// } +// |> mapToSignal { _ -> Signal in +// return .complete() +// } +// } else { +// return .complete() +// } +// } |> switchToLatest } func _internal_reportPeerReaction(account: Account, authorId: PeerId, messageId: MessageId) -> Signal { diff --git a/submodules/TelegramUI/BUILD b/submodules/TelegramUI/BUILD index 328418a38e..ff1d488ded 100644 --- a/submodules/TelegramUI/BUILD +++ b/submodules/TelegramUI/BUILD @@ -459,7 +459,9 @@ swift_library( "//submodules/TelegramUI/Components/MinimizedContainer", "//submodules/TelegramUI/Components/SpaceWarpView", "//submodules/TelegramUI/Components/MiniAppListScreen", + "//submodules/TelegramUI/Components/Stars/StarsIntroScreen", "//submodules/TelegramUI/Components/Gifts/GiftOptionsScreen", + "//submodules/TelegramUI/Components/ContentReportScreen", ] + select({ "@build_bazel_rules_apple//apple:ios_arm64": appcenter_targets, "//build-system:ios_sim_arm64": [], diff --git a/submodules/TelegramUI/Components/Ads/AdsReportScreen/Sources/AdsReportScreen.swift b/submodules/TelegramUI/Components/Ads/AdsReportScreen/Sources/AdsReportScreen.swift index 86ee3fe125..b3574cd921 100644 --- a/submodules/TelegramUI/Components/Ads/AdsReportScreen/Sources/AdsReportScreen.swift +++ b/submodules/TelegramUI/Components/Ads/AdsReportScreen/Sources/AdsReportScreen.swift @@ -396,6 +396,7 @@ private final class SheetContent: CombinedComponent { let navigation = navigation.update( component: NavigationStackComponent( items: items, + clipContent: false, requestPop: { [weak state] in state?.pushedOptions.removeLast() update(.spring(duration: 0.45)) diff --git a/submodules/TelegramUI/Components/Calls/CallScreen/Sources/PrivateCallScreen.swift b/submodules/TelegramUI/Components/Calls/CallScreen/Sources/PrivateCallScreen.swift index 28fcc277ad..9474675578 100644 --- a/submodules/TelegramUI/Components/Calls/CallScreen/Sources/PrivateCallScreen.swift +++ b/submodules/TelegramUI/Components/Calls/CallScreen/Sources/PrivateCallScreen.swift @@ -324,13 +324,15 @@ public final class PrivateCallScreen: OverlayMaskContainerView, AVPictureInPictu self.closeAction?() } - if #available(iOS 16.0, *) { - let pipVideoCallViewController = AVPictureInPictureVideoCallViewController() - pipVideoCallViewController.view.addSubview(self.pipView) - self.pipView.frame = pipVideoCallViewController.view.bounds - self.pipView.autoresizingMask = [.flexibleWidth, .flexibleHeight] - self.pipView.translatesAutoresizingMaskIntoConstraints = true - self.pipVideoCallViewController = pipVideoCallViewController + if !"".isEmpty { + if #available(iOS 16.0, *) { + let pipVideoCallViewController = AVPictureInPictureVideoCallViewController() + pipVideoCallViewController.view.addSubview(self.pipView) + self.pipView.frame = pipVideoCallViewController.view.bounds + self.pipView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + self.pipView.translatesAutoresizingMaskIntoConstraints = true + self.pipVideoCallViewController = pipVideoCallViewController + } } if let blurFilter = makeBlurFilter() { diff --git a/submodules/TelegramUI/Components/Chat/ChatBotInfoItem/Sources/ChatBotInfoItem.swift b/submodules/TelegramUI/Components/Chat/ChatBotInfoItem/Sources/ChatBotInfoItem.swift index 8ee63a28ac..2bff980564 100644 --- a/submodules/TelegramUI/Components/Chat/ChatBotInfoItem/Sources/ChatBotInfoItem.swift +++ b/submodules/TelegramUI/Components/Chat/ChatBotInfoItem/Sources/ChatBotInfoItem.swift @@ -151,7 +151,7 @@ public final class ChatBotInfoItemNode: ListViewItemNode { continuePlayingWithoutSoundOnLostAudioSession: false, storeAfterDownload: nil ) - let videoNode = UniversalVideoNode(postbox: context.account.postbox, audioSession: context.sharedContext.mediaManager.audioSession, manager: context.sharedContext.mediaManager.universalVideoManager, decoration: VideoDecoration(), content: videoContent, priority: .embedded) + let videoNode = UniversalVideoNode(accountId: context.account.id, postbox: context.account.postbox, audioSession: context.sharedContext.mediaManager.audioSession, manager: context.sharedContext.mediaManager.universalVideoManager, decoration: VideoDecoration(), content: videoContent, priority: .embedded) videoNode.canAttachContent = true self.videoNode = videoNode diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageActionBubbleContentNode/Sources/ChatMessageActionBubbleContentNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageActionBubbleContentNode/Sources/ChatMessageActionBubbleContentNode.swift index cebcfdff62..7b56c5b951 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageActionBubbleContentNode/Sources/ChatMessageActionBubbleContentNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageActionBubbleContentNode/Sources/ChatMessageActionBubbleContentNode.swift @@ -276,7 +276,7 @@ public class ChatMessageActionBubbleContentNode: ChatMessageBubbleContentNode { let videoContent = NativeVideoContent(id: .profileVideo(id, "action"), userLocation: .peer(item.message.id.peerId), fileReference: videoFileReference, streamVideo: isMediaStreamable(resource: video.resource) ? .conservative : .none, loopVideo: true, enableSound: false, fetchAutomatically: true, onlyFullSizeThumbnail: false, useLargeThumbnail: true, autoFetchFullSizeThumbnail: true, continuePlayingWithoutSoundOnLostAudioSession: false, placeholderColor: .clear, storeAfterDownload: nil) if videoContent.id != strongSelf.videoContent?.id { let mediaManager = item.context.sharedContext.mediaManager - let videoNode = UniversalVideoNode(postbox: item.context.account.postbox, audioSession: mediaManager.audioSession, manager: mediaManager.universalVideoManager, decoration: GalleryVideoDecoration(), content: videoContent, priority: .secondaryOverlay) + let videoNode = UniversalVideoNode(accountId: item.context.account.id, postbox: item.context.account.postbox, audioSession: mediaManager.audioSession, manager: mediaManager.universalVideoManager, decoration: GalleryVideoDecoration(), content: videoContent, priority: .secondaryOverlay) videoNode.isUserInteractionEnabled = false videoNode.ownsContentNodeUpdated = { [weak self] owns in if let strongSelf = self { diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageGiftBubbleContentNode/Sources/ChatMessageGiftBubbleContentNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageGiftBubbleContentNode/Sources/ChatMessageGiftBubbleContentNode.swift index 576330bd64..6f86b3e5d8 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageGiftBubbleContentNode/Sources/ChatMessageGiftBubbleContentNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageGiftBubbleContentNode/Sources/ChatMessageGiftBubbleContentNode.swift @@ -31,13 +31,16 @@ public class ChatMessageGiftBubbleContentNode: ChatMessageBubbleContentNode { private let backgroundMaskNode: ASImageNode private var linkHighlightingNode: LinkHighlightingNode? + private let mediaBackgroundMaskNode: ASImageNode private var mediaBackgroundContent: WallpaperBubbleBackgroundNode? - private let mediaBackgroundNode: NavigationBackgroundNode private let titleNode: TextNode private let subtitleNode: TextNode private let placeholderNode: StickerShimmerEffectNode private let animationNode: AnimatedStickerNode + private let ribbonBackgroundNode: ASImageNode + private let ribbonTextNode: TextNode + private var shimmerEffectNode: ShimmerEffectForegroundNode? private let buttonNode: HighlightTrackingButtonNode private let buttonStarsNode: PremiumStarsNode @@ -79,9 +82,7 @@ public class ChatMessageGiftBubbleContentNode: ChatMessageBubbleContentNode { self.backgroundMaskNode = ASImageNode() - self.mediaBackgroundNode = NavigationBackgroundNode(color: .clear) - self.mediaBackgroundNode.clipsToBounds = true - self.mediaBackgroundNode.cornerRadius = 24.0 + self.mediaBackgroundMaskNode = ASImageNode() self.titleNode = TextNode() self.titleNode.isUserInteractionEnabled = false @@ -107,19 +108,30 @@ public class ChatMessageGiftBubbleContentNode: ChatMessageBubbleContentNode { self.buttonTitleNode.isUserInteractionEnabled = false self.buttonTitleNode.displaysAsynchronously = false + self.ribbonBackgroundNode = ASImageNode() + self.ribbonBackgroundNode.displaysAsynchronously = false + + self.ribbonTextNode = TextNode() + self.ribbonTextNode.isUserInteractionEnabled = false + self.ribbonTextNode.displaysAsynchronously = false + super.init() self.addSubnode(self.labelNode) - self.addSubnode(self.mediaBackgroundNode) self.addSubnode(self.titleNode) self.addSubnode(self.subtitleNode) + self.addSubnode(self.subtitleNode) + self.addSubnode(self.placeholderNode) self.addSubnode(self.animationNode) self.addSubnode(self.buttonNode) self.buttonNode.addSubnode(self.buttonStarsNode) self.addSubnode(self.buttonTitleNode) + self.addSubnode(self.ribbonBackgroundNode) + self.addSubnode(self.ribbonTextNode) + self.buttonNode.highligthedChanged = { [weak self] highlighted in if let strongSelf = self { if highlighted { @@ -226,7 +238,8 @@ public class ChatMessageGiftBubbleContentNode: ChatMessageBubbleContentNode { let makeTitleLayout = TextNode.asyncLayout(self.titleNode) let makeSubtitleLayout = TextNode.asyncLayout(self.subtitleNode) let makeButtonTitleLayout = TextNode.asyncLayout(self.buttonTitleNode) - + let makeRibbonTextLayout = TextNode.asyncLayout(self.ribbonTextNode) + let cachedMaskBackgroundImage = self.cachedMaskBackgroundImage return { item, layoutConstants, _, _, _, _ in @@ -247,6 +260,7 @@ public class ChatMessageGiftBubbleContentNode: ChatMessageBubbleContentNode { var title = item.presentationData.strings.Notification_PremiumGift_Title var text = "" var buttonTitle = item.presentationData.strings.Notification_PremiumGift_View + var ribbonTitle = "" var hasServiceMessage = true var textSpacing: CGFloat = 0.0 for media in item.message.media { @@ -315,8 +329,9 @@ public class ChatMessageGiftBubbleContentNode: ChatMessageBubbleContentNode { buttonTitle = item.presentationData.strings.Notification_PremiumPrize_View hasServiceMessage = false } - case let .starGift(gift, convertStars, giftText, entities, nameHidden, savedToProfile, converted)://(amount, giftId, nameHidden, limitNumber, limitTotal, giftText, _): + case let .starGift(gift, convertStars, giftText, entities, nameHidden, savedToProfile, converted): let _ = nameHidden + //TODO:localize let authorName = item.message.author.flatMap { EnginePeer($0) }?.compactDisplayTitle ?? "" title = "Gift from \(authorName)" if let giftText, !giftText.isEmpty { @@ -344,6 +359,9 @@ public class ChatMessageGiftBubbleContentNode: ChatMessageBubbleContentNode { } } animationFile = gift.file + if let availability = gift.availability { + ribbonTitle = "1 of \(availability.total)" + } default: break } @@ -377,6 +395,8 @@ public class ChatMessageGiftBubbleContentNode: ChatMessageBubbleContentNode { let (subtitleLayout, subtitleApply) = makeSubtitleLayout(TextNodeLayoutArguments(attributedString: attributedText, backgroundColor: nil, maximumNumberOfLines: 0, truncationType: .end, constrainedSize: CGSize(width: giftSize.width - 32.0, height: CGFloat.greatestFiniteMagnitude), alignment: .center, cutout: nil, insets: UIEdgeInsets())) let (buttonTitleLayout, buttonTitleApply) = makeButtonTitleLayout(TextNodeLayoutArguments(attributedString: NSAttributedString(string: buttonTitle, font: Font.semibold(15.0), textColor: primaryTextColor, paragraphAlignment: .center), backgroundColor: nil, maximumNumberOfLines: 0, truncationType: .end, constrainedSize: CGSize(width: giftSize.width - 32.0, height: CGFloat.greatestFiniteMagnitude), alignment: .center, cutout: nil, insets: UIEdgeInsets())) + + let (ribbonTextLayout, ribbonTextApply) = makeRibbonTextLayout(TextNodeLayoutArguments(attributedString: NSAttributedString(string: ribbonTitle, font: Font.semibold(11.0), textColor: primaryTextColor, paragraphAlignment: .center), backgroundColor: nil, maximumNumberOfLines: 0, truncationType: .end, constrainedSize: CGSize(width: giftSize.width - 32.0, height: CGFloat.greatestFiniteMagnitude), alignment: .center, cutout: nil, insets: UIEdgeInsets())) giftSize.height = titleLayout.size.height + textSpacing + subtitleLayout.size.height + 212.0 @@ -424,31 +444,10 @@ public class ChatMessageGiftBubbleContentNode: ChatMessageBubbleContentNode { return (backgroundSize.width, { boundingWidth in return (backgroundSize, { [weak self] animation, synchronousLoads, _ in if let strongSelf = self { - if strongSelf.item == nil { - strongSelf.animationNode.autoplay = true - - if let file = animationFile { - strongSelf.animationNode.setup(source: AnimatedStickerResourceSource(account: item.context.account, resource: file.resource), width: 384, height: 384, playbackMode: .once, mode: .direct(cachePathPrefix: nil)) - if strongSelf.fetchDisposable == nil { - strongSelf.fetchDisposable = freeMediaFileResourceInteractiveFetched(postbox: item.context.account.postbox, userLocation: .other, fileReference: .message(message: MessageReference(item.message), media: file), resource: file.resource).start() - } - } else if animationName.hasPrefix("Gift") { - strongSelf.animationNode.setup(source: AnimatedStickerNodeLocalFileSource(name: animationName), width: 384, height: 384, playbackMode: .still(.end), mode: .direct(cachePathPrefix: nil)) - } - } - strongSelf.item = item - - strongSelf.updateVisibility() + let overlayColor = item.presentationData.theme.theme.overallDarkAppearance ? UIColor(rgb: 0xffffff, alpha: 0.12) : UIColor(rgb: 0x000000, alpha: 0.12) - strongSelf.labelNode.isHidden = !hasServiceMessage - let imageFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((backgroundSize.width - giftSize.width) / 2.0), y: hasServiceMessage ? labelLayout.size.height + 16.0 : 0.0), size: giftSize) let mediaBackgroundFrame = imageFrame.insetBy(dx: -2.0, dy: -2.0) - strongSelf.mediaBackgroundNode.frame = mediaBackgroundFrame - - strongSelf.mediaBackgroundNode.updateColor(color: selectDateFillStaticColor(theme: item.presentationData.theme.theme, wallpaper: item.presentationData.theme.wallpaper), enableBlur: item.controllerInteraction.enableFullTranslucency && dateFillNeedsBlur(theme: item.presentationData.theme.theme, wallpaper: item.presentationData.theme.wallpaper), transition: .immediate) - strongSelf.mediaBackgroundNode.update(size: mediaBackgroundFrame.size, transition: .immediate) - strongSelf.buttonNode.backgroundColor = item.presentationData.theme.theme.overallDarkAppearance ? UIColor(rgb: 0xffffff, alpha: 0.12) : UIColor(rgb: 0x000000, alpha: 0.12) var iconSize = CGSize(width: 160.0, height: 160.0) var iconOffset: CGFloat = 0.0 @@ -456,13 +455,56 @@ public class ChatMessageGiftBubbleContentNode: ChatMessageBubbleContentNode { iconSize = CGSize(width: 120.0, height: 120.0) iconOffset = 32.0 } - strongSelf.animationNode.frame = CGRect(origin: CGPoint(x: mediaBackgroundFrame.minX + floorToScreenPixels((mediaBackgroundFrame.width - iconSize.width) / 2.0), y: mediaBackgroundFrame.minY - 16.0 + iconOffset), size: iconSize) + let animationFrame = CGRect(origin: CGPoint(x: mediaBackgroundFrame.minX + floorToScreenPixels((mediaBackgroundFrame.width - iconSize.width) / 2.0), y: mediaBackgroundFrame.minY - 16.0 + iconOffset), size: iconSize) + strongSelf.animationNode.frame = animationFrame + + if strongSelf.item == nil { + strongSelf.animationNode.started = { [weak self] in + if let strongSelf = self { + let current = CACurrentMediaTime() + if let setupTimestamp = strongSelf.setupTimestamp, current - setupTimestamp > 0.3 { + if !strongSelf.placeholderNode.alpha.isZero { + strongSelf.animationNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) + strongSelf.removePlaceholder(animated: true) + } + } else { + strongSelf.removePlaceholder(animated: false) + } + } + } + + strongSelf.animationNode.autoplay = true + + if let file = animationFile { + strongSelf.animationNode.setup(source: AnimatedStickerResourceSource(account: item.context.account, resource: file.resource), width: 384, height: 384, playbackMode: .once, mode: .direct(cachePathPrefix: nil)) + if strongSelf.fetchDisposable == nil { + strongSelf.fetchDisposable = freeMediaFileResourceInteractiveFetched(postbox: item.context.account.postbox, userLocation: .other, fileReference: .message(message: MessageReference(item.message), media: file), resource: file.resource).start() + } + + if let immediateThumbnailData = file.immediateThumbnailData { + let shimmeringColor = bubbleVariableColor(variableColor: item.presentationData.theme.theme.chat.message.stickerPlaceholderShimmerColor, wallpaper: item.presentationData.theme.wallpaper) + strongSelf.placeholderNode.update(backgroundColor: nil, foregroundColor: overlayColor, shimmeringColor: shimmeringColor, data: immediateThumbnailData, size: animationFrame.size, enableEffect: item.context.sharedContext.energyUsageSettings.fullTranslucency) + } + } else if animationName.hasPrefix("Gift") { + strongSelf.animationNode.setup(source: AnimatedStickerNodeLocalFileSource(name: animationName), width: 384, height: 384, playbackMode: .still(.end), mode: .direct(cachePathPrefix: nil)) + } + } + strongSelf.item = item + + strongSelf.updateVisibility() + + strongSelf.labelNode.isHidden = !hasServiceMessage + + strongSelf.buttonNode.backgroundColor = overlayColor + strongSelf.animationNode.updateLayout(size: iconSize) + strongSelf.placeholderNode.frame = animationFrame let _ = labelApply() let _ = titleApply() let _ = subtitleApply() let _ = buttonTitleApply() + let _ = ribbonTextApply() let labelFrame = CGRect(origin: CGPoint(x: 8.0, y: 2.0), size: labelLayout.size) strongSelf.labelNode.frame = labelFrame @@ -479,23 +521,58 @@ public class ChatMessageGiftBubbleContentNode: ChatMessageBubbleContentNode { let buttonSize = CGSize(width: buttonTitleLayout.size.width + 38.0, height: 34.0) strongSelf.buttonNode.frame = CGRect(origin: CGPoint(x: mediaBackgroundFrame.minX + floorToScreenPixels((mediaBackgroundFrame.width - buttonSize.width) / 2.0), y: subtitleFrame.maxY + 10.0), size: buttonSize) strongSelf.buttonStarsNode.frame = CGRect(origin: .zero, size: buttonSize) - - if item.controllerInteraction.presentationContext.backgroundNode?.hasExtraBubbleBackground() == true { - if strongSelf.mediaBackgroundContent == nil, let backgroundContent = item.controllerInteraction.presentationContext.backgroundNode?.makeBubbleBackground(for: .free) { - strongSelf.mediaBackgroundNode.isHidden = true - backgroundContent.clipsToBounds = true - backgroundContent.allowsGroupOpacity = true - backgroundContent.cornerRadius = 24.0 - - strongSelf.mediaBackgroundContent = backgroundContent - strongSelf.insertSubnode(backgroundContent, at: 0) + + if ribbonTextLayout.size.width > 0.0 { + if strongSelf.ribbonBackgroundNode.image == nil { + let ribbonImage = generateTintedImage(image: UIImage(bundleImageName: "Chat/Message/GiftRibbon"), color: overlayColor) + strongSelf.ribbonBackgroundNode.image = ribbonImage } + if let ribbonImage = strongSelf.ribbonBackgroundNode.image { + let ribbonFrame = CGRect(origin: CGPoint(x: mediaBackgroundFrame.maxX - ribbonImage.size.width + 2.0, y: mediaBackgroundFrame.minY - 2.0), size: ribbonImage.size) + strongSelf.ribbonBackgroundNode.frame = ribbonFrame + + strongSelf.ribbonTextNode.transform = CATransform3DMakeRotation(.pi / 4.0, 0.0, 0.0, 1.0) + strongSelf.ribbonTextNode.bounds = CGRect(origin: .zero, size: ribbonTextLayout.size) + strongSelf.ribbonTextNode.position = ribbonFrame.center.offsetBy(dx: 7.0, dy: -6.0) + } + } + + if strongSelf.mediaBackgroundContent == nil, let backgroundContent = item.controllerInteraction.presentationContext.backgroundNode?.makeBubbleBackground(for: .free) { + backgroundContent.clipsToBounds = true + backgroundContent.cornerRadius = 24.0 - strongSelf.mediaBackgroundContent?.frame = mediaBackgroundFrame - } else { - strongSelf.mediaBackgroundNode.isHidden = false - strongSelf.mediaBackgroundContent?.removeFromSupernode() - strongSelf.mediaBackgroundContent = nil + strongSelf.mediaBackgroundContent = backgroundContent + strongSelf.insertSubnode(backgroundContent, at: 0) + } + + if let backgroundContent = strongSelf.mediaBackgroundContent { + if ribbonTextLayout.size.width > 0.0 { + let backgroundMaskFrame = mediaBackgroundFrame.insetBy(dx: -2.0, dy: -2.0) + backgroundContent.frame = backgroundMaskFrame + backgroundContent.cornerRadius = 0.0 + + if strongSelf.mediaBackgroundMaskNode.image?.size != mediaBackgroundFrame.size { + strongSelf.mediaBackgroundMaskNode.image = generateImage(backgroundMaskFrame.size, contextGenerator: { size, context in + let bounds = CGRect(origin: .zero, size: size) + context.clear(bounds) + + context.setFillColor(UIColor.black.cgColor) + context.addPath(UIBezierPath(roundedRect: bounds.insetBy(dx: 2.0, dy: 2.0), cornerRadius: 24.0).cgPath) + context.fillPath() + + if let ribbonImage = UIImage(bundleImageName: "Chat/Message/GiftRibbon"), let cgImage = ribbonImage.cgImage { + context.draw(cgImage, in: CGRect(origin: CGPoint(x: bounds.width - ribbonImage.size.width, y: bounds.height - ribbonImage.size.height), size: ribbonImage.size), byTiling: false) + } + }) + } + backgroundContent.view.mask = strongSelf.mediaBackgroundMaskNode.view + strongSelf.mediaBackgroundMaskNode.frame = CGRect(origin: .zero, size: backgroundMaskFrame.size) + } else { + backgroundContent.frame = mediaBackgroundFrame + backgroundContent.clipsToBounds = true + backgroundContent.cornerRadius = 24.0 + backgroundContent.view.mask = nil + } } let baseBackgroundFrame = labelFrame.offsetBy(dx: 0.0, dy: -11.0) @@ -645,7 +722,7 @@ public class ChatMessageGiftBubbleContentNode: ChatMessageBubbleContentNode { return ChatMessageBubbleContentTapAction(content: .ignore) } else if let backgroundNode = self.backgroundNode, backgroundNode.frame.contains(point) { return ChatMessageBubbleContentTapAction(content: .openMessage) - } else if self.mediaBackgroundNode.frame.contains(point) { + } else if self.mediaBackgroundContent?.frame.contains(point) == true { return ChatMessageBubbleContentTapAction(content: .openMessage) } else { return ChatMessageBubbleContentTapAction(content: .none) diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageInteractiveInstantVideoNode/Sources/ChatMessageInteractiveInstantVideoNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageInteractiveInstantVideoNode/Sources/ChatMessageInteractiveInstantVideoNode.swift index 5dcd3a1f81..9bd38d928d 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageInteractiveInstantVideoNode/Sources/ChatMessageInteractiveInstantVideoNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageInteractiveInstantVideoNode/Sources/ChatMessageInteractiveInstantVideoNode.swift @@ -763,7 +763,7 @@ public class ChatMessageInteractiveInstantVideoNode: ASDisplayNode { }) } let mediaManager = item.context.sharedContext.mediaManager - let videoNode = UniversalVideoNode(postbox: item.context.account.postbox, audioSession: mediaManager.audioSession, manager: mediaManager.universalVideoManager, decoration: ChatBubbleInstantVideoDecoration(inset: 2.0, backgroundImage: instantVideoBackgroundImage, tapped: { + let videoNode = UniversalVideoNode(accountId: item.context.account.id, postbox: item.context.account.postbox, audioSession: mediaManager.audioSession, manager: mediaManager.universalVideoManager, decoration: ChatBubbleInstantVideoDecoration(inset: 2.0, backgroundImage: instantVideoBackgroundImage, tapped: { if let strongSelf = self { if let item = strongSelf.item { if strongSelf.infoBackgroundNode.alpha.isZero { diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageInteractiveMediaNode/Sources/ChatMessageInteractiveMediaNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageInteractiveMediaNode/Sources/ChatMessageInteractiveMediaNode.swift index e1586b4821..707944d151 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageInteractiveMediaNode/Sources/ChatMessageInteractiveMediaNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageInteractiveMediaNode/Sources/ChatMessageInteractiveMediaNode.swift @@ -1659,7 +1659,7 @@ public final class ChatMessageInteractiveMediaNode: ASDisplayNode, GalleryItemTr let loopVideo = updatedVideoFile.isAnimated let videoContent: UniversalVideoContent - if NativeVideoContent.isHLSVideo(file: updatedVideoFile), context.sharedContext.immediateExperimentalUISettings.dynamicStreaming { + if !"".isEmpty && NativeVideoContent.isHLSVideo(file: updatedVideoFile) { videoContent = HLSVideoContent(id: .message(message.id, message.stableId, updatedVideoFile.fileId), userLocation: .peer(message.id.peerId), fileReference: .message(message: MessageReference(message), media: updatedVideoFile), streamVideo: true, loopVideo: loopVideo) } else { videoContent = NativeVideoContent(id: .message(message.stableId, updatedVideoFile.fileId), userLocation: .peer(message.id.peerId), fileReference: .message(message: MessageReference(message), media: updatedVideoFile), streamVideo: streamVideo ? .conservative : .none, loopVideo: loopVideo, enableSound: false, fetchAutomatically: false, onlyFullSizeThumbnail: (onlyFullSizeVideoThumbnail ?? false), continuePlayingWithoutSoundOnLostAudioSession: isInlinePlayableVideo, placeholderColor: emptyColor, captureProtected: message.isCopyProtected() || isExtendedMedia, storeAfterDownload: { [weak context] in @@ -1669,7 +1669,7 @@ public final class ChatMessageInteractiveMediaNode: ASDisplayNode, GalleryItemTr let _ = storeDownloadedMedia(storeManager: context.downloadedMediaStoreManager, media: .message(message: MessageReference(message), media: updatedVideoFile), peerId: peerId).startStandalone() }) } - let videoNode = UniversalVideoNode(postbox: context.account.postbox, audioSession: mediaManager.audioSession, manager: mediaManager.universalVideoManager, decoration: decoration, content: videoContent, priority: .embedded) + let videoNode = UniversalVideoNode(accountId: context.account.id, postbox: context.account.postbox, audioSession: mediaManager.audioSession, manager: mediaManager.universalVideoManager, decoration: decoration, content: videoContent, priority: .embedded) videoNode.isUserInteractionEnabled = false videoNode.ownsContentNodeUpdated = { [weak self] owns in if let strongSelf = self { @@ -2162,10 +2162,15 @@ public final class ChatMessageInteractiveMediaNode: ASDisplayNode, GalleryItemTr if let duration = file.duration, !message.flags.contains(.Unsent) { let durationString = file.isAnimated ? gifTitle : stringForDuration(playerDuration > 0 ? playerDuration : Int32(duration), position: playerPosition) if isMediaStreamable(message: message, media: file) { - badgeContent = .mediaDownload(backgroundColor: messageTheme.mediaDateAndStatusFillColor, foregroundColor: messageTheme.mediaDateAndStatusTextColor, duration: durationString, size: active ? sizeString : nil, muted: muted, active: active) - mediaDownloadState = .fetching(progress: automaticPlayback ? nil : adjustedProgress) - if self.playerStatus?.status == .playing { - mediaDownloadState = nil + if NativeVideoContent.isHLSVideo(file: file) { + mediaDownloadState = .fetching(progress: nil) + badgeContent = .text(inset: 12.0, backgroundColor: messageTheme.mediaDateAndStatusFillColor, foregroundColor: messageTheme.mediaDateAndStatusTextColor, text: NSAttributedString(string: durationString), iconName: nil) + } else { + badgeContent = .mediaDownload(backgroundColor: messageTheme.mediaDateAndStatusFillColor, foregroundColor: messageTheme.mediaDateAndStatusTextColor, duration: durationString, size: active ? sizeString : nil, muted: muted, active: active) + mediaDownloadState = .fetching(progress: automaticPlayback ? nil : adjustedProgress) + if self.playerStatus?.status == .playing { + mediaDownloadState = nil + } } state = automaticPlayback ? .none : .play(messageTheme.mediaOverlayControlColors.foregroundColor) } else { @@ -2264,7 +2269,11 @@ public final class ChatMessageInteractiveMediaNode: ASDisplayNode, GalleryItemTr do { let durationString = file.isAnimated ? gifTitle : stringForDuration(playerDuration > 0 ? playerDuration : (file.duration.flatMap { Int32(floor($0)) } ?? 0), position: playerPosition) if wideLayout { - if isMediaStreamable(message: message, media: file), let fileSize = file.size, fileSize > 0 && fileSize != .max { + if NativeVideoContent.isHLSVideo(file: file) { + state = automaticPlayback ? .none : .play(messageTheme.mediaOverlayControlColors.foregroundColor) + mediaDownloadState = nil + badgeContent = .text(inset: 12.0, backgroundColor: messageTheme.mediaDateAndStatusFillColor, foregroundColor: messageTheme.mediaDateAndStatusTextColor, text: NSAttributedString(string: durationString), iconName: nil) + } else if isMediaStreamable(message: message, media: file), let fileSize = file.size, fileSize > 0 && fileSize != .max { state = automaticPlayback ? .none : .play(messageTheme.mediaOverlayControlColors.foregroundColor) badgeContent = .mediaDownload(backgroundColor: messageTheme.mediaDateAndStatusFillColor, foregroundColor: messageTheme.mediaDateAndStatusTextColor, duration: durationString, size: dataSizeString(fileSize, formatting: formatting), muted: muted, active: true) mediaDownloadState = .remote diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageProfilePhotoSuggestionContentNode/Sources/ChatMessageProfilePhotoSuggestionContentNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageProfilePhotoSuggestionContentNode/Sources/ChatMessageProfilePhotoSuggestionContentNode.swift index 47c902badc..cbcbc37fc4 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageProfilePhotoSuggestionContentNode/Sources/ChatMessageProfilePhotoSuggestionContentNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageProfilePhotoSuggestionContentNode/Sources/ChatMessageProfilePhotoSuggestionContentNode.swift @@ -222,7 +222,7 @@ public class ChatMessageProfilePhotoSuggestionContentNode: ChatMessageBubbleCont let videoContent = NativeVideoContent(id: .profileVideo(id, "action"), userLocation: .peer(item.message.id.peerId), fileReference: videoFileReference, streamVideo: isMediaStreamable(resource: video.resource) ? .conservative : .none, loopVideo: true, enableSound: false, fetchAutomatically: true, onlyFullSizeThumbnail: false, useLargeThumbnail: true, autoFetchFullSizeThumbnail: true, continuePlayingWithoutSoundOnLostAudioSession: false, placeholderColor: .clear, storeAfterDownload: nil) if videoContent.id != strongSelf.videoContent?.id { let mediaManager = item.context.sharedContext.mediaManager - let videoNode = UniversalVideoNode(postbox: item.context.account.postbox, audioSession: mediaManager.audioSession, manager: mediaManager.universalVideoManager, decoration: GalleryVideoDecoration(), content: videoContent, priority: .secondaryOverlay) + let videoNode = UniversalVideoNode(accountId: item.context.account.id, postbox: item.context.account.postbox, audioSession: mediaManager.audioSession, manager: mediaManager.universalVideoManager, decoration: GalleryVideoDecoration(), content: videoContent, priority: .secondaryOverlay) videoNode.isUserInteractionEnabled = false videoNode.ownsContentNodeUpdated = { [weak self] owns in if let strongSelf = self { diff --git a/submodules/TelegramUI/Components/Chat/ChatQrCodeScreen/Sources/ChatQrCodeScreen.swift b/submodules/TelegramUI/Components/Chat/ChatQrCodeScreen/Sources/ChatQrCodeScreen.swift index 86eb90094c..9cef08d2d2 100644 --- a/submodules/TelegramUI/Components/Chat/ChatQrCodeScreen/Sources/ChatQrCodeScreen.swift +++ b/submodules/TelegramUI/Components/Chat/ChatQrCodeScreen/Sources/ChatQrCodeScreen.swift @@ -2286,7 +2286,7 @@ private class MessageContentNode: ASDisplayNode, ContentNode { } } else { let videoContent = NativeVideoContent(id: .message(message.stableId, video.fileId), userLocation: .peer(message.id.peerId), fileReference: .message(message: MessageReference(message), media: video), streamVideo: .conservative, loopVideo: true, enableSound: false, fetchAutomatically: false, onlyFullSizeThumbnail: self.isStatic, continuePlayingWithoutSoundOnLostAudioSession: true, placeholderColor: .clear, captureProtected: false, storeAfterDownload: nil) - let videoNode = UniversalVideoNode(postbox: self.context.account.postbox, audioSession: self.context.sharedContext.mediaManager.audioSession, manager: self.context.sharedContext.mediaManager.universalVideoManager, decoration: GalleryVideoDecoration(), content: videoContent, priority: .overlay, autoplay: !self.isStatic) + let videoNode = UniversalVideoNode(accountId: self.context.account.id, postbox: self.context.account.postbox, audioSession: self.context.sharedContext.mediaManager.audioSession, manager: self.context.sharedContext.mediaManager.universalVideoManager, decoration: GalleryVideoDecoration(), content: videoContent, priority: .overlay, autoplay: !self.isStatic) self.videoStatusDisposable.set((videoNode.status |> deliverOnMainQueue).startStrict(next: { [weak self] status in diff --git a/submodules/TelegramUI/Components/ContentReportScreen/BUILD b/submodules/TelegramUI/Components/ContentReportScreen/BUILD new file mode 100644 index 0000000000..24d0da15cc --- /dev/null +++ b/submodules/TelegramUI/Components/ContentReportScreen/BUILD @@ -0,0 +1,41 @@ +load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library") + +swift_library( + name = "ContentReportScreen", + module_name = "ContentReportScreen", + srcs = glob([ + "Sources/**/*.swift", + ]), + copts = [ + "-warnings-as-errors", + ], + deps = [ + "//submodules/AsyncDisplayKit", + "//submodules/Display", + "//submodules/Postbox", + "//submodules/TelegramCore", + "//submodules/SSignalKit/SwiftSignalKit", + "//submodules/ComponentFlow", + "//submodules/Components/ViewControllerComponent", + "//submodules/Components/ComponentDisplayAdapters", + "//submodules/Components/MultilineTextComponent", + "//submodules/Components/BalancedTextComponent", + "//submodules/TelegramPresentationData", + "//submodules/AccountContext", + "//submodules/AppBundle", + "//submodules/ItemListUI", + "//submodules/TelegramStringFormatting", + "//submodules/PresentationDataUtils", + "//submodules/Components/SheetComponent", + "//submodules/UndoUI", + "//submodules/TelegramUI/Components/ListSectionComponent", + "//submodules/TelegramUI/Components/ListActionItemComponent", + "//submodules/TelegramUI/Components/NavigationStackComponent", + "//submodules/TelegramUI/Components/LottieComponent", + "//submodules/TelegramUI/Components/ListMultilineTextFieldItemComponent", + "//submodules/TelegramUI/Components/ButtonComponent", + ], + visibility = [ + "//visibility:public", + ], +) diff --git a/submodules/TelegramUI/Components/ContentReportScreen/Sources/ContentReportScreen.swift b/submodules/TelegramUI/Components/ContentReportScreen/Sources/ContentReportScreen.swift new file mode 100644 index 0000000000..a248532726 --- /dev/null +++ b/submodules/TelegramUI/Components/ContentReportScreen/Sources/ContentReportScreen.swift @@ -0,0 +1,726 @@ +import Foundation +import UIKit +import Display +import ComponentFlow +import SwiftSignalKit +import TelegramCore +import Markdown +import TextFormat +import TelegramPresentationData +import ViewControllerComponent +import SheetComponent +import BalancedTextComponent +import MultilineTextComponent +import ListSectionComponent +import ListActionItemComponent +import NavigationStackComponent +import ItemListUI +import UndoUI +import AccountContext +import LottieComponent +import TextFieldComponent +import ListMultilineTextFieldItemComponent +import ButtonComponent + +private enum ReportResult { + case reported +} + +private final class SheetPageContent: CombinedComponent { + typealias EnvironmentType = ViewControllerComponentContainer.Environment + + enum Content: Equatable { + struct Item: Equatable { + let title: String + let option: Data + } + + case options(items: [Item]) + case comment(isOptional: Bool, option: Data) + } + + let context: AccountContext + let isFirst: Bool + let title: String? + let subtitle: String + let content: Content + let action: (Content.Item, String?) -> Void + let pop: () -> Void + + init( + context: AccountContext, + isFirst: Bool, + title: String?, + subtitle: String, + content: Content, + action: @escaping (Content.Item, String?) -> Void, + pop: @escaping () -> Void + ) { + self.context = context + self.isFirst = isFirst + self.title = title + self.subtitle = subtitle + self.content = content + self.action = action + self.pop = pop + } + + static func ==(lhs: SheetPageContent, rhs: SheetPageContent) -> Bool { + if lhs.context !== rhs.context { + return false + } + if lhs.title != rhs.title { + return false + } + if lhs.subtitle != rhs.subtitle { + return false + } + if lhs.content != rhs.content { + return false + } + return true + } + + final class State: ComponentState { + var backArrowImage: (UIImage, PresentationTheme)? + + let playOnce = ActionSlot() + private var didPlayAnimation = false + + let textInputState = ListMultilineTextFieldItemComponent.ExternalState() + + func playAnimationIfNeeded() { + guard !self.didPlayAnimation else { + return + } + self.didPlayAnimation = true + self.playOnce.invoke(Void()) + } + } + + func makeState() -> State { + return State() + } + + static var body: Body { + let background = Child(RoundedRectangle.self) + let back = Child(Button.self) + let title = Child(Text.self) + let animation = Child(LottieComponent.self) + let section = Child(ListSectionComponent.self) + let button = Child(ButtonComponent.self) + + return { context in + let environment = context.environment[EnvironmentType.self] + let component = context.component + let state = context.state + + let presentationData = component.context.sharedContext.currentPresentationData.with { $0 } + let theme = environment.theme + let strings = environment.strings + + let sideInset: CGFloat = 16.0 + environment.safeInsets.left + + var contentSize = CGSize(width: context.availableSize.width, height: 18.0) + + let background = background.update( + component: RoundedRectangle(color: theme.list.modalBlocksBackgroundColor, cornerRadius: 8.0), + availableSize: CGSize(width: context.availableSize.width, height: 1000.0), + transition: .immediate + ) + context.add(background + .position(CGPoint(x: context.availableSize.width / 2.0, y: background.size.height / 2.0)) + ) + + let backArrowImage: UIImage + if let (cached, cachedTheme) = state.backArrowImage, cachedTheme === theme { + backArrowImage = cached + } else { + backArrowImage = NavigationBarTheme.generateBackArrowImage(color: theme.list.itemAccentColor)! + state.backArrowImage = (backArrowImage, theme) + } + + let backContents: AnyComponent + if component.isFirst { + backContents = AnyComponent(Text(text: strings.Common_Cancel, font: Font.regular(17.0), color: theme.list.itemAccentColor)) + } else { + backContents = AnyComponent( + HStack([ + AnyComponentWithIdentity(id: "arrow", component: AnyComponent(Image(image: backArrowImage, contentMode: .center))), + AnyComponentWithIdentity(id: "label", component: AnyComponent(Text(text: strings.Common_Back, font: Font.regular(17.0), color: theme.list.itemAccentColor))) + ], spacing: 6.0) + ) + } + let back = back.update( + component: Button( + content: backContents, + action: { + component.pop() + } + ), + availableSize: CGSize(width: context.availableSize.width, height: context.availableSize.height), + transition: .immediate + ) + context.add(back + .position(CGPoint(x: sideInset + back.size.width / 2.0 - (component.title != nil ? 8.0 : 0.0), y: contentSize.height + back.size.height / 2.0)) + ) + + let constrainedTitleWidth = context.availableSize.width - (back.size.width + 16.0) * 2.0 + + let titleString: String + if let title = component.title { + titleString = title + } else { + titleString = "" + } + + let title = title.update( + component: Text(text: titleString, font: Font.semibold(17.0), color: theme.list.itemPrimaryTextColor), + availableSize: CGSize(width: constrainedTitleWidth, height: context.availableSize.height), + transition: .immediate + ) + context.add(title + .position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + title.size.height / 2.0)) + ) + contentSize.height += title.size.height + contentSize.height += 24.0 + + var items: [AnyComponentWithIdentity] = [] + var footer: AnyComponent? + + switch component.content { + case let .options(options): + for item in options { + items.append(AnyComponentWithIdentity(id: item.title, component: AnyComponent(ListActionItemComponent( + theme: theme, + title: AnyComponent(VStack([ + AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString( + string: item.title, + font: Font.regular(presentationData.listsFontSize.baseDisplaySize), + textColor: theme.list.itemPrimaryTextColor + )), + maximumNumberOfLines: 1 + ))), + ], alignment: .left, spacing: 2.0)), + accessory: .arrow, + action: { _ in + component.action(item, nil) + } + )))) + } + case let .comment(isOptional, _): + contentSize.height -= 11.0 + + let animationHeight: CGFloat = 120.0 + let animation = animation.update( + component: LottieComponent( + content: LottieComponent.AppBundleContent(name: "Cop"), + startingPosition: .begin, + playOnce: state.playOnce + ), + environment: {}, + availableSize: CGSize(width: animationHeight, height: animationHeight), + transition: .immediate + ) + context.add(animation + .position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + animation.size.height / 2.0)) + ) + contentSize.height += animation.size.height + contentSize.height += 18.0 + + items.append( + AnyComponentWithIdentity(id: items.count, component: AnyComponent(ListMultilineTextFieldItemComponent( + externalState: state.textInputState, + context: component.context, + theme: theme, + strings: strings, + initialText: "", + resetText: nil, + placeholder: isOptional ? "Add Comment (Optional)" : "Add Comment", + autocapitalizationType: .none, + autocorrectionType: .no, + returnKeyType: .done, + characterLimit: 140, + displayCharacterLimit: true, + emptyLineHandling: .notAllowed, + updated: { [weak state] _ in + state?.updated() + }, + returnKeyAction: { +// guard let self else { +// return +// } +// if let titleView = self.introSection.findTaggedView(tag: self.textInputTag) as? ListMultilineTextFieldItemComponent.View { +// titleView.endEditing(true) +// } + }, + textUpdateTransition: .spring(duration: 0.4), + tag: nil + ))) + ) + + footer = AnyComponent(MultilineTextComponent( + text: .plain( + NSAttributedString(string: "Please help us by telling what is wrong with the message you have selected.", font: Font.regular(presentationData.listsFontSize.itemListBaseHeaderFontSize), textColor: theme.list.freeTextColor) + ), + maximumNumberOfLines: 0 + )) + } + + let section = section.update( + component: ListSectionComponent( + theme: theme, + header: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString( + string: component.subtitle.uppercased(), + font: Font.regular(presentationData.listsFontSize.itemListBaseHeaderFontSize), + textColor: theme.list.freeTextColor + )), + maximumNumberOfLines: 0 + )), + footer: footer, + items: items + ), + environment: {}, + availableSize: CGSize(width: context.availableSize.width - sideInset * 2.0, height: .greatestFiniteMagnitude), + transition: context.transition + ) + context.add(section + .position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + section.size.height / 2.0)) + ) + contentSize.height += section.size.height + contentSize.height += 54.0 + + if case let .comment(isOptional, option) = component.content { + contentSize.height -= 16.0 + + let action = component.action + let button = button.update( + component: ButtonComponent( + background: ButtonComponent.Background( + color: theme.list.itemCheckColors.fillColor, + foreground: theme.list.itemCheckColors.foregroundColor, + pressedColor: theme.list.itemCheckColors.fillColor.withMultipliedAlpha(0.8) + ), + content: AnyComponentWithIdentity(id: AnyHashable(0 as Int), component: AnyComponent(Text(text: "Send Report", font: Font.semibold(17.0), color: theme.list.itemCheckColors.foregroundColor))), + isEnabled: isOptional || state.textInputState.hasText, + allowActionWhenDisabled: false, + displaysProgress: false, + action: { + action(SheetPageContent.Content.Item(title: "", option: option), state.textInputState.text.string) + } + ), + environment: {}, + availableSize: CGSize(width: context.availableSize.width - sideInset * 2.0, height: 50.0), + transition: context.transition + ) + context.add(button + .clipsToBounds(true) + .cornerRadius(10.0) + .position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + button.size.height / 2.0)) + ) + contentSize.height += button.size.height + contentSize.height += 16.0 + + if environment.inputHeight.isZero && environment.safeInsets.bottom > 0.0 { + contentSize.height += environment.safeInsets.bottom + } + } + + contentSize.height += environment.inputHeight + + state.playAnimationIfNeeded() + + return contentSize + } + } +} + +private final class SheetContent: CombinedComponent { + typealias EnvironmentType = ViewControllerComponentContainer.Environment + + let context: AccountContext + let subject: ReportContentSubject + let title: String + let options: [ReportContentResult.Option] + let pts: Int + let openMore: () -> Void + let complete: (ReportResult) -> Void + let dismiss: () -> Void + let update: (ComponentTransition) -> Void + + init( + context: AccountContext, + subject: ReportContentSubject, + title: String, + options: [ReportContentResult.Option], + pts: Int, + openMore: @escaping () -> Void, + complete: @escaping (ReportResult) -> Void, + dismiss: @escaping () -> Void, + update: @escaping (ComponentTransition) -> Void + ) { + self.context = context + self.subject = subject + self.title = title + self.options = options + self.pts = pts + self.openMore = openMore + self.complete = complete + self.dismiss = dismiss + self.update = update + } + + static func ==(lhs: SheetContent, rhs: SheetContent) -> Bool { + if lhs.context !== rhs.context { + return false + } + if lhs.subject != rhs.subject { + return false + } + if lhs.title != rhs.title { + return false + } + if lhs.options != rhs.options { + return false + } + if lhs.pts != rhs.pts { + return false + } + return true + } + + final class State: ComponentState { + var pushedOptions: [(title: String, subtitle: String, content: SheetPageContent.Content)] = [] + let disposable = MetaDisposable() + + deinit { + self.disposable.dispose() + } + } + + func makeState() -> State { + return State() + } + + static var body: Body { + let navigation = Child(NavigationStackComponent.self) + + return { context in + let environment = context.environment[EnvironmentType.self] + let component = context.component + let state = context.state + let update = component.update + + let accountContext = component.context + let subject = component.subject + let complete = component.complete + let action: (SheetPageContent.Content.Item, String?) -> Void = { [weak state] item, message in + guard let state else { + return + } + state.disposable.set( + (accountContext.engine.messages.reportContent(subject: subject, option: item.option, message: message) + |> deliverOnMainQueue).start(next: { [weak state] result in + switch result { + case let .options(title, options): + state?.pushedOptions.append((item.title, title, .options(items: options.map { SheetPageContent.Content.Item(title: $0.text, option: $0.option) }))) + state?.updated(transition: .spring(duration: 0.45)) + case let .addComment(isOptional, option): + state?.pushedOptions.append((item.title, "", .comment(isOptional: isOptional, option: option))) + state?.updated(transition: .spring(duration: 0.45)) + case .reported: + complete(.reported) + } + }, error: { error in +// if case .premiumRequired = error { +// complete(.premiumRequired) +// } + }) + ) + } + + let mainTitle: String + switch component.subject { + case .peer: + mainTitle = "Report Peer" + case .messages: + mainTitle = "Report Message" + case .stories: + mainTitle = "Report Story" + } + + var items: [AnyComponentWithIdentity] = [] + items.append(AnyComponentWithIdentity(id: items.count, component: AnyComponent( + SheetPageContent( + context: component.context, + isFirst: true, + title: mainTitle, + subtitle: component.title, + content: .options(items: component.options.map { + SheetPageContent.Content.Item(title: $0.text, option: $0.option) + }), + action: { item, message in + action(item, message) + }, + pop: { + component.dismiss() + } + ) + ))) + for pushedOption in state.pushedOptions { + items.append(AnyComponentWithIdentity(id: items.count, component: AnyComponent( + SheetPageContent( + context: component.context, + isFirst: false, + title: pushedOption.title, + subtitle: pushedOption.subtitle, + content: pushedOption.content, + action: { item, message in + action(item, message) + }, + pop: { [weak state] in + state?.pushedOptions.removeLast() + update(.spring(duration: 0.45)) + } + ) + ))) + } + + var contentSize = CGSize(width: context.availableSize.width, height: 0.0) + let navigation = navigation.update( + component: NavigationStackComponent( + items: items, + clipContent: false, + requestPop: { [weak state] in + state?.pushedOptions.removeLast() + update(.spring(duration: 0.45)) + } + ), + environment: { environment }, + availableSize: CGSize(width: context.availableSize.width, height: context.availableSize.height), + transition: context.transition + ) + context.add(navigation + .position(CGPoint(x: context.availableSize.width / 2.0, y: navigation.size.height / 2.0)) + .clipsToBounds(true) + .cornerRadius(8.0) + ) + contentSize.height += navigation.size.height + + return contentSize + } + } +} + +private final class SheetContainerComponent: CombinedComponent { + typealias EnvironmentType = ViewControllerComponentContainer.Environment + + let context: AccountContext + let subject: ReportContentSubject + let title: String + let options: [ReportContentResult.Option] + let openMore: () -> Void + let complete: (ReportResult) -> Void + + init( + context: AccountContext, + subject: ReportContentSubject, + title: String, + options: [ReportContentResult.Option], + openMore: @escaping () -> Void, + complete: @escaping (ReportResult) -> Void + ) { + self.context = context + self.subject = subject + self.title = title + self.options = options + self.openMore = openMore + self.complete = complete + } + + static func ==(lhs: SheetContainerComponent, rhs: SheetContainerComponent) -> Bool { + if lhs.context !== rhs.context { + return false + } + if lhs.subject != rhs.subject { + return false + } + if lhs.title != rhs.title { + return false + } + if lhs.options != rhs.options { + return false + } + return true + } + + final class State: ComponentState { + var pts: Int = 0 + } + + func makeState() -> State { + return State() + } + + static var body: Body { + let sheet = Child(SheetComponent.self) + let animateOut = StoredActionSlot(Action.self) + + let sheetExternalState = SheetComponent.ExternalState() + + return { context in + let environment = context.environment[EnvironmentType.self] + let state = context.state + let controller = environment.controller + + let sheet = sheet.update( + component: SheetComponent( + content: AnyComponent(SheetContent( + context: context.component.context, + subject: context.component.subject, + title: context.component.title, + options: context.component.options, + pts: state.pts, + openMore: context.component.openMore, + complete: context.component.complete, + dismiss: { + animateOut.invoke(Action { _ in + if let controller = controller() { + controller.dismiss(completion: nil) + } + }) + }, + update: { [weak state] transition in + state?.pts += 1 + state?.updated(transition: transition) + } + )), + backgroundColor: .color(environment.theme.list.modalBlocksBackgroundColor), + followContentSizeChanges: true, + externalState: sheetExternalState, + animateOut: animateOut + ), + environment: { + environment + SheetComponentEnvironment( + isDisplaying: environment.value.isVisible, + isCentered: environment.metrics.widthClass == .regular, + hasInputHeight: !environment.inputHeight.isZero, + regularMetricsSize: CGSize(width: 430.0, height: 900.0), + dismiss: { animated in + if animated { + animateOut.invoke(Action { _ in + if let controller = controller() { + controller.dismiss(completion: nil) + } + }) + } else { + if let controller = controller() { + controller.dismiss(completion: nil) + } + } + } + ) + }, + availableSize: context.availableSize, + transition: context.transition + ) + + context.add(sheet + .position(CGPoint(x: context.availableSize.width / 2.0, y: context.availableSize.height / 2.0)) + ) + + if let controller = controller(), !controller.automaticallyControlPresentationContextLayout { + let layout = ContainerViewLayout( + size: context.availableSize, + metrics: environment.metrics, + deviceMetrics: environment.deviceMetrics, + intrinsicInsets: UIEdgeInsets(top: 0.0, left: 0.0, bottom: max(environment.safeInsets.bottom, sheetExternalState.contentHeight), right: 0.0), + safeInsets: UIEdgeInsets(top: 0.0, left: environment.safeInsets.left, bottom: 0.0, right: environment.safeInsets.right), + additionalInsets: .zero, + statusBarHeight: environment.statusBarHeight, + inputHeight: nil, + inputHeightIsInteractivellyChanging: false, + inVoiceOver: false + ) + controller.presentationContext.containerLayoutUpdated(layout, transition: context.transition.containedViewLayoutTransition) + } + + return context.availableSize + } + } +} + + +public final class ContentReportScreen: ViewControllerComponentContainer { + private let context: AccountContext + + public init( + context: AccountContext, + subject: ReportContentSubject, + title: String, + options: [ReportContentResult.Option], + forceDark: Bool = false, + completed: @escaping () -> Void + ) { + self.context = context + + var completeImpl: ((ReportResult) -> Void)? + super.init( + context: context, + component: SheetContainerComponent( + context: context, + subject: subject, + title: title, + options: options, + openMore: {}, + complete: { hidden in + completeImpl?(hidden) + } + ), + navigationBarAppearance: .none, + statusBarStyle: .ignore, + theme: forceDark ? .dark : .default + ) + + self.navigationPresentation = .flatModal + + completeImpl = { [weak self] result in + guard let self else { + return + } + let navigationController = self.navigationController + self.dismissAnimated() + + switch result { + case .reported: + Queue.mainQueue().after(0.1) { + completed() + } + + let presentationData = context.sharedContext.currentPresentationData.with { $0 } + Queue.mainQueue().after(0.4, { + (navigationController?.viewControllers.last as? ViewController)?.present(UndoOverlayController(presentationData: presentationData, content: .emoji(name: "PoliceCar", text: presentationData.strings.Report_Succeed), elevatedLayout: false, action: { _ in return true }), in: .current) + }) + } + } + } + + required public init(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public override func viewDidLoad() { + super.viewDidLoad() + + self.view.disablesInteractiveModalDismiss = true + } + + public func dismissAnimated() { + if let view = self.node.hostView.findTaggedView(tag: SheetComponent.View.Tag()) as? SheetComponent.View { + view.dismissAnimated() + } + } +} diff --git a/submodules/TelegramUI/Components/Gifts/GiftAnimationComponent/BUILD b/submodules/TelegramUI/Components/Gifts/GiftAnimationComponent/BUILD new file mode 100644 index 0000000000..2fec2704be --- /dev/null +++ b/submodules/TelegramUI/Components/Gifts/GiftAnimationComponent/BUILD @@ -0,0 +1,31 @@ +load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library") + +swift_library( + name = "GiftAnimationComponent", + module_name = "GiftAnimationComponent", + srcs = glob([ + "Sources/**/*.swift", + ]), + copts = [ + "-warnings-as-errors", + ], + deps = [ + "//submodules/AsyncDisplayKit", + "//submodules/Display", + "//submodules/Postbox", + "//submodules/TelegramCore", + "//submodules/SSignalKit/SwiftSignalKit", + "//submodules/ComponentFlow", + "//submodules/Components/ComponentDisplayAdapters", + "//submodules/TelegramPresentationData", + "//submodules/AccountContext", + "//submodules/AppBundle", + "//submodules/TelegramStringFormatting", + "//submodules/PresentationDataUtils", + "//submodules/TextFormat", + "//submodules/TelegramUI/Components/EmojiTextAttachmentView", + ], + visibility = [ + "//visibility:public", + ], +) diff --git a/submodules/TelegramUI/Components/Gifts/GiftAnimationComponent/Sources/GiftAnimationComponent.swift b/submodules/TelegramUI/Components/Gifts/GiftAnimationComponent/Sources/GiftAnimationComponent.swift new file mode 100644 index 0000000000..c5d161e3de --- /dev/null +++ b/submodules/TelegramUI/Components/Gifts/GiftAnimationComponent/Sources/GiftAnimationComponent.swift @@ -0,0 +1,98 @@ +import Foundation +import UIKit +import Display +import ComponentFlow +import TelegramCore +import TelegramPresentationData +import AppBundle +import AccountContext +import EmojiTextAttachmentView +import TextFormat + +public final class GiftAnimationComponent: Component { + let context: AccountContext + let theme: PresentationTheme + let file: TelegramMediaFile? + + public init( + context: AccountContext, + theme: PresentationTheme, + file: TelegramMediaFile? + ) { + self.context = context + self.theme = theme + self.file = file + } + + public static func ==(lhs: GiftAnimationComponent, rhs: GiftAnimationComponent) -> Bool { + if lhs.context !== rhs.context { + return false + } + if lhs.theme !== rhs.theme { + return false + } + if lhs.file != rhs.file { + return false + } + return true + } + + public final class View: UIView { + private var component: GiftAnimationComponent? + private weak var componentState: EmptyComponentState? + + private var animationLayer: InlineStickerItemLayer? + + override init(frame: CGRect) { + super.init(frame: frame) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func update(component: GiftAnimationComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + self.component = component + self.componentState = state + + let emoji = ChatTextInputTextCustomEmojiAttribute( + interactivelySelectedFromPackId: nil, + fileId: component.file?.fileId.id ?? 0, + file: component.file + ) + + let iconSize = availableSize + if self.animationLayer == nil { + let animationLayer = InlineStickerItemLayer( + context: .account(component.context), + userLocation: .other, + attemptSynchronousLoad: false, + emoji: emoji, + file: component.file, + cache: component.context.animationCache, + renderer: component.context.animationRenderer, + unique: true, + placeholderColor: component.theme.list.mediaPlaceholderColor, + pointSize: CGSize(width: iconSize.width * 1.2, height: iconSize.height * 1.2), + loopCount: 1 + ) + animationLayer.isVisibleForAnimations = true + self.animationLayer = animationLayer + self.layer.addSublayer(animationLayer) + } + if let animationLayer = self.animationLayer { + transition.setFrame(layer: animationLayer, frame: CGRect(origin: .zero, size: iconSize)) + } + + return iconSize + } + } + + public func makeView() -> View { + return View(frame: CGRect()) + } + + public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } +} diff --git a/submodules/TelegramUI/Components/Gifts/GiftItemComponent/Sources/GiftItemComponent.swift b/submodules/TelegramUI/Components/Gifts/GiftItemComponent/Sources/GiftItemComponent.swift index 340460ce72..2f1b3612be 100644 --- a/submodules/TelegramUI/Components/Gifts/GiftItemComponent/Sources/GiftItemComponent.swift +++ b/submodules/TelegramUI/Components/Gifts/GiftItemComponent/Sources/GiftItemComponent.swift @@ -20,35 +20,62 @@ public final class GiftItemComponent: Component { } public struct Ribbon: Equatable { + public enum Color { + case red + case blue + + var colors: [UIColor] { + switch self { + case .red: + return [ + UIColor(rgb: 0xed1c26), + UIColor(rgb: 0xff5c55) + + ] + case .blue: + return [ + UIColor(rgb: 0x34a4fc), + UIColor(rgb: 0x6fd3ff) + ] + } + } + } public let text: String - public let color: UIColor + public let color: Color - public init(text: String, color: UIColor) { + public init(text: String, color: Color) { self.text = text self.color = color } } + public enum Peer: Equatable { + case peer(EnginePeer) + case anonymous + } + let context: AccountContext let theme: PresentationTheme - let peer: EnginePeer? - let subject: Subject + let peer: GiftItemComponent.Peer? + let subject: GiftItemComponent.Subject let title: String? let subtitle: String? let price: String let ribbon: Ribbon? let isLoading: Bool + let isHidden: Bool public init( context: AccountContext, theme: PresentationTheme, - peer: EnginePeer?, - subject: Subject, + peer: GiftItemComponent.Peer?, + subject: GiftItemComponent.Subject, title: String? = nil, subtitle: String? = nil, price: String, ribbon: Ribbon? = nil, - isLoading: Bool = false + isLoading: Bool = false, + isHidden: Bool = false ) { self.context = context self.theme = theme @@ -59,6 +86,7 @@ public final class GiftItemComponent: Component { self.price = price self.ribbon = ribbon self.isLoading = isLoading + self.isHidden = isHidden } public static func ==(lhs: GiftItemComponent, rhs: GiftItemComponent) -> Bool { @@ -89,6 +117,9 @@ public final class GiftItemComponent: Component { if lhs.isLoading != rhs.isLoading { return false } + if lhs.isHidden != rhs.isHidden { + return false + } return true } @@ -108,6 +139,9 @@ public final class GiftItemComponent: Component { private var animationLayer: InlineStickerItemLayer? + private var hiddenIconBackground: UIVisualEffectView? + private var hiddenIcon: UIImageView? + override init(frame: CGRect) { super.init(frame: frame) @@ -125,6 +159,8 @@ public final class GiftItemComponent: Component { } func update(component: GiftItemComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + let isFirstTime = self.component == nil + self.component = component self.componentState = state @@ -201,8 +237,9 @@ public final class GiftItemComponent: Component { self.layer.addSublayer(animationLayer) } + let animationFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((availableSize.width - iconSize.width) / 2.0), y: animationOffset), size: iconSize) if let animationLayer = self.animationLayer { - transition.setFrame(layer: animationLayer, frame: CGRect(origin: CGPoint(x: floorToScreenPixels((availableSize.width - iconSize.width) / 2.0), y: animationOffset), size: iconSize)) + transition.setFrame(layer: animationLayer, frame: animationFrame) } if let title = component.title { @@ -287,7 +324,7 @@ public final class GiftItemComponent: Component { ribbonTextView.bounds = CGRect(origin: .zero, size: ribbonTextSize) if self.ribbon.image == nil { - self.ribbon.image = generateGradientTintedImage(image: UIImage(bundleImageName: "Premium/GiftRibbon"), colors: [ribbon.color.withMultipliedBrightnessBy(1.1), ribbon.color.withMultipliedBrightnessBy(0.9)], direction: .diagonal) + self.ribbon.image = generateGradientTintedImage(image: UIImage(bundleImageName: "Premium/GiftRibbon"), colors: ribbon.color.colors, direction: .diagonal) } if let ribbonImage = self.ribbon.image { self.ribbon.frame = CGRect(origin: CGPoint(x: size.width - ribbonImage.size.width + 2.0, y: -2.0), size: ribbonImage.size) @@ -312,13 +349,64 @@ public final class GiftItemComponent: Component { self.avatarNode = avatarNode } - avatarNode.setPeer(context: component.context, theme: component.theme, peer: peer, displayDimensions: CGSize(width: 20.0, height: 20.0)) + switch peer { + case let .peer(peer): + avatarNode.setPeer(context: component.context, theme: component.theme, peer: peer, displayDimensions: CGSize(width: 20.0, height: 20.0)) + case .anonymous: + avatarNode.setPeer(context: component.context, theme: component.theme, peer: nil, overrideImage: .anonymousSavedMessagesIcon(isColored: true)) + } + avatarNode.frame = CGRect(origin: CGPoint(x: 2.0, y: 2.0), size: CGSize(width: 20.0, height: 20.0)) } self.backgroundLayer.backgroundColor = component.theme.list.itemBlocksBackgroundColor.cgColor transition.setFrame(layer: self.backgroundLayer, frame: CGRect(origin: .zero, size: size)) + if component.isHidden { + let hiddenIconBackground: UIVisualEffectView + let hiddenIcon: UIImageView + if let currentBackground = self.hiddenIconBackground, let currentIcon = self.hiddenIcon { + hiddenIconBackground = currentBackground + hiddenIcon = currentIcon + } else { + let blurEffect: UIBlurEffect + if #available(iOS 13.0, *) { + blurEffect = UIBlurEffect(style: .systemThinMaterialDark) + } else { + blurEffect = UIBlurEffect(style: .dark) + } + hiddenIconBackground = UIVisualEffectView(effect: blurEffect) + hiddenIconBackground.clipsToBounds = true + hiddenIconBackground.layer.cornerRadius = 15.0 + self.hiddenIconBackground = hiddenIconBackground + + hiddenIcon = UIImageView(image: generateTintedImage(image: UIImage(bundleImageName: "Peer Info/HiddenIcon"), color: .white)) + self.hiddenIcon = hiddenIcon + + self.addSubview(hiddenIconBackground) + hiddenIconBackground.contentView.addSubview(hiddenIcon) + + if !isFirstTime { + hiddenIconBackground.layer.animateScale(from: 0.01, to: 1.0, duration: 0.2) + hiddenIconBackground.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) + } + } + + let iconSize = CGSize(width: 30.0, height: 30.0) + hiddenIconBackground.frame = iconSize.centered(around: animationFrame.center) + hiddenIcon.frame = CGRect(origin: .zero, size: iconSize) + } else { + if let hiddenIconBackground = self.hiddenIconBackground { + self.hiddenIconBackground = nil + self.hiddenIcon = nil + + hiddenIconBackground.layer.animateAlpha(from: 1.0, to: 0.01, duration: 0.2, removeOnCompletion: false, completion: { _ in + hiddenIconBackground.removeFromSuperview() + }) + hiddenIconBackground.layer.animateScale(from: 1.0, to: 0.01, duration: 0.2, removeOnCompletion: false) + } + } + return size } } diff --git a/submodules/TelegramUI/Components/Gifts/GiftOptionsScreen/Sources/GiftOptionsScreen.swift b/submodules/TelegramUI/Components/Gifts/GiftOptionsScreen/Sources/GiftOptionsScreen.swift index 78a2fdb53a..eb54cd350d 100644 --- a/submodules/TelegramUI/Components/Gifts/GiftOptionsScreen/Sources/GiftOptionsScreen.swift +++ b/submodules/TelegramUI/Components/Gifts/GiftOptionsScreen/Sources/GiftOptionsScreen.swift @@ -30,15 +30,18 @@ final class GiftOptionsScreenComponent: Component { typealias EnvironmentType = ViewControllerComponentContainer.Environment let context: AccountContext + let starsContext: StarsContext let peerId: EnginePeer.Id let premiumOptions: [CachedPremiumGiftOption] init( context: AccountContext, + starsContext: StarsContext, peerId: EnginePeer.Id, premiumOptions: [CachedPremiumGiftOption] ) { self.context = context + self.starsContext = starsContext self.peerId = peerId self.premiumOptions = premiumOptions } @@ -100,10 +103,15 @@ final class GiftOptionsScreenComponent: Component { private let header = ComponentView() + private let balanceTitle = ComponentView() + private let balanceValue = ComponentView() + private let balanceIcon = ComponentView() + private let premiumTitle = ComponentView() private let premiumDescription = ComponentView() private var premiumItems: [AnyHashable: ComponentView] = [:] - private var selectedPremiumGift: String? + private var inProgressPremiumGift: String? + private let purchaseDisposable = MetaDisposable() private let starsTitle = ComponentView() private let starsDescription = ComponentView() @@ -113,6 +121,9 @@ final class GiftOptionsScreenComponent: Component { private var isUpdating: Bool = false + private var starsStateDisposable: Disposable? + private var starsState: StarsContext.State? + private var component: GiftOptionsScreenComponent? private(set) weak var state: State? private var environment: EnvironmentType? @@ -147,6 +158,8 @@ final class GiftOptionsScreenComponent: Component { } deinit { + self.starsStateDisposable?.dispose() + self.purchaseDisposable.dispose() } func scrollToTop() { @@ -205,7 +218,6 @@ final class GiftOptionsScreenComponent: Component { transition.setScale(view: premiumTitleView, scale: premiumTitleScale) } - if let headerView = self.header.view { transition.setPosition(view: headerView, position: CGPoint(x: availableWidth / 2.0, y: topInset + headerView.bounds.height / 2.0 - 30.0 - premiumTitleOffset * premiumTitleScale)) transition.setScale(view: headerView, scale: premiumTitleScale) @@ -273,7 +285,7 @@ final class GiftOptionsScreenComponent: Component { ribbon: gift.availability != nil ? GiftItemComponent.Ribbon( text: "Limited", - color: UIColor(rgb: 0x58c1fe) + color: .blue ) : nil ) @@ -330,6 +342,88 @@ final class GiftOptionsScreenComponent: Component { } } + private func buyPremium(_ product: PremiumGiftProduct) { + guard let component = self.component, let inAppPurchaseManager = self.component?.context.inAppPurchaseManager, self.inProgressPremiumGift == nil else { + return + } + + self.inProgressPremiumGift = product.id + self.state?.updated() + + let (currency, amount) = product.storeProduct.priceCurrencyAndAmount + + addAppLogEvent(postbox: component.context.account.postbox, type: "premium_gift.promo_screen_accept") + + let purpose: AppStoreTransactionPurpose = .giftCode(peerIds: [component.peerId], boostPeer: nil, currency: currency, amount: amount) + let quantity: Int32 = 1 + + let _ = (component.context.engine.payments.canPurchasePremium(purpose: purpose) + |> deliverOnMainQueue).start(next: { [weak self] available in + if let strongSelf = self { + let presentationData = component.context.sharedContext.currentPresentationData.with { $0 } + if available { + strongSelf.purchaseDisposable.set((inAppPurchaseManager.buyProduct(product.storeProduct, quantity: quantity, purpose: purpose) + |> deliverOnMainQueue).start(next: { [weak self] status in + guard let self, case .purchased = status, let controller = self.environment?.controller(), let navigationController = controller.navigationController as? NavigationController else { + return + } + + var controllers = navigationController.viewControllers + controllers = controllers.filter { !($0 is GiftOptionsScreen) } + var foundController = false + for controller in controllers.reversed() { + if let chatController = controller as? ChatController, case .peer(id: component.peerId) = chatController.chatLocation { + chatController.hintPlayNextOutgoingGift() + foundController = true + break + } + } + if !foundController { + let chatController = component.context.sharedContext.makeChatController(context: component.context, chatLocation: .peer(id: component.peerId), subject: nil, botStart: nil, mode: .standard(.default), params: nil) + chatController.hintPlayNextOutgoingGift() + controllers.append(chatController) + } + navigationController.setViewControllers(controllers, animated: true) + }, error: { [weak self] error in + guard let self, let controller = self.environment?.controller() else { + return + } + self.inProgressPremiumGift = nil + self.state?.updated(transition: .immediate) + + var errorText: String? + switch error { + case .generic: + errorText = presentationData.strings.Premium_Purchase_ErrorUnknown + case .network: + errorText = presentationData.strings.Premium_Purchase_ErrorNetwork + case .notAllowed: + errorText = presentationData.strings.Premium_Purchase_ErrorNotAllowed + case .cantMakePayments: + errorText = presentationData.strings.Premium_Purchase_ErrorCantMakePayments + case .assignFailed: + errorText = presentationData.strings.Premium_Purchase_ErrorUnknown + case .tryLater: + errorText = presentationData.strings.Premium_Purchase_ErrorUnknown + case .cancelled: + break + } + + if let errorText { + addAppLogEvent(postbox: component.context.account.postbox, type: "premium_gift.promo_screen_fail") + + let alertController = textAlertController(context: component.context, title: nil, text: errorText, actions: [TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_OK, action: {})]) + controller.present(alertController, in: .window(.root)) + } + })) + } else { + self?.inProgressPremiumGift = nil + self?.state?.updated(transition: .immediate) + } + } + }) + } + func update(component: GiftOptionsScreenComponent, availableSize: CGSize, state: State, environment: Environment, transition: ComponentTransition) -> CGSize { self.isUpdating = true defer { @@ -340,13 +434,21 @@ final class GiftOptionsScreenComponent: Component { let controller = environment.controller let themeUpdated = self.environment?.theme !== environment.theme self.environment = environment + self.state = state if self.component == nil { - + self.starsStateDisposable = (component.starsContext.state + |> deliverOnMainQueue).start(next: { [weak self] state in + guard let self else { + return + } + self.starsState = state + if !self.isUpdating { + self.state?.updated() + } + }) } - self.component = component - self.state = state if themeUpdated { self.backgroundColor = environment.theme.list.blocksBackgroundColor @@ -451,6 +553,55 @@ final class GiftOptionsScreenComponent: Component { transition.setFrame(view: cancelButtonView, frame: cancelButtonFrame) } + let balanceTitleSize = self.balanceTitle.update( + transition: .immediate, + component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString( + string: strings.Stars_Purchase_Balance, + font: Font.regular(14.0), + textColor: environment.theme.actionSheet.primaryTextColor + )), + maximumNumberOfLines: 1 + )), + environment: {}, + containerSize: availableSize + ) + let balanceValueSize = self.balanceValue.update( + transition: .immediate, + component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString( + string: presentationStringsFormattedNumber(Int32(self.starsState?.balance ?? 0), environment.dateTimeFormat.groupingSeparator), + font: Font.semibold(14.0), + textColor: environment.theme.actionSheet.primaryTextColor + )), + maximumNumberOfLines: 1 + )), + environment: {}, + containerSize: availableSize + ) + let balanceIconSize = self.balanceIcon.update( + transition: .immediate, + component: AnyComponent(BundleIconComponent(name: "Premium/Stars/StarSmall", tintColor: nil)), + environment: {}, + containerSize: availableSize + ) + + if let balanceTitleView = self.balanceTitle.view, let balanceValueView = self.balanceValue.view, let balanceIconView = self.balanceIcon.view { + if balanceTitleView.superview == nil { + self.addSubview(balanceTitleView) + self.addSubview(balanceValueView) + self.addSubview(balanceIconView) + } + let navigationHeight = environment.navigationHeight - environment.statusBarHeight + let topBalanceOriginY = environment.statusBarHeight + (navigationHeight - balanceTitleSize.height - balanceValueSize.height) / 2.0 + balanceTitleView.center = CGPoint(x: availableSize.width - 16.0 - environment.safeInsets.right - balanceTitleSize.width / 2.0, y: topBalanceOriginY + balanceTitleSize.height / 2.0) + balanceTitleView.bounds = CGRect(origin: .zero, size: balanceTitleSize) + balanceValueView.center = CGPoint(x: availableSize.width - 16.0 - environment.safeInsets.right - balanceValueSize.width / 2.0, y: topBalanceOriginY + balanceTitleSize.height + balanceValueSize.height / 2.0) + balanceValueView.bounds = CGRect(origin: .zero, size: balanceValueSize) + balanceIconView.center = CGPoint(x: availableSize.width - 16.0 - environment.safeInsets.right - balanceValueSize.width - balanceIconSize.width / 2.0 - 2.0, y: topBalanceOriginY + balanceTitleSize.height + balanceValueSize.height / 2.0 - UIScreenPixel) + balanceIconView.bounds = CGRect(origin: .zero, size: balanceIconSize) + } + let premiumTitleSize = self.premiumTitle.update( transition: transition, component: AnyComponent(MultilineTextComponent( @@ -494,8 +645,13 @@ final class GiftOptionsScreenComponent: Component { return nil } }, - tapAction: { _, _ in - + tapAction: { [weak self] _, _ in + guard let self, let component = self.component, let environment = self.environment else { + return + } + let introController = component.context.sharedContext.makePremiumIntroController(context: component.context, source: .settings, forceDark: false, dismissed: nil) + introController.navigationPresentation = .modal + environment.controller()?.push(introController) } )), environment: {}, @@ -561,21 +717,15 @@ final class GiftOptionsScreenComponent: Component { ribbon: product.discount.flatMap { GiftItemComponent.Ribbon( text: "-\($0)%", - color: UIColor(rgb: 0xfa4846) + color: .red ) }, - isLoading: self.selectedPremiumGift == product.id + isLoading: self.inProgressPremiumGift == product.id ) ), effectAlignment: .center, action: { [weak self] in - self?.selectedPremiumGift = product.id - self?.state?.updated() - - Queue.mainQueue().after(4.0, { - self?.selectedPremiumGift = nil - self?.state?.updated() - }) + self?.buyPremium(product) }, animateAlpha: false ) @@ -658,8 +808,13 @@ final class GiftOptionsScreenComponent: Component { return nil } }, - tapAction: { _, _ in - + tapAction: { [weak self] _, _ in + guard let self, let component = self.component, let environment = self.environment else { + return + } + let introController = component.context.sharedContext.makeStarsIntroScreen(context: component.context) + introController.navigationPresentation = .modal + environment.controller()?.push(introController) } )), environment: {}, @@ -859,11 +1014,17 @@ final class GiftOptionsScreenComponent: Component { public final class GiftOptionsScreen: ViewControllerComponentContainer, GiftOptionsScreenProtocol { private let context: AccountContext - public init(context: AccountContext, peerId: EnginePeer.Id, premiumOptions: [CachedPremiumGiftOption]) { + public init( + context: AccountContext, + starsContext: StarsContext, + peerId: EnginePeer.Id, + premiumOptions: [CachedPremiumGiftOption] + ) { self.context = context super.init(context: context, component: GiftOptionsScreenComponent( context: context, + starsContext: starsContext, peerId: peerId, premiumOptions: premiumOptions ), navigationBarAppearance: .none, theme: .default, updatedPresentationData: nil) diff --git a/submodules/TelegramUI/Components/Gifts/GiftSetupScreen/Sources/ChatGiftPreviewItem.swift b/submodules/TelegramUI/Components/Gifts/GiftSetupScreen/Sources/ChatGiftPreviewItem.swift index 8a37f061b5..35f48321e7 100644 --- a/submodules/TelegramUI/Components/Gifts/GiftSetupScreen/Sources/ChatGiftPreviewItem.swift +++ b/submodules/TelegramUI/Components/Gifts/GiftSetupScreen/Sources/ChatGiftPreviewItem.swift @@ -148,6 +148,8 @@ final class ChatGiftPreviewItemNode: ListViewItemNode { private let disposable = MetaDisposable() + private var initialBubbleHeight: CGFloat? + init() { self.topStripeNode = ASDisplayNode() self.topStripeNode.isLayerBacked = true @@ -235,14 +237,13 @@ final class ChatGiftPreviewItemNode: ListViewItemNode { }) itemNode!.isUserInteractionEnabled = false messageNodes.append(itemNode!) + + self.initialBubbleHeight = itemNode?.frame.height } nodes = messageNodes } var contentSize = CGSize(width: params.width, height: 4.0 + 4.0) -// for node in nodes { -// contentSize.height += node.frame.size.height -// } contentSize.height = 346.0 insets = itemListNeighborsGroupedInsets(neighbors, params) if params.width <= 320.0 { @@ -269,7 +270,13 @@ final class ChatGiftPreviewItemNode: ListViewItemNode { if node.supernode == nil { strongSelf.containerNode.addSubnode(node) } - node.updateFrame(CGRect(origin: CGPoint(x: 0.0, y: floor((contentSize.height - node.frame.size.height) / 2.0)), size: node.frame.size), within: layoutSize) + let bubbleHeight: CGFloat + if let initialBubbleHeight = strongSelf.initialBubbleHeight { + bubbleHeight = max(node.frame.height, initialBubbleHeight) + } else { + bubbleHeight = node.frame.height + } + node.updateFrame(CGRect(origin: CGPoint(x: 0.0, y: floor((contentSize.height - bubbleHeight) / 2.0)), size: node.frame.size), within: layoutSize) //topOffset += node.frame.size.height } diff --git a/submodules/TelegramUI/Components/Gifts/GiftSetupScreen/Sources/GiftSetupScreen.swift b/submodules/TelegramUI/Components/Gifts/GiftSetupScreen/Sources/GiftSetupScreen.swift index f26cb3deb2..805b8aaf6c 100644 --- a/submodules/TelegramUI/Components/Gifts/GiftSetupScreen/Sources/GiftSetupScreen.swift +++ b/submodules/TelegramUI/Components/Gifts/GiftSetupScreen/Sources/GiftSetupScreen.swift @@ -90,6 +90,14 @@ final class GiftSetupScreenComponent: Component { private var starImage: (UIImage, PresentationTheme)? + private var optionsDisposable: Disposable? + private(set) var options: [StarsTopUpOption] = [] { + didSet { + self.optionsPromise.set(self.options) + } + } + private let optionsPromise = ValuePromise<[StarsTopUpOption]?>(nil) + override init(frame: CGRect) { self.scrollView = ScrollView() self.scrollView.showsVerticalScrollIndicator = true @@ -159,45 +167,77 @@ final class GiftSetupScreenComponent: Component { } func proceed() { - guard let component = self.component else { + guard let component = self.component, let starsContext = component.context.starsContext, let starsState = starsContext.currentState else { return } - let source: BotPaymentInvoiceSource = .starGift(hideName: self.hideName, peerId: component.peerId, giftId: component.gift.id, text: self.textInputState.text.string, entities: []) - let inputData = BotCheckoutController.InputData.fetch(context: component.context, source: source) - |> map(Optional.init) - |> `catch` { _ -> Signal in - return .single(nil) - } - let _ = (inputData - |> deliverOnMainQueue).startStandalone(next: { [weak self] inputData in - guard let inputData else { + let proceed = { [weak self] in + guard let self else { return } - let _ = (component.context.engine.payments.sendStarsPaymentForm(formId: inputData.form.id, source: source) - |> deliverOnMainQueue).start(next: { [weak self] result in - guard let self, let controller = self.environment?.controller(), let navigationController = controller.navigationController as? NavigationController else { + let source: BotPaymentInvoiceSource = .starGift(hideName: self.hideName, peerId: component.peerId, giftId: component.gift.id, text: self.textInputState.text.string, entities: []) + let inputData = BotCheckoutController.InputData.fetch(context: component.context, source: source) + |> map(Optional.init) + |> `catch` { _ -> Signal in + return .single(nil) + } + + let _ = (inputData + |> deliverOnMainQueue).startStandalone(next: { [weak self] inputData in + guard let inputData else { return } - - var controllers = navigationController.viewControllers - controllers = controllers.filter { !($0 is GiftSetupScreen) && !($0 is GiftOptionsScreenProtocol) } - var foundController = false - for controller in controllers.reversed() { - if let chatController = controller as? ChatController, case .peer(id: component.peerId) = chatController.chatLocation { - chatController.hintPlayNextOutgoingGift() - foundController = true - break + let _ = (component.context.engine.payments.sendStarsPaymentForm(formId: inputData.form.id, source: source) + |> deliverOnMainQueue).start(next: { [weak self] result in + guard let self, let controller = self.environment?.controller(), let navigationController = controller.navigationController as? NavigationController else { + return } - } - if !foundController { - let chatController = component.context.sharedContext.makeChatController(context: component.context, chatLocation: .peer(id: component.peerId), subject: nil, botStart: nil, mode: .standard(.default), params: nil) - chatController.hintPlayNextOutgoingGift() - controllers.append(chatController) - } - navigationController.setViewControllers(controllers, animated: true) + + var controllers = navigationController.viewControllers + controllers = controllers.filter { !($0 is GiftSetupScreen) && !($0 is GiftOptionsScreenProtocol) } + var foundController = false + for controller in controllers.reversed() { + if let chatController = controller as? ChatController, case .peer(id: component.peerId) = chatController.chatLocation { + chatController.hintPlayNextOutgoingGift() + foundController = true + break + } + } + if !foundController { + let chatController = component.context.sharedContext.makeChatController(context: component.context, chatLocation: .peer(id: component.peerId), subject: nil, botStart: nil, mode: .standard(.default), params: nil) + chatController.hintPlayNextOutgoingGift() + controllers.append(chatController) + } + navigationController.setViewControllers(controllers, animated: true) + }) }) - }) + } + + if starsState.balance < component.gift.price { + let _ = (self.optionsPromise.get() + |> filter { $0 != nil } + |> take(1) + |> deliverOnMainQueue).startStandalone(next: { [weak self] options in + guard let self, let component = self.component, let controller = self.environment?.controller() else { + return + } + let purchaseController = component.context.sharedContext.makeStarsPurchaseScreen( + context: component.context, + starsContext: starsContext, + options: options ?? [], + purpose: .starGift(peerId: component.peerId, requiredStars: component.gift.price), + completion: { [weak starsContext] stars in + starsContext?.add(balance: stars) + Queue.mainQueue().after(0.1) { + proceed() + } + } + ) + controller.push(purchaseController) + }) + } else { + proceed() + } } func update(component: GiftSetupScreenComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { diff --git a/submodules/TelegramUI/Components/Gifts/GiftViewScreen/BUILD b/submodules/TelegramUI/Components/Gifts/GiftViewScreen/BUILD index 342a21824d..a0742ca169 100644 --- a/submodules/TelegramUI/Components/Gifts/GiftViewScreen/BUILD +++ b/submodules/TelegramUI/Components/Gifts/GiftViewScreen/BUILD @@ -25,6 +25,7 @@ swift_library( "//submodules/Components/ViewControllerComponent", "//submodules/Components/BundleIconComponent", "//submodules/Components/MultilineTextComponent", + "//submodules/Components/MultilineTextWithEntitiesComponent", "//submodules/Components/BalancedTextComponent", "//submodules/TelegramUI/Components/ListSectionComponent", "//submodules/TelegramUI/Components/ListActionItemComponent", @@ -36,6 +37,7 @@ swift_library( "//submodules/Components/SolidRoundedButtonComponent", "//submodules/TelegramUI/Components/Stars/StarsAvatarComponent", "//submodules/TelegramUI/Components/EmojiTextAttachmentView", + "//submodules/TelegramUI/Components/Gifts/GiftAnimationComponent", "//submodules/UndoUI", ], visibility = [ diff --git a/submodules/TelegramUI/Components/Gifts/GiftViewScreen/Sources/GiftViewScreen.swift b/submodules/TelegramUI/Components/Gifts/GiftViewScreen/Sources/GiftViewScreen.swift index 28b3467edd..d81772d75b 100644 --- a/submodules/TelegramUI/Components/Gifts/GiftViewScreen/Sources/GiftViewScreen.swift +++ b/submodules/TelegramUI/Components/Gifts/GiftViewScreen/Sources/GiftViewScreen.swift @@ -12,6 +12,7 @@ import ComponentFlow import ViewControllerComponent import SheetComponent import MultilineTextComponent +import MultilineTextWithEntitiesComponent import BundleIconComponent import SolidRoundedButtonComponent import Markdown @@ -22,6 +23,7 @@ import TelegramStringFormatting import StarsAvatarComponent import EmojiTextAttachmentView import UndoUI +import GiftAnimationComponent private final class GiftViewSheetContent: CombinedComponent { typealias EnvironmentType = ViewControllerComponentContainer.Environment @@ -32,6 +34,7 @@ private final class GiftViewSheetContent: CombinedComponent { let openPeer: (EnginePeer) -> Void let updateSavedToProfile: (Bool) -> Void let convertToStars: () -> Void + let openStarsIntro: () -> Void init( context: AccountContext, @@ -39,7 +42,8 @@ private final class GiftViewSheetContent: CombinedComponent { cancel: @escaping (Bool) -> Void, openPeer: @escaping (EnginePeer) -> Void, updateSavedToProfile: @escaping (Bool) -> Void, - convertToStars: @escaping () -> Void + convertToStars: @escaping () -> Void, + openStarsIntro: @escaping () -> Void ) { self.context = context self.subject = subject @@ -47,6 +51,7 @@ private final class GiftViewSheetContent: CombinedComponent { self.openPeer = openPeer self.updateSavedToProfile = updateSavedToProfile self.convertToStars = convertToStars + self.openStarsIntro = openStarsIntro } static func ==(lhs: GiftViewSheetContent, rhs: GiftViewSheetContent) -> Bool { @@ -176,7 +181,7 @@ private final class GiftViewSheetContent: CombinedComponent { limitNumber = arguments.gift.availability?.remains limitTotal = arguments.gift.availability?.total convertStars = arguments.convertStars - incoming = arguments.incoming + incoming = arguments.incoming || arguments.peerId == component.context.account.peerId savedToProfile = arguments.savedToProfile converted = arguments.converted } else { @@ -259,7 +264,13 @@ private final class GiftViewSheetContent: CombinedComponent { ) let tableFont = Font.regular(15.0) + let tableBoldFont = Font.semibold(15.0) + let tableItalicFont = Font.italic(15.0) + let tableBoldItalicFont = Font.semiboldItalic(15.0) + let tableMonospaceFont = Font.monospace(15.0) + let tableTextColor = theme.list.itemPrimaryTextColor + let tableLinkColor = theme.list.itemAccentColor var tableItems: [TableComponent.Item] = [] if let peerId = component.subject.arguments?.peerId, let peer = state.peerMap[peerId] { @@ -291,6 +302,18 @@ private final class GiftViewSheetContent: CombinedComponent { ) ) )) + } else { + tableItems.append(.init( + id: "from", + title: strings.Stars_Transaction_From, + component: AnyComponent( + PeerCellComponent( + context: component.context, + theme: theme, + peer: nil + ) + ) + )) } tableItems.append(.init( @@ -312,11 +335,19 @@ private final class GiftViewSheetContent: CombinedComponent { } if let text { + let attributedText = stringWithAppliedEntities(text, entities: entities ?? [], baseColor: tableTextColor, linkColor: tableLinkColor, baseFont: tableFont, linkFont: tableFont, boldFont: tableBoldFont, italicFont: tableItalicFont, boldItalicFont: tableBoldItalicFont, fixedFont: tableMonospaceFont, blockQuoteFont: tableFont, message: nil) + tableItems.append(.init( id: "text", title: nil, component: AnyComponent( - MultilineTextComponent(text: .plain(NSAttributedString(string: text, font: tableFont, textColor: tableTextColor))) + MultilineTextWithEntitiesComponent( + context: component.context, + animationCache: component.context.animationCache, + animationRenderer: component.context.animationRenderer, + placeholderColor: theme.list.mediaPlaceholderColor, + text: .plain(attributedText) + ) ) )) } @@ -331,40 +362,7 @@ private final class GiftViewSheetContent: CombinedComponent { ) let textFont = Font.regular(15.0) -// let boldTextFont = Font.semibold(15.0) -// let textColor = theme.actionSheet.secondaryTextColor let linkColor = theme.actionSheet.controlAccentColor -// let destructiveColor = theme.actionSheet.destructiveActionTextColor -// let markdownAttributes = MarkdownAttributes(body: MarkdownAttributeSet(font: textFont, textColor: textColor), bold: MarkdownAttributeSet(font: boldTextFont, textColor: textColor), link: MarkdownAttributeSet(font: textFont, textColor: linkColor), linkAttribute: { contents in -// return (TelegramTextAttributes.URL, contents) -// }) -// let additional = additional.update( -// component: BalancedTextComponent( -// text: .markdown(text: additionalText, attributes: markdownAttributes), -// horizontalAlignment: .center, -// maximumNumberOfLines: 0, -// lineSpacing: 0.2, -// highlightColor: linkColor.withAlphaComponent(0.2), -// highlightAction: { attributes in -// if let _ = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.URL)] { -// return NSAttributedString.Key(rawValue: TelegramTextAttributes.URL) -// } else { -// return nil -// } -// }, -// tapAction: { attributes, _ in -// if let controller = controller() as? GiftViewScreen, let navigationController = controller.navigationController as? NavigationController { -// let presentationData = component.context.sharedContext.currentPresentationData.with { $0 } -// component.context.sharedContext.openExternalUrl(context: component.context, urlContext: .generic, url: strings.Stars_Transaction_Terms_URL, forceExternal: false, presentationData: presentationData, navigationController: navigationController, dismissInput: {}) -// component.cancel(true) -// } -// } -// ), -// availableSize: CGSize(width: context.availableSize.width - textSideInset * 2.0, height: context.availableSize.height), -// transition: .immediate -// ) - - context.add(title .position(CGPoint(x: context.availableSize.width / 2.0, y: 177.0)) @@ -417,7 +415,7 @@ private final class GiftViewSheetContent: CombinedComponent { } }, tapAction: { _, _ in - + component.openStarsIntro() } ), availableSize: CGSize(width: context.availableSize.width - sideInset * 2.0 - 60.0, height: CGFloat.greatestFiniteMagnitude), @@ -467,31 +465,7 @@ private final class GiftViewSheetContent: CombinedComponent { .position(CGPoint(x: context.availableSize.width / 2.0, y: originY + table.size.height / 2.0)) ) originY += table.size.height + 23.0 - -// context.add(additional -// .position(CGPoint(x: context.availableSize.width / 2.0, y: originY + additional.size.height / 2.0)) -// ) -// originY += additional.size.height + 23.0 - -// if let statusText { -// originY += 7.0 -// let status = status.update( -// component: BalancedTextComponent( -// text: .plain(NSAttributedString(string: statusText, font: textFont, textColor: statusIsDestructive ? destructiveColor : textColor)), -// horizontalAlignment: .center, -// maximumNumberOfLines: 0, -// lineSpacing: 0.1 -// ), -// availableSize: CGSize(width: context.availableSize.width - textSideInset * 2.0, height: context.availableSize.height), -// transition: .immediate -// ) -// context.add(status -// .position(CGPoint(x: context.availableSize.width / 2.0, y: originY + status.size.height / 2.0)) -// ) -// originY += status.size.height + (statusIsDestructive ? 23.0 : 13.0) -// } - - + if incoming && !converted { let button = button.update( component: SolidRoundedButtonComponent( @@ -545,6 +519,33 @@ private final class GiftViewSheetContent: CombinedComponent { .position(CGPoint(x: secondaryButtonFrame.midX, y: secondaryButtonFrame.midY)) ) originY += secondaryButton.size.height + } else { + let button = button.update( + component: SolidRoundedButtonComponent( + title: strings.Common_OK, + theme: SolidRoundedButtonComponent.Theme(theme: theme), + font: .bold, + fontSize: 17.0, + height: 50.0, + cornerRadius: 10.0, + gloss: false, + iconName: nil, + animationName: nil, + iconPosition: .left, + isLoading: state.inProgress, + action: { + component.cancel(true) + } + ), + availableSize: CGSize(width: context.availableSize.width - sideInset * 2.0, height: 50.0), + transition: context.transition + ) + let buttonFrame = CGRect(origin: CGPoint(x: sideInset, y: originY), size: button.size) + context.add(button + .position(CGPoint(x: buttonFrame.midX, y: buttonFrame.midY)) + ) + originY += button.size.height + originY += 7.0 } context.add(closeButton @@ -566,19 +567,22 @@ private final class GiftViewSheetComponent: CombinedComponent { let openPeer: (EnginePeer) -> Void let updateSavedToProfile: (Bool) -> Void let convertToStars: () -> Void + let openStarsIntro: () -> Void init( context: AccountContext, subject: GiftViewScreen.Subject, openPeer: @escaping (EnginePeer) -> Void, updateSavedToProfile: @escaping (Bool) -> Void, - convertToStars: @escaping () -> Void + convertToStars: @escaping () -> Void, + openStarsIntro: @escaping () -> Void ) { self.context = context self.subject = subject self.openPeer = openPeer self.updateSavedToProfile = updateSavedToProfile self.convertToStars = convertToStars + self.openStarsIntro = openStarsIntro } static func ==(lhs: GiftViewSheetComponent, rhs: GiftViewSheetComponent) -> Bool { @@ -620,7 +624,8 @@ private final class GiftViewSheetComponent: CombinedComponent { }, openPeer: context.component.openPeer, updateSavedToProfile: context.component.updateSavedToProfile, - convertToStars: context.component.convertToStars + convertToStars: context.component.convertToStars, + openStarsIntro: context.component.openStarsIntro )), backgroundColor: .color(environment.theme.actionSheet.opaqueItemBackgroundColor), followContentSizeChanges: true, @@ -691,14 +696,14 @@ public class GiftViewScreen: ViewControllerComponentContainer { case message(EngineMessage) case profileGift(EnginePeer.Id, ProfileGiftsContext.State.StarGift) - var arguments: (peerId: EnginePeer.Id, messageId: EngineMessage.Id?, incoming: Bool, gift: StarGift, convertStars: Int64, text: String?, entities: [MessageTextEntity]?, nameHidden: Bool, savedToProfile: Bool, converted: Bool)? { + var arguments: (peerId: EnginePeer.Id, fromPeerName: String?, messageId: EngineMessage.Id?, incoming: Bool, gift: StarGift, convertStars: Int64, text: String?, entities: [MessageTextEntity]?, nameHidden: Bool, savedToProfile: Bool, converted: Bool)? { switch self { case let .message(message): if let action = message.media.first(where: { $0 is TelegramMediaAction }) as? TelegramMediaAction, case let .starGift(gift, convertStars, text, entities, nameHidden, savedToProfile, converted) = action.action { - return (message.id.peerId, message.id, message.flags.contains(.Incoming), gift, convertStars, text, entities, nameHidden, savedToProfile, converted) + return (message.id.peerId, message.author?.compactDisplayTitle, message.id, message.flags.contains(.Incoming), gift, convertStars, text, entities, nameHidden, savedToProfile, converted) } case let .profileGift(peerId, gift): - return (peerId, gift.messageId, false, gift.gift, gift.convertStars ?? 0, gift.text, gift.entities, gift.nameHidden, true, false) + return (peerId, gift.fromPeer?.compactDisplayTitle, gift.messageId, false, gift.gift, gift.convertStars ?? 0, gift.text, gift.entities, gift.nameHidden, gift.savedToProfile, false) } return nil } @@ -712,13 +717,17 @@ public class GiftViewScreen: ViewControllerComponentContainer { public init( context: AccountContext, subject: GiftViewScreen.Subject, - forceDark: Bool = false + forceDark: Bool = false, + updateSavedToProfile: ((Bool) -> Void)? = nil, + convertToStars: (() -> Void)? = nil ) { self.context = context var openPeerImpl: ((EnginePeer) -> Void)? var updateSavedToProfileImpl: ((Bool) -> Void)? var convertToStarsImpl: (() -> Void)? + var openStarsIntroImpl: (() -> Void)? + super.init( context: context, component: GiftViewSheetComponent( @@ -732,6 +741,9 @@ public class GiftViewScreen: ViewControllerComponentContainer { }, convertToStars: { convertToStarsImpl?() + }, + openStarsIntro: { + openStarsIntroImpl?() } ), navigationBarAppearance: .none, @@ -764,8 +776,12 @@ public class GiftViewScreen: ViewControllerComponentContainer { guard let self, let arguments = subject.arguments, let messageId = arguments.messageId else { return } - let _ = (context.engine.payments.updateStarGiftAddedToProfile(messageId: messageId, added: added) - |> deliverOnMainQueue).startStandalone() + if let updateSavedToProfile { + updateSavedToProfile(added) + } else { + let _ = (context.engine.payments.updateStarGiftAddedToProfile(messageId: messageId, added: added) + |> deliverOnMainQueue).startStandalone() + } self.dismissAnimated() @@ -774,9 +790,14 @@ public class GiftViewScreen: ViewControllerComponentContainer { if let lastController = navigationController.viewControllers.last as? ViewController { let resultController = UndoOverlayController( presentationData: presentationData, - content: .sticker(context: context, file: arguments.gift.file, loop: false, title: "Gift Saved to Profile", text: "The gift is now displayed in your profile.", undoText: nil, customAction: nil), + content: .sticker(context: context, file: arguments.gift.file, loop: false, title: added ? "Gift Saved to Profile" : "Gift Removed from Profile", text: added ? "The gift is now displayed in [your profile]()." : "The gift is no longer displayed in [your profile]().", undoText: nil, customAction: nil), elevatedLayout: lastController is ChatController, - action: { _ in return true} + action: { action in + if case .info = action { + + } + return true + } ) lastController.present(resultController, in: .window(.root)) } @@ -785,19 +806,22 @@ public class GiftViewScreen: ViewControllerComponentContainer { } convertToStarsImpl = { [weak self] in - guard let self, case let .message(message) = subject, let arguments = subject.arguments, let messageId = arguments.messageId, let navigationController = self.navigationController as? NavigationController else { + guard let self, let arguments = subject.arguments, let messageId = arguments.messageId, let fromPeerName = arguments.fromPeerName, let navigationController = self.navigationController as? NavigationController else { return } let controller = textAlertController( context: self.context, title: "Convert Gift to Stars", - text: "Do you want to convert this gift from **\(message.author?.compactDisplayTitle ?? "")** to **\(arguments.convertStars) Stars**?\n\nThis action cannot be undone.", + text: "Do you want to convert this gift from **\(fromPeerName)** to **\(arguments.convertStars) Stars**?\n\nThis action cannot be undone.", actions: [ TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_Cancel, action: {}), TextAlertAction(type: .defaultAction, title: "Convert", action: { [weak self, weak navigationController] in - let _ = (context.engine.payments.convertStarGift(messageId: messageId) - |> deliverOnMainQueue).startStandalone() - + if let convertToStars { + convertToStars() + } else { + let _ = (context.engine.payments.convertStarGift(messageId: messageId) + |> deliverOnMainQueue).startStandalone() + } self?.dismissAnimated() if let navigationController { @@ -827,6 +851,14 @@ public class GiftViewScreen: ViewControllerComponentContainer { ) self.present(controller, in: .window(.root)) } + openStarsIntroImpl = { [weak self] in + guard let self else { + return + } + let introController = context.sharedContext.makeStarsIntroScreen(context: context) + introController.navigationPresentation = .modal + self.push(introController) + } } required public init(coder aDecoder: NSCoder) { @@ -1130,14 +1162,18 @@ private final class PeerCellComponent: Component { } final class View: UIView { - private let avatar = ComponentView() + private let avatarNode: AvatarNode private let text = ComponentView() private var component: PeerCellComponent? private weak var state: EmptyComponentState? override init(frame: CGRect) { + self.avatarNode = AvatarNode(font: avatarPlaceholderFont(size: 8.0)) + super.init(frame: frame) + + self.addSubnode(self.avatarNode) } required init?(coder: NSCoder) { @@ -1152,29 +1188,25 @@ private final class PeerCellComponent: Component { let spacing: CGFloat = 6.0 let peerName: String - let peer: StarsContext.State.Transaction.Peer + let avatarOverride: AvatarNodeImageOverride? if let peerValue = component.peer { peerName = peerValue.compactDisplayTitle - peer = .peer(peerValue) + avatarOverride = nil } else { + //TODO:localize peerName = "Hidden Name" - peer = .fragment + avatarOverride = .anonymousSavedMessagesIcon(isColored: true) } - let avatarNaturalSize = self.avatar.update( - transition: .immediate, - component: AnyComponent( - StarsAvatarComponent(context: component.context, theme: component.theme, peer: peer, photo: nil, media: [], backgroundColor: .clear) - ), - environment: {}, - containerSize: CGSize(width: 40.0, height: 40.0) - ) + let avatarNaturalSize = CGSize(width: 40.0, height: 40.0) + self.avatarNode.setPeer(context: component.context, theme: component.theme, peer: component.peer, overrideImage: avatarOverride) + self.avatarNode.bounds = CGRect(origin: .zero, size: avatarNaturalSize) let textSize = self.text.update( transition: .immediate, component: AnyComponent( MultilineTextComponent( - text: .plain(NSAttributedString(string: peerName, font: Font.regular(15.0), textColor: component.theme.list.itemAccentColor, paragraphAlignment: .left)) + text: .plain(NSAttributedString(string: peerName, font: Font.regular(15.0), textColor: component.peer != nil ? component.theme.list.itemAccentColor : component.theme.list.itemPrimaryTextColor, paragraphAlignment: .left)) ) ), environment: {}, @@ -1184,15 +1216,7 @@ private final class PeerCellComponent: Component { let size = CGSize(width: avatarSize.width + textSize.width + spacing, height: textSize.height) let avatarFrame = CGRect(origin: CGPoint(x: 0.0, y: floorToScreenPixels((size.height - avatarSize.height) / 2.0)), size: avatarSize) - - if let view = self.avatar.view { - if view.superview == nil { - self.addSubview(view) - } - let scale = avatarSize.width / avatarNaturalSize.width - view.transform = CGAffineTransform(scaleX: scale, y: scale) - view.frame = avatarFrame - } + self.avatarNode.frame = avatarFrame if let view = self.text.view { if view.superview == nil { @@ -1235,91 +1259,3 @@ private func generateCloseButtonImage(backgroundColor: UIColor, foregroundColor: context.strokePath() }) } - -private final class GiftAnimationComponent: Component { - let context: AccountContext - let theme: PresentationTheme - let file: TelegramMediaFile? - - public init( - context: AccountContext, - theme: PresentationTheme, - file: TelegramMediaFile? - ) { - self.context = context - self.theme = theme - self.file = file - } - - public static func ==(lhs: GiftAnimationComponent, rhs: GiftAnimationComponent) -> Bool { - if lhs.context !== rhs.context { - return false - } - if lhs.theme !== rhs.theme { - return false - } - if lhs.file != rhs.file { - return false - } - return true - } - - public final class View: UIView { - private var component: GiftAnimationComponent? - private weak var componentState: EmptyComponentState? - - private var animationLayer: InlineStickerItemLayer? - - override init(frame: CGRect) { - super.init(frame: frame) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - func update(component: GiftAnimationComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { - self.component = component - self.componentState = state - - let emoji = ChatTextInputTextCustomEmojiAttribute( - interactivelySelectedFromPackId: nil, - fileId: component.file?.fileId.id ?? 0, - file: component.file - ) - - let iconSize = availableSize - if self.animationLayer == nil { - let animationLayer = InlineStickerItemLayer( - context: .account(component.context), - userLocation: .other, - attemptSynchronousLoad: false, - emoji: emoji, - file: component.file, - cache: component.context.animationCache, - renderer: component.context.animationRenderer, - unique: true, - placeholderColor: component.theme.list.mediaPlaceholderColor, - pointSize: CGSize(width: iconSize.width * 1.2, height: iconSize.height * 1.2), - loopCount: 1 - ) - animationLayer.isVisibleForAnimations = true - self.animationLayer = animationLayer - self.layer.addSublayer(animationLayer) - } - if let animationLayer = self.animationLayer { - transition.setFrame(layer: animationLayer, frame: CGRect(origin: .zero, size: iconSize)) - } - - return iconSize - } - } - - public func makeView() -> View { - return View(frame: CGRect()) - } - - public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { - return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) - } -} diff --git a/submodules/TelegramUI/Components/NavigationStackComponent/Sources/NavigationStackComponent.swift b/submodules/TelegramUI/Components/NavigationStackComponent/Sources/NavigationStackComponent.swift index e7a795459f..3a5aabd841 100644 --- a/submodules/TelegramUI/Components/NavigationStackComponent/Sources/NavigationStackComponent.swift +++ b/submodules/TelegramUI/Components/NavigationStackComponent/Sources/NavigationStackComponent.swift @@ -85,13 +85,16 @@ public final class NavigationStackComponent: Compon } public let items: [AnyComponentWithIdentity] + public let clipContent: Bool public let requestPop: () -> Void public init( items: [AnyComponentWithIdentity], + clipContent: Bool = true, requestPop: @escaping () -> Void ) { self.items = items + self.clipContent = clipContent self.requestPop = requestPop } @@ -99,6 +102,9 @@ public final class NavigationStackComponent: Compon if lhs.items != rhs.items { return false } + if lhs.clipContent != rhs.clipContent { + return false + } return true } @@ -198,7 +204,7 @@ public final class NavigationStackComponent: Compon } else { itemTransition = itemTransition.withAnimation(.none) itemView = ItemView() - itemView.clipsToBounds = true + itemView.clipsToBounds = component.clipContent self.itemViews[itemId] = itemView itemView.contents.parentState = state } diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/Panes/PeerInfoRecommendedChannelsPane.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/Panes/PeerInfoRecommendedChannelsPane.swift index 991453a817..2dd9c7b988 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/Panes/PeerInfoRecommendedChannelsPane.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/Panes/PeerInfoRecommendedChannelsPane.swift @@ -100,7 +100,6 @@ final class PeerInfoRecommendedChannelsPaneNode: ASDisplayNode, PeerInfoPaneNode private let listNode: ListView private var currentEntries: [RecommendedChannelsListEntry] = [] private var currentState: (RecommendedChannels?, Bool)? - private var canLoadMore: Bool = false private var enqueuedTransactions: [RecommendedChannelsListTransaction] = [] private var unlockBackground: UIImageView? diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoAvatarTransformContainerNode.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoAvatarTransformContainerNode.swift index 827ec82b64..a07718b06f 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoAvatarTransformContainerNode.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoAvatarTransformContainerNode.swift @@ -333,7 +333,7 @@ final class PeerInfoAvatarTransformContainerNode: ASDisplayNode { self.videoNode?.removeFromSupernode() let mediaManager = self.context.sharedContext.mediaManager - let videoNode = UniversalVideoNode(postbox: self.context.account.postbox, audioSession: mediaManager.audioSession, manager: mediaManager.universalVideoManager, decoration: GalleryVideoDecoration(), content: videoContent, priority: .embedded) + let videoNode = UniversalVideoNode(accountId: self.context.account.id, postbox: self.context.account.postbox, audioSession: mediaManager.audioSession, manager: mediaManager.universalVideoManager, decoration: GalleryVideoDecoration(), content: videoContent, priority: .embedded) videoNode.isUserInteractionEnabled = false videoNode.isHidden = true diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoEditingAvatarNode.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoEditingAvatarNode.swift index ea1c47a629..9d34dd106c 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoEditingAvatarNode.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoEditingAvatarNode.swift @@ -168,7 +168,7 @@ final class PeerInfoEditingAvatarNode: ASDisplayNode { self.videoNode?.removeFromSupernode() let mediaManager = self.context.sharedContext.mediaManager - let videoNode = UniversalVideoNode(postbox: self.context.account.postbox, audioSession: mediaManager.audioSession, manager: mediaManager.universalVideoManager, decoration: GalleryVideoDecoration(), content: videoContent, priority: .gallery) + let videoNode = UniversalVideoNode(accountId: self.context.account.id, postbox: self.context.account.postbox, audioSession: mediaManager.audioSession, manager: mediaManager.universalVideoManager, decoration: GalleryVideoDecoration(), content: videoContent, priority: .gallery) videoNode.isUserInteractionEnabled = false self.videoStartTimestamp = video.representation.startTimestamp self.videoContent = videoContent diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift index 57410dcdee..e9a5f9f5c9 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift @@ -999,7 +999,8 @@ private func settingsItems(data: PeerInfoScreenData?, context: AccountContext, p items[.payment]!.append(PeerInfoScreenDisclosureItem(id: 103, label: .text(""), additionalBadgeLabel: presentationData.strings.Settings_New, text: presentationData.strings.Settings_Business, icon: PresentationResourcesSettings.business, action: { interaction.openSettings(.businessSetup) })) - items[.payment]!.append(PeerInfoScreenDisclosureItem(id: 104, label: .text(""), text: presentationData.strings.Settings_PremiumGift, icon: PresentationResourcesSettings.premiumGift, action: { + //TODO:localize + items[.payment]!.append(PeerInfoScreenDisclosureItem(id: 104, label: .text(""), text: "Send a Gift", icon: PresentationResourcesSettings.premiumGift, action: { interaction.openSettings(.premiumGift) })) } @@ -6098,8 +6099,9 @@ final class PeerInfoScreenNode: ViewControllerTracingNode, PeerInfoScreenNodePro }))) } - if strongSelf.peerId.namespace == Namespaces.Peer.CloudUser, !user.isDeleted && user.botInfo == nil && !user.flags.contains(.isSupport), let cachedData = data.cachedData as? CachedUserData, !cachedData.premiumGiftOptions.isEmpty { - items.append(.action(ContextMenuActionItem(text: presentationData.strings.PeerInfo_GiftPremium, icon: { theme in + if strongSelf.peerId.namespace == Namespaces.Peer.CloudUser, !user.isDeleted && user.botInfo == nil && !user.flags.contains(.isSupport) { + //TODO:localize + items.append(.action(ContextMenuActionItem(text: "Send a Gift", icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Gift"), color: theme.contextMenu.primaryColor) }, action: { [weak self] _, f in f(.dismissWithoutContent) @@ -7068,7 +7070,10 @@ final class PeerInfoScreenNode: ViewControllerTracingNode, PeerInfoScreenNodePro } private func scheduleGroupCall() { - self.context.scheduleGroupCall(peerId: self.peerId) + guard let controller = self.controller else { + return + } + self.context.scheduleGroupCall(peerId: self.peerId, parentController: controller) } private func createExternalStream(credentialsPromise: Promise?) { @@ -11639,11 +11644,14 @@ final class PeerInfoScreenNode: ViewControllerTracingNode, PeerInfoScreenNodePro return } strongSelf.view.endEditing(true) + strongSelf.controller?.present(peerReportOptionsController(context: strongSelf.context, subject: .messages(Array(messageIds).sorted()), passthrough: false, present: { c, a in self?.controller?.present(c, in: .window(.root), with: a) }, push: { c in self?.controller?.push(c) }, completion: { _, _ in }), in: .window(.root)) + + }, displayCopyProtectionTip: { [weak self] node, save in if let strongSelf = self, let peer = strongSelf.data?.peer, let messageIds = strongSelf.state.selectedMessageIds, !messageIds.isEmpty { let _ = (strongSelf.context.engine.data.get(EngineDataMap( diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/BUILD b/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/BUILD index 6517691155..27132baef3 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/BUILD +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/BUILD @@ -51,6 +51,7 @@ swift_library( "//submodules/TelegramUI/Components/Gifts/GiftItemComponent", "//submodules/TelegramUI/Components/Gifts/GiftViewScreen", "//submodules/TelegramUI/Components/ButtonComponent", + "//submodules/Components/BalancedTextComponent", ], visibility = [ "//visibility:public", diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoGiftsPaneNode.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoGiftsPaneNode.swift index 9a4c7f8122..f99b353467 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoGiftsPaneNode.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoGiftsPaneNode.swift @@ -15,12 +15,13 @@ import MergeLists import ItemListUI import ChatControllerInteraction import MultilineTextComponent +import BalancedTextComponent import Markdown import PeerInfoPaneNode import GiftItemComponent import PlainButtonComponent import GiftViewScreen -import ButtonComponent +import SolidRoundedButtonNode public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScrollViewDelegate { private let context: AccountContext @@ -37,6 +38,10 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr private let backgroundNode: ASDisplayNode private let scrollNode: ASScrollNode + private var unlockBackground: UIImageView? + private var unlockText: ComponentView? + private var unlockButton: SolidRoundedButtonNode? + private var currentParams: (size: CGSize, sideInset: CGFloat, bottomInset: CGFloat, isScrollingLockedAtTop: Bool, presentationData: PresentationData)? private var theme: PresentationTheme? @@ -82,7 +87,8 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr guard let self else { return } - self.statusPromise.set(.single(PeerInfoStatusData(text: "\(state.count ?? 0) gifts", isActivity: true, key: .gifts))) + let presentationData = self.context.sharedContext.currentPresentationData.with { $0 } + self.statusPromise.set(.single(PeerInfoStatusData(text: presentationData.strings.SharedMedia_GiftCount(state.count ?? 0), isActivity: true, key: .gifts))) self.starsProducts = state.gifts if !self.didSetReady { @@ -149,6 +155,13 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr } if isVisible { + let ribbonText: String? + if let availability = product.gift.availability { + //TODO:localize + ribbonText = "1 of \(compactNumericCountString(Int(availability.total)))" + } else { + ribbonText = nil + } let _ = visibleItem.update( transition: itemTransition, component: AnyComponent( @@ -157,26 +170,36 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr GiftItemComponent( context: self.context, theme: params.presentationData.theme, - peer: product.fromPeer, + peer: product.fromPeer.flatMap { .peer($0) } ?? .anonymous, subject: .starGift(product.gift.id, product.gift.file), price: "â­ï¸ \(product.gift.price)", - ribbon: product.gift.availability != nil ? - GiftItemComponent.Ribbon( - text: "1 of 1K", - color: UIColor(rgb: 0x58c1fe) - ) - : nil + ribbon: ribbonText.flatMap { GiftItemComponent.Ribbon(text: $0, color: .blue) }, + isHidden: !product.savedToProfile ) ), effectAlignment: .center, action: { [weak self] in - if let self { - let controller = GiftViewScreen( - context: self.context, - subject: .profileGift(self.peerId, product) - ) - self.parentController?.push(controller) + guard let self else { + return } + let controller = GiftViewScreen( + context: self.context, + subject: .profileGift(self.peerId, product), + updateSavedToProfile: { [weak self] added in + guard let self, let messageId = product.messageId else { + return + } + self.profileGifts.updateStarGiftAddedToProfile(messageId: messageId, added: added) + }, + convertToStars: { [weak self] in + guard let self, let messageId = product.messageId else { + return + } + self.profileGifts.convertStarGift(messageId: messageId) + } + ) + self.parentController?.push(controller) + }, animateAlpha: false ) @@ -198,46 +221,124 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr } } - let contentHeight = ceil(CGFloat(starsProducts.count) / 3.0) * starsOptionSize.height + 60.0 + params.bottomInset + 16.0 + var contentHeight = ceil(CGFloat(starsProducts.count) / 3.0) * starsOptionSize.height + 60.0 + 16.0 -// //TODO:localize -// let buttonSize = self.button.update( -// transition: .immediate, -// component: AnyComponent(ButtonComponent( -// background: ButtonComponent.Background( -// color: params.presentationData.theme.list.itemCheckColors.fillColor, -// foreground: params.presentationData.theme.list.itemCheckColors.foregroundColor, -// pressedColor: params.presentationData.theme.list.itemCheckColors.fillColor.withMultipliedAlpha(0.9), -// cornerRadius: 10.0 -// ), -// content: AnyComponentWithIdentity( -// id: AnyHashable(0), -// component: AnyComponent(MultilineTextComponent(text: .plain(NSAttributedString(string: "Send Gifts to Friends", font: Font.semibold(17.0), textColor: )params.presentationData.theme.list.itemCheckColors.foregroundColor))) -// ), -// isEnabled: true, -// displaysProgress: false, -// action: { -// -// } -// )), -// environment: {}, -// containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 50) -// ) -// if let buttonView = self.button.view { -// if buttonView.superview == nil { -// self.addSubview(buttonView) -// } -// buttonView.frame = CGRect(origin: CGPoint(x: floor((availableSize.width - buttonSize.width) / 2.0), y: availableSize.height - environment.safeInsets.bottom - buttonSize.height), size: buttonSize) -// } - -// contentHeight += 100.0 + if self.peerId == self.context.account.peerId { + let transition = ComponentTransition.immediate + + let size = params.size + let sideInset = params.sideInset + let bottomInset = params.bottomInset + let presentationData = params.presentationData + + let themeUpdated = self.theme !== presentationData.theme + self.theme = presentationData.theme + + let unlockText: ComponentView + let unlockBackground: UIImageView + let unlockButton: SolidRoundedButtonNode + if let current = self.unlockText { + unlockText = current + } else { + unlockText = ComponentView() + self.unlockText = unlockText + } + + if let current = self.unlockBackground { + unlockBackground = current + } else { + unlockBackground = UIImageView() + unlockBackground.contentMode = .scaleToFill + self.view.addSubview(unlockBackground) + self.unlockBackground = unlockBackground + } + + if let current = self.unlockButton { + unlockButton = current + } else { + unlockButton = SolidRoundedButtonNode(theme: SolidRoundedButtonTheme(theme: presentationData.theme), height: 50.0, cornerRadius: 10.0) + self.view.addSubview(unlockButton.view) + self.unlockButton = unlockButton + + //TODO:localize + unlockButton.title = "Send Gifts to Friends" + + unlockButton.pressed = { [weak self] in + self?.buttonPressed() + } + } + if themeUpdated { + let topColor = presentationData.theme.list.plainBackgroundColor.withAlphaComponent(0.0) + let bottomColor = presentationData.theme.list.plainBackgroundColor + unlockBackground.image = generateGradientImage(size: CGSize(width: 1.0, height: 170.0), colors: [topColor, bottomColor, bottomColor], locations: [0.0, 0.3, 1.0]) + unlockButton.updateTheme(SolidRoundedButtonTheme(theme: presentationData.theme)) + } + + let textFont = Font.regular(13.0) + let boldTextFont = Font.semibold(13.0) + let textColor = presentationData.theme.list.itemSecondaryTextColor + let linkColor = presentationData.theme.list.itemAccentColor + let markdownAttributes = MarkdownAttributes(body: MarkdownAttributeSet(font: textFont, textColor: textColor), bold: MarkdownAttributeSet(font: boldTextFont, textColor: textColor), link: MarkdownAttributeSet(font: boldTextFont, textColor: linkColor), linkAttribute: { _ in + return nil + }) + + let scrollOffset: CGFloat = min(0.0, self.scrollNode.view.contentOffset.y + bottomInset + 80.0) + + transition.setFrame(view: unlockBackground, frame: CGRect(x: 0.0, y: size.height - bottomInset - 170.0 + scrollOffset, width: size.width, height: bottomInset + 170.0)) + + let buttonSideInset = sideInset + 16.0 + let buttonSize = CGSize(width: size.width - buttonSideInset * 2.0, height: 50.0) + transition.setFrame(view: unlockButton.view, frame: CGRect(origin: CGPoint(x: buttonSideInset, y: size.height - bottomInset - buttonSize.height - 26.0), size: buttonSize)) + let _ = unlockButton.updateLayout(width: buttonSize.width, transition: .immediate) + + let unlockSize = unlockText.update( + transition: .immediate, + component: AnyComponent( + BalancedTextComponent( + text: .markdown(text: "These gifts were sent to you by other users. Tap on a gift to exchange it for Stars or change its privacy settings.", attributes: markdownAttributes), + horizontalAlignment: .center, + maximumNumberOfLines: 0, + lineSpacing: 0.2 + ) + ), + environment: {}, + containerSize: CGSize(width: size.width - 32.0, height: 200.0) + ) + if let view = unlockText.view { + if view.superview == nil { + view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.buttonPressed))) + self.scrollNode.view.addSubview(view) + } + transition.setFrame(view: view, frame: CGRect(origin: CGPoint(x: floor((size.width - unlockSize.width) / 2.0), y: contentHeight), size: unlockSize)) + } + contentHeight += unlockSize.height + } + contentHeight += params.bottomInset let contentSize = CGSize(width: params.size.width, height: contentHeight) if self.scrollNode.view.contentSize != contentSize { self.scrollNode.view.contentSize = contentSize } } + + let bottomOffset = max(0.0, self.scrollNode.view.contentSize.height - self.scrollNode.view.contentOffset.y - self.scrollNode.view.frame.height) + if bottomOffset < 100.0 { + self.profileGifts.loadMore() + } + } + + @objc private func buttonPressed() { + let _ = (self.context.account.stateManager.contactBirthdays + |> take(1) + |> deliverOnMainQueue).start(next: { [weak self] birthdays in + guard let self else { + return + } + let controller = self.context.sharedContext.makePremiumGiftController(context: self.context, source: .settings(birthdays), completion: nil) + controller.navigationPresentation = .modal + self.chatControllerInteraction.navigationController()?.pushViewController(controller) + }) } public func update(size: CGSize, topInset: CGFloat, sideInset: CGFloat, bottomInset: CGFloat, deviceMetrics: DeviceMetrics, visibleHeight: CGFloat, isScrollingLockedAtTop: Bool, expandProgress: CGFloat, navigationHeight: CGFloat, presentationData: PresentationData, synchronous: Bool, transition: ContainedViewLayoutTransition) { diff --git a/submodules/TelegramUI/Components/Stars/StarsIntroScreen/BUILD b/submodules/TelegramUI/Components/Stars/StarsIntroScreen/BUILD new file mode 100644 index 0000000000..598da6fc38 --- /dev/null +++ b/submodules/TelegramUI/Components/Stars/StarsIntroScreen/BUILD @@ -0,0 +1,44 @@ +load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library") + +swift_library( + name = "StarsIntroScreen", + module_name = "StarsIntroScreen", + srcs = glob([ + "Sources/**/*.swift", + ]), + copts = [ + "-warnings-as-errors", + ], + deps = [ + "//submodules/AsyncDisplayKit", + "//submodules/Display", + "//submodules/Postbox", + "//submodules/TelegramCore", + "//submodules/SSignalKit/SwiftSignalKit", + "//submodules/ComponentFlow", + "//submodules/Components/ViewControllerComponent", + "//submodules/Components/ComponentDisplayAdapters", + "//submodules/Components/MultilineTextComponent", + "//submodules/Components/BalancedTextComponent", + "//submodules/TelegramPresentationData", + "//submodules/AccountContext", + "//submodules/AppBundle", + "//submodules/ItemListUI", + "//submodules/TelegramStringFormatting", + "//submodules/PresentationDataUtils", + "//submodules/Components/SheetComponent", + "//submodules/UndoUI", + "//submodules/TextFormat", + "//submodules/TelegramUI/Components/ListSectionComponent", + "//submodules/TelegramUI/Components/ListActionItemComponent", + "//submodules/TelegramUI/Components/ScrollComponent", + "//submodules/TelegramUI/Components/Premium/PremiumStarComponent", + "//submodules/TelegramUI/Components/ButtonComponent", + "//submodules/Components/BundleIconComponent", + "//submodules/Components/SolidRoundedButtonComponent", + "//submodules/Components/BlurredBackgroundComponent", + ], + visibility = [ + "//visibility:public", + ], +) diff --git a/submodules/TelegramUI/Components/Stars/StarsIntroScreen/Sources/StarsIntroScreen.swift b/submodules/TelegramUI/Components/Stars/StarsIntroScreen/Sources/StarsIntroScreen.swift new file mode 100644 index 0000000000..db13e8975a --- /dev/null +++ b/submodules/TelegramUI/Components/Stars/StarsIntroScreen/Sources/StarsIntroScreen.swift @@ -0,0 +1,573 @@ +import Foundation +import UIKit +import Display +import ComponentFlow +import SwiftSignalKit +import TelegramCore +import Markdown +import TextFormat +import TelegramPresentationData +import ViewControllerComponent +import ScrollComponent +import BundleIconComponent +import BalancedTextComponent +import MultilineTextComponent +import SolidRoundedButtonComponent +import AccountContext +import ScrollComponent +import BlurredBackgroundComponent +import PremiumStarComponent + +private final class ScrollContent: CombinedComponent { + typealias EnvironmentType = (ViewControllerComponentContainer.Environment, ScrollChildEnvironment) + + let context: AccountContext + let openExamples: () -> Void + let dismiss: () -> Void + + init( + context: AccountContext, + openExamples: @escaping () -> Void, + dismiss: @escaping () -> Void + ) { + self.context = context + self.openExamples = openExamples + self.dismiss = dismiss + } + + static func ==(lhs: ScrollContent, rhs: ScrollContent) -> Bool { + if lhs.context !== rhs.context { + return false + } + return true + } + + static var body: Body { + let star = Child(PremiumStarComponent.self) + + let title = Child(BalancedTextComponent.self) + let text = Child(BalancedTextComponent.self) + let list = Child(List.self) + + return { context in + let environment = context.environment[ViewControllerComponentContainer.Environment.self].value + let component = context.component + + let theme = environment.theme + //let strings = environment.strings + + let sideInset: CGFloat = 16.0 + environment.safeInsets.left + let textSideInset: CGFloat = 30.0 + environment.safeInsets.left + + let titleFont = Font.semibold(20.0) + let textFont = Font.regular(15.0) + + let textColor = theme.actionSheet.primaryTextColor + let secondaryTextColor = theme.actionSheet.secondaryTextColor + let linkColor = theme.actionSheet.controlAccentColor + + let spacing: CGFloat = 16.0 + var contentSize = CGSize(width: context.availableSize.width, height: 152.0) + + let star = star.update( + component: PremiumStarComponent( + theme: environment.theme, + isIntro: true, + isVisible: true, + hasIdleAnimations: true, + colors: [ + UIColor(rgb: 0xe57d02), + UIColor(rgb: 0xf09903), + UIColor(rgb: 0xf9b004), + UIColor(rgb: 0xfdd219) + ], + particleColor: UIColor(rgb: 0xf9b004), + backgroundColor: environment.theme.list.plainBackgroundColor + ), + availableSize: CGSize(width: min(414.0, context.availableSize.width), height: 220.0), + transition: context.transition + ) + context.add(star + .position(CGPoint(x: context.availableSize.width / 2.0, y: environment.navigationHeight + 24.0)) + ) + + let title = title.update( + component: BalancedTextComponent( + text: .plain(NSAttributedString(string: "What are Stars?", font: titleFont, textColor: textColor)), + horizontalAlignment: .center, + maximumNumberOfLines: 0, + lineSpacing: 0.1 + ), + availableSize: CGSize(width: context.availableSize.width - textSideInset * 2.0, height: context.availableSize.height), + transition: .immediate + ) + context.add(title + .position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + title.size.height / 2.0)) + ) + contentSize.height += title.size.height + contentSize.height += spacing - 8.0 + + let text = text.update( + component: BalancedTextComponent( + text: .plain(NSAttributedString(string: "Buy packages of Stars on Telegram that let you do following:", font: textFont, textColor: secondaryTextColor)), + horizontalAlignment: .center, + maximumNumberOfLines: 0, + lineSpacing: 0.2 + ), + availableSize: CGSize(width: context.availableSize.width - textSideInset * 2.0, height: context.availableSize.height), + transition: .immediate + ) + context.add(text + .position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + text.size.height / 2.0)) + ) + contentSize.height += text.size.height + contentSize.height += spacing + + var items: [AnyComponentWithIdentity] = [] + items.append( + AnyComponentWithIdentity( + id: "gift", + component: AnyComponent(ParagraphComponent( + title: "Send Gifts to Friends", + titleColor: textColor, + text: "Give your friends gifts that can be kept on their profiles or converted to Stars.", + textColor: secondaryTextColor, + accentColor: linkColor, + iconName: "Premium/StarsPerk/Gift", + iconColor: linkColor + )) + ) + ) + items.append( + AnyComponentWithIdentity( + id: "miniapp", + component: AnyComponent(ParagraphComponent( + title: "Use Stars in Miniapps", + titleColor: textColor, + text: "Buy additional content and services in Telegram miniapps. [See Examples >]()", + textColor: secondaryTextColor, + accentColor: linkColor, + iconName: "Premium/StarsPerk/Miniapp", + iconColor: linkColor, + action: { + component.openExamples() + } + )) + ) + ) + items.append( + AnyComponentWithIdentity( + id: "media", + component: AnyComponent(ParagraphComponent( + title: "Unlock Content in Channels", + titleColor: textColor, + text: "Get access to paid content and services in Telegram channels.", + textColor: secondaryTextColor, + accentColor: linkColor, + iconName: "Premium/StarsPerk/Media", + iconColor: linkColor + )) + ) + ) + items.append( + AnyComponentWithIdentity( + id: "reaction", + component: AnyComponent(ParagraphComponent( + title: "Send Star Reactions", + titleColor: textColor, + text: "Support your favorite channels by sending Star reactions to their posts.", + textColor: secondaryTextColor, + accentColor: linkColor, + iconName: "Premium/StarsPerk/Reaction", + iconColor: linkColor + )) + ) + ) + + let list = list.update( + component: List(items), + availableSize: CGSize(width: context.availableSize.width - sideInset * 2.0, height: 10000.0), + transition: context.transition + ) + context.add(list + .position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + list.size.height / 2.0)) + ) + contentSize.height += list.size.height + contentSize.height += spacing - 9.0 + + contentSize.height += 12.0 + 50.0 + if environment.safeInsets.bottom > 0 { + contentSize.height += environment.safeInsets.bottom + 5.0 + } else { + contentSize.height += 12.0 + } + + return contentSize + } + } +} + +private final class ContainerComponent: CombinedComponent { + typealias EnvironmentType = ViewControllerComponentContainer.Environment + + let context: AccountContext + let openExamples: () -> Void + + init( + context: AccountContext, + openExamples: @escaping () -> Void + ) { + self.context = context + self.openExamples = openExamples + } + + static func ==(lhs: ContainerComponent, rhs: ContainerComponent) -> Bool { + if lhs.context !== rhs.context { + return false + } + return true + } + + final class State: ComponentState { + var topContentOffset: CGFloat? + var bottomContentOffset: CGFloat? + } + + func makeState() -> State { + return State() + } + + static var body: Body { + let background = Child(Rectangle.self) + let scroll = Child(ScrollComponent.self) + let bottomPanel = Child(BlurredBackgroundComponent.self) + let bottomSeparator = Child(Rectangle.self) + let actionButton = Child(SolidRoundedButtonComponent.self) + let scrollExternalState = ScrollComponent.ExternalState() + + return { context in + let environment = context.environment[EnvironmentType.self] + let theme = environment.theme + //let strings = environment.strings + let state = context.state + + let controller = environment.controller + + let background = background.update( + component: Rectangle(color: environment.theme.list.plainBackgroundColor), + environment: {}, + availableSize: context.availableSize, + transition: context.transition + ) + context.add(background + .position(CGPoint(x: context.availableSize.width / 2.0, y: context.availableSize.height / 2.0)) + ) + + let scroll = scroll.update( + component: ScrollComponent( + content: AnyComponent(ScrollContent( + context: context.component.context, + openExamples: context.component.openExamples, + dismiss: { + controller()?.dismiss() + } + )), + externalState: scrollExternalState, + contentInsets: UIEdgeInsets(top: 0.0, left: 0.0, bottom: 1.0, right: 0.0), + contentOffsetUpdated: { [weak state] topContentOffset, bottomContentOffset in + state?.topContentOffset = topContentOffset + state?.bottomContentOffset = bottomContentOffset + Queue.mainQueue().justDispatch { + state?.updated(transition: .immediate) + } + }, + contentOffsetWillCommit: { targetContentOffset in + } + ), + environment: { environment }, + availableSize: context.availableSize, + transition: context.transition + ) + + context.add(scroll + .position(CGPoint(x: context.availableSize.width / 2.0, y: context.availableSize.height / 2.0)) + ) + + let buttonHeight: CGFloat = 50.0 + let bottomPanelPadding: CGFloat = 12.0 + let bottomInset: CGFloat = environment.safeInsets.bottom > 0.0 ? environment.safeInsets.bottom + 5.0 : bottomPanelPadding + let bottomPanelHeight = bottomPanelPadding + buttonHeight + bottomInset + + let bottomPanelAlpha: CGFloat + if scrollExternalState.contentHeight > context.availableSize.height { + if let bottomContentOffset = state.bottomContentOffset { + bottomPanelAlpha = min(16.0, bottomContentOffset) / 16.0 + } else { + bottomPanelAlpha = 1.0 + } + } else { + bottomPanelAlpha = 0.0 + } + + let bottomPanel = bottomPanel.update( + component: BlurredBackgroundComponent( + color: theme.rootController.tabBar.backgroundColor + ), + availableSize: CGSize(width: context.availableSize.width, height: bottomPanelHeight), + transition: context.transition + ) + let bottomSeparator = bottomSeparator.update( + component: Rectangle( + color: theme.rootController.tabBar.separatorColor + ), + availableSize: CGSize(width: context.availableSize.width, height: UIScreenPixel), + transition: context.transition + ) + + context.add(bottomPanel + .position(CGPoint(x: context.availableSize.width / 2.0, y: context.availableSize.height - bottomPanel.size.height / 2.0)) + .opacity(bottomPanelAlpha) + ) + context.add(bottomSeparator + .position(CGPoint(x: context.availableSize.width / 2.0, y: context.availableSize.height - bottomPanel.size.height)) + .opacity(bottomPanelAlpha) + ) + + let sideInset: CGFloat = 16.0 + environment.safeInsets.left + let actionButton = actionButton.update( + component: SolidRoundedButtonComponent( + title: "Got It", + theme: SolidRoundedButtonComponent.Theme( + backgroundColor: theme.list.itemCheckColors.fillColor, + backgroundColors: [], + foregroundColor: theme.list.itemCheckColors.foregroundColor + ), + font: .bold, + fontSize: 17.0, + height: buttonHeight, + cornerRadius: 10.0, + gloss: false, + iconName: nil, + animationName: nil, + iconPosition: .left, + action: { + controller()?.dismiss() + } + ), + availableSize: CGSize(width: context.availableSize.width - sideInset * 2.0, height: 50.0), + transition: context.transition + ) + context.add(actionButton + .position(CGPoint(x: context.availableSize.width / 2.0, y: context.availableSize.height - bottomPanelHeight + bottomPanelPadding + actionButton.size.height / 2.0)) + ) + + return context.availableSize + } + } +} + +public final class StarsIntroScreen: ViewControllerComponentContainer { + private let context: AccountContext + + public init( + context: AccountContext, + forceDark: Bool = false + ) { + self.context = context + + var openExamplesImpl: (() -> Void)? + super.init( + context: context, + component: ContainerComponent( + context: context, + openExamples: { + openExamplesImpl?() + } + ), + navigationBarAppearance: .none, + statusBarStyle: .ignore, + theme: forceDark ? .dark : .default + ) + + self.navigationPresentation = .modal + + openExamplesImpl = { [weak self] in + guard let self else { + return + } + let _ = (context.sharedContext.makeMiniAppListScreenInitialData(context: context) + |> deliverOnMainQueue).startStandalone(next: { [weak self] initialData in + guard let self, let navigationController = self.navigationController as? NavigationController else { + return + } + navigationController.pushViewController(context.sharedContext.makeMiniAppListScreen(context: context, initialData: initialData)) + }) + } + } + + required public init(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +private final class ParagraphComponent: CombinedComponent { + let title: String + let titleColor: UIColor + let text: String + let textColor: UIColor + let accentColor: UIColor + let iconName: String + let iconColor: UIColor + let action: () -> Void + + public init( + title: String, + titleColor: UIColor, + text: String, + textColor: UIColor, + accentColor: UIColor, + iconName: String, + iconColor: UIColor, + action: @escaping () -> Void = {} + ) { + self.title = title + self.titleColor = titleColor + self.text = text + self.textColor = textColor + self.accentColor = accentColor + self.iconName = iconName + self.iconColor = iconColor + self.action = action + } + + static func ==(lhs: ParagraphComponent, rhs: ParagraphComponent) -> Bool { + if lhs.title != rhs.title { + return false + } + if lhs.titleColor != rhs.titleColor { + return false + } + if lhs.text != rhs.text { + return false + } + if lhs.textColor != rhs.textColor { + return false + } + if lhs.accentColor != rhs.accentColor { + return false + } + if lhs.iconName != rhs.iconName { + return false + } + if lhs.iconColor != rhs.iconColor { + return false + } + return true + } + + final class State: ComponentState { + var cachedChevronImage: (UIImage, UIColor)? + } + + func makeState() -> State { + return State() + } + + static var body: Body { + let title = Child(MultilineTextComponent.self) + let text = Child(MultilineTextComponent.self) + let icon = Child(BundleIconComponent.self) + + return { context in + let component = context.component + let state = context.state + + let leftInset: CGFloat = 32.0 + let rightInset: CGFloat = 24.0 + let textSideInset: CGFloat = leftInset + 8.0 + let spacing: CGFloat = 5.0 + + let textTopInset: CGFloat = 9.0 + + let title = title.update( + component: MultilineTextComponent( + text: .plain(NSAttributedString( + string: component.title, + font: Font.semibold(15.0), + textColor: component.titleColor, + paragraphAlignment: .natural + )), + horizontalAlignment: .center, + maximumNumberOfLines: 1 + ), + availableSize: CGSize(width: context.availableSize.width - leftInset - rightInset, height: CGFloat.greatestFiniteMagnitude), + transition: .immediate + ) + + let textFont = Font.regular(15.0) + let boldTextFont = Font.semibold(15.0) + let textColor = component.textColor + let accentColor = component.accentColor + let markdownAttributes = MarkdownAttributes( + body: MarkdownAttributeSet(font: textFont, textColor: textColor), + bold: MarkdownAttributeSet(font: boldTextFont, textColor: textColor), + link: MarkdownAttributeSet(font: textFont, textColor: accentColor), + linkAttribute: { contents in + return (TelegramTextAttributes.URL, contents) + } + ) + + if state.cachedChevronImage == nil || state.cachedChevronImage?.1 != accentColor { + state.cachedChevronImage = (generateTintedImage(image: UIImage(bundleImageName: "Settings/TextArrowRight"), color: accentColor)!, accentColor) + } + let textAttributedString = parseMarkdownIntoAttributedString(component.text, attributes: markdownAttributes).mutableCopy() as! NSMutableAttributedString + if let range = textAttributedString.string.range(of: ">"), let chevronImage = state.cachedChevronImage?.0 { + textAttributedString.addAttribute(.attachment, value: chevronImage, range: NSRange(range, in: textAttributedString.string)) + } + + let text = text.update( + component: MultilineTextComponent( + text: .plain(textAttributedString), + horizontalAlignment: .natural, + maximumNumberOfLines: 0, + lineSpacing: 0.2, + highlightAction: { attributes in + if let _ = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.URL)] { + return NSAttributedString.Key(rawValue: TelegramTextAttributes.URL) + } else { + return nil + } + }, + tapAction: { _, _ in + component.action() + } + ), + availableSize: CGSize(width: context.availableSize.width - leftInset - rightInset, height: context.availableSize.height), + transition: .immediate + ) + + let icon = icon.update( + component: BundleIconComponent( + name: component.iconName, + tintColor: component.iconColor + ), + availableSize: CGSize(width: context.availableSize.width, height: context.availableSize.height), + transition: .immediate + ) + + context.add(title + .position(CGPoint(x: textSideInset + title.size.width / 2.0, y: textTopInset + title.size.height / 2.0)) + ) + + context.add(text + .position(CGPoint(x: textSideInset + text.size.width / 2.0, y: textTopInset + title.size.height + spacing + text.size.height / 2.0)) + ) + + context.add(icon + .position(CGPoint(x: 15.0, y: textTopInset + 18.0)) + ) + + return CGSize(width: context.availableSize.width, height: textTopInset + title.size.height + text.size.height + 20.0) + } + } +} diff --git a/submodules/TelegramUI/Components/Stars/StarsPurchaseScreen/Sources/StarsPurchaseScreen.swift b/submodules/TelegramUI/Components/Stars/StarsPurchaseScreen/Sources/StarsPurchaseScreen.swift index 6dfa8bb2d5..c49448d6af 100644 --- a/submodules/TelegramUI/Components/Stars/StarsPurchaseScreen/Sources/StarsPurchaseScreen.swift +++ b/submodules/TelegramUI/Components/Stars/StarsPurchaseScreen/Sources/StarsPurchaseScreen.swift @@ -237,6 +237,8 @@ private final class StarsPurchaseScreenContentComponent: CombinedComponent { textString = renew ? strings.Stars_Purchase_SubscriptionRenewInfo(component.peers.first?.value.compactDisplayTitle ?? "").string : strings.Stars_Purchase_SubscriptionInfo(component.peers.first?.value.compactDisplayTitle ?? "").string case .unlockMedia: textString = strings.Stars_Purchase_StarsNeededUnlockInfo + case .starGift: + textString = strings.Stars_Purchase_StarGiftInfo(component.peers.first?.value.compactDisplayTitle ?? "").string } let markdownAttributes = MarkdownAttributes(body: MarkdownAttributeSet(font: textFont, textColor: textColor), bold: MarkdownAttributeSet(font: boldTextFont, textColor: textColor), link: MarkdownAttributeSet(font: textFont, textColor: accentColor), linkAttribute: { contents in @@ -815,11 +817,9 @@ private final class StarsPurchaseScreenComponent: CombinedComponent { switch context.component.purpose { case .generic: titleText = strings.Stars_Purchase_GetStars - case let .topUp(requiredStars, _): - titleText = strings.Stars_Purchase_StarsNeeded(Int32(requiredStars)) case .gift: titleText = strings.Stars_Purchase_GiftStars - case let .transfer(_, requiredStars), let .reactions(_, requiredStars), let .subscription(_, requiredStars, _), let .unlockMedia(requiredStars): + case let .topUp(requiredStars, _), let .transfer(_, requiredStars), let .reactions(_, requiredStars), let .subscription(_, requiredStars, _), let .unlockMedia(requiredStars), let .starGift(_, requiredStars): titleText = strings.Stars_Purchase_StarsNeeded(Int32(requiredStars)) } @@ -1239,6 +1239,8 @@ private extension StarsPurchasePurpose { return [peerId] case let .subscription(peerId, _, _): return [peerId] + case let .starGift(peerId, _): + return [peerId] default: return [] } @@ -1256,6 +1258,8 @@ private extension StarsPurchasePurpose { return requiredStars case let .unlockMedia(requiredStars): return requiredStars + case let .starGift(_, requiredStars): + return requiredStars default: return nil } diff --git a/submodules/TelegramUI/Components/Stars/StarsTransactionScreen/BUILD b/submodules/TelegramUI/Components/Stars/StarsTransactionScreen/BUILD index b814269b7d..ff340fd489 100644 --- a/submodules/TelegramUI/Components/Stars/StarsTransactionScreen/BUILD +++ b/submodules/TelegramUI/Components/Stars/StarsTransactionScreen/BUILD @@ -36,6 +36,7 @@ swift_library( "//submodules/GalleryUI", "//submodules/TelegramUI/Components/MiniAppListScreen", "//submodules/TelegramUI/Components/Premium/PremiumStarComponent", + "//submodules/TelegramUI/Components/Gifts/GiftAnimationComponent", ], visibility = [ "//visibility:public", diff --git a/submodules/TelegramUI/Components/Stars/StarsTransactionScreen/Sources/StarsTransactionScreen.swift b/submodules/TelegramUI/Components/Stars/StarsTransactionScreen/Sources/StarsTransactionScreen.swift index 4e8ba3c566..af129188fe 100644 --- a/submodules/TelegramUI/Components/Stars/StarsTransactionScreen/Sources/StarsTransactionScreen.swift +++ b/submodules/TelegramUI/Components/Stars/StarsTransactionScreen/Sources/StarsTransactionScreen.swift @@ -25,6 +25,7 @@ import GalleryUI import StarsAvatarComponent import MiniAppListScreen import PremiumStarComponent +import GiftAnimationComponent private final class StarsTransactionSheetContent: CombinedComponent { typealias EnvironmentType = ViewControllerComponentContainer.Environment @@ -145,6 +146,7 @@ private final class StarsTransactionSheetContent: CombinedComponent { let title = Child(MultilineTextComponent.self) let star = Child(StarsImageComponent.self) let activeStar = Child(PremiumStarComponent.self) + let gift = Child(GiftAnimationComponent.self) let amountBackground = Child(RoundedRectangle.self) let amount = Child(BalancedTextComponent.self) let amountStar = Child(BundleIconComponent.self) @@ -225,6 +227,7 @@ private final class StarsTransactionSheetContent: CombinedComponent { var isReaction = false var giveawayMessageId: MessageId? var isBoost = false + var giftAnimation: TelegramMediaFile? var delayedCloseOnOpenPeer = true switch subject { @@ -322,7 +325,18 @@ private final class StarsTransactionSheetContent: CombinedComponent { } } case let .transaction(transaction, parentPeer): - if let giveawayMessageIdValue = transaction.giveawayMessageId { + if let starGift = transaction.starGift { + titleText = "Gift" + descriptionText = "" + count = transaction.count + transactionId = transaction.id + date = transaction.date + if case let .peer(peer) = transaction.peer { + toPeer = peer + } + transactionPeer = transaction.peer + giftAnimation = starGift.file + } else if let giveawayMessageIdValue = transaction.giveawayMessageId { titleText = strings.Stars_Transaction_Giveaway_Title descriptionText = "" count = transaction.count @@ -572,7 +586,17 @@ private final class StarsTransactionSheetContent: CombinedComponent { imageIcon = nil } var starChild: _UpdatedChildComponent - if isBoost { + if let giftAnimation { + starChild = gift.update( + component: GiftAnimationComponent( + context: component.context, + theme: theme, + file: giftAnimation + ), + availableSize: CGSize(width: 128.0, height: 128.0), + transition: .immediate + ) + } else if isBoost { starChild = activeStar.update( component: PremiumStarComponent( theme: theme, @@ -877,7 +901,7 @@ private final class StarsTransactionSheetContent: CombinedComponent { ) context.add(starChild - .position(CGPoint(x: context.availableSize.width / 2.0, y: starChild.size.height / 2.0 - 19.0)) + .position(CGPoint(x: context.availableSize.width / 2.0, y: 200.0 / 2.0 - 19.0)) ) context.add(title @@ -885,7 +909,7 @@ private final class StarsTransactionSheetContent: CombinedComponent { ) var originY: CGFloat = 0.0 - originY += starChild.size.height - 23.0 + originY += 200.0 - 23.0 var descriptionSize: CGSize = .zero if !descriptionText.isEmpty { diff --git a/submodules/TelegramUI/Components/Stars/StarsTransactionsScreen/Sources/StarsTransactionsListPanelComponent.swift b/submodules/TelegramUI/Components/Stars/StarsTransactionsScreen/Sources/StarsTransactionsListPanelComponent.swift index 5431bef8d3..3200636923 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 let _ = item.giveawayMessageId { + if let _ = item.starGift { + itemTitle = peer.displayTitle(strings: environment.strings, displayOrder: .firstLast) + itemSubtitle = item.count > 0 ? environment.strings.Stars_Intro_Transaction_ConvertedGift : environment.strings.Stars_Intro_Transaction_Gift + } else if let _ = item.giveawayMessageId { itemTitle = peer.displayTitle(strings: environment.strings, displayOrder: .firstLast) itemSubtitle = environment.strings.Stars_Intro_Transaction_GiveawayPrize } else if !item.media.isEmpty { diff --git a/submodules/TelegramUI/Components/Stories/PeerListItemComponent/Sources/PeerListItemComponent.swift b/submodules/TelegramUI/Components/Stories/PeerListItemComponent/Sources/PeerListItemComponent.swift index 349aa6da22..972ce43e09 100644 --- a/submodules/TelegramUI/Components/Stories/PeerListItemComponent/Sources/PeerListItemComponent.swift +++ b/submodules/TelegramUI/Components/Stories/PeerListItemComponent/Sources/PeerListItemComponent.swift @@ -169,6 +169,7 @@ public final class PeerListItemComponent: Component { case neutral case accent case constructive + case destructive } public var text: String @@ -937,8 +938,9 @@ public final class PeerListItemComponent: Component { case .accent: labelColor = component.theme.list.itemAccentColor case .constructive: - //TODO:release labelColor = UIColor(rgb: 0x33C758) + case .destructive: + labelColor = UIColor(rgb: 0xff3b30) } var animateLabelDirection: Bool? diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemContentComponent.swift b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemContentComponent.swift index fad4658d7a..c0a5f2c7c6 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemContentComponent.swift +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemContentComponent.swift @@ -200,6 +200,7 @@ final class StoryItemContentComponent: Component { if case let .file(file) = currentMessageMedia, let peerReference = PeerReference(component.peer._asPeer()) { if self.videoNode == nil { let videoNode = UniversalVideoNode( + accountId: component.context.account.id, postbox: component.context.account.postbox, audioSession: component.context.sharedContext.mediaManager.audioSession, manager: component.context.sharedContext.mediaManager.universalVideoManager, diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift index b194377d78..80b6b69fa2 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift @@ -6961,48 +6961,69 @@ public final class StoryItemSetContainerComponent: Component { if !component.slice.effectivePeer.isService { items.append(.action(ContextMenuActionItem(text: component.strings.Story_Context_Report, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Report"), color: theme.contextMenu.primaryColor) - }, action: { [weak self] c, a in + }, action: { [weak self] _, f in guard let self, let component = self.component, let controller = component.controller() else { return } - let options: [PeerReportOption] = [.spam, .violence, .pornography, .childAbuse, .copyright, .illegalDrugs, .personalDetails, .other] - presentPeerReportOptions( + f(.default) + + self.isReporting = true + self.updateIsProgressPaused() + + component.context.sharedContext.makeContentReportScreen( context: component.context, - parent: controller, - contextController: c, - backAction: { _ in }, - subject: .story(component.slice.effectivePeer.id, component.slice.item.storyItem.id), - options: options, - passthrough: true, - forceTheme: defaultDarkPresentationTheme, - isDetailedReportingVisible: { [weak self] isReporting in + subject: .stories(component.slice.effectivePeer.id, [component.slice.item.storyItem.id]), + forceDark: true, + present: { c in + controller.push(c) + }, + completion: { [weak self] in guard let self else { return } - self.isReporting = isReporting + self.isReporting = false self.updateIsProgressPaused() - }, - completion: { [weak self] reason, _ in - guard let self, let component = self.component, let controller = component.controller(), let reason else { - return - } - let _ = component.context.engine.peers.reportPeerStory(peerId: component.slice.effectivePeer.id, storyId: component.slice.item.storyItem.id, reason: reason, message: "").startStandalone() - controller.present( - UndoOverlayController( - presentationData: presentationData, - content: .emoji( - name: "PoliceCar", - text: presentationData.strings.Report_Succeed - ), - elevatedLayout: false, - blurred: true, - action: { _ in return false } - ) - , in: .current - ) } ) + +// let options: [PeerReportOption] = [.spam, .violence, .pornography, .childAbuse, .copyright, .illegalDrugs, .personalDetails, .other] +// presentPeerReportOptions( +// context: component.context, +// parent: controller, +// contextController: c, +// backAction: { _ in }, +// subject: .story(component.slice.effectivePeer.id, component.slice.item.storyItem.id), +// options: options, +// passthrough: true, +// forceTheme: defaultDarkPresentationTheme, +// isDetailedReportingVisible: { [weak self] isReporting in +// guard let self else { +// return +// } +// self.isReporting = isReporting +// self.updateIsProgressPaused() +// }, +// completion: { [weak self] reason, _ in +// guard let self, let component = self.component, let controller = component.controller(), let reason else { +// return +// } +// let _ = component.context.engine.peers.reportPeerStory(peerId: component.slice.effectivePeer.id, storyId: component.slice.item.storyItem.id, reason: reason, message: "").startStandalone() +// controller.present( +// UndoOverlayController( +// presentationData: presentationData, +// content: .emoji( +// name: "PoliceCar", +// text: presentationData.strings.Report_Succeed +// ), +// elevatedLayout: false, +// blurred: true, +// action: { _ in return false } +// ) +// , in: .current +// ) +// } +// ) }))) } } diff --git a/submodules/TelegramUI/Images.xcassets/Media Gallery/NavigationSettings.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Media Gallery/NavigationSettings.imageset/Contents.json new file mode 100644 index 0000000000..cc44172ecd --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Media Gallery/NavigationSettings.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "videosettings_30.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/submodules/TelegramUI/Images.xcassets/Media Gallery/NavigationSettings.imageset/videosettings_30.pdf b/submodules/TelegramUI/Images.xcassets/Media Gallery/NavigationSettings.imageset/videosettings_30.pdf new file mode 100644 index 0000000000..028e8b9ab0 Binary files /dev/null and b/submodules/TelegramUI/Images.xcassets/Media Gallery/NavigationSettings.imageset/videosettings_30.pdf differ diff --git a/submodules/TelegramUI/Images.xcassets/Media Gallery/NavigationSettingsQAuto.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Media Gallery/NavigationSettingsQAuto.imageset/Contents.json new file mode 100644 index 0000000000..e2fa1abb90 --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Media Gallery/NavigationSettingsQAuto.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "videosettingsauto_30.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/submodules/TelegramUI/Images.xcassets/Media Gallery/NavigationSettingsQAuto.imageset/videosettingsauto_30.pdf b/submodules/TelegramUI/Images.xcassets/Media Gallery/NavigationSettingsQAuto.imageset/videosettingsauto_30.pdf new file mode 100644 index 0000000000..d36b2d7686 Binary files /dev/null and b/submodules/TelegramUI/Images.xcassets/Media Gallery/NavigationSettingsQAuto.imageset/videosettingsauto_30.pdf differ diff --git a/submodules/TelegramUI/Images.xcassets/Media Gallery/NavigationSettingsQHD.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Media Gallery/NavigationSettingsQHD.imageset/Contents.json new file mode 100644 index 0000000000..1f7bfc2bb2 --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Media Gallery/NavigationSettingsQHD.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "videosettingshd_30.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/submodules/TelegramUI/Images.xcassets/Media Gallery/NavigationSettingsQHD.imageset/videosettingshd_30.pdf b/submodules/TelegramUI/Images.xcassets/Media Gallery/NavigationSettingsQHD.imageset/videosettingshd_30.pdf new file mode 100644 index 0000000000..735db423a8 Binary files /dev/null and b/submodules/TelegramUI/Images.xcassets/Media Gallery/NavigationSettingsQHD.imageset/videosettingshd_30.pdf differ diff --git a/submodules/TelegramUI/Images.xcassets/Media Gallery/NavigationSettingsQSD.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Media Gallery/NavigationSettingsQSD.imageset/Contents.json new file mode 100644 index 0000000000..8f71976b5f --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Media Gallery/NavigationSettingsQSD.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "videosettingssd_30.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/submodules/TelegramUI/Images.xcassets/Media Gallery/NavigationSettingsQSD.imageset/videosettingssd_30.pdf b/submodules/TelegramUI/Images.xcassets/Media Gallery/NavigationSettingsQSD.imageset/videosettingssd_30.pdf new file mode 100644 index 0000000000..c77db5874e Binary files /dev/null and b/submodules/TelegramUI/Images.xcassets/Media Gallery/NavigationSettingsQSD.imageset/videosettingssd_30.pdf differ diff --git a/submodules/TelegramUI/Images.xcassets/Peer Info/HiddenIcon.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Peer Info/HiddenIcon.imageset/Contents.json new file mode 100644 index 0000000000..126065aee3 --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Peer Info/HiddenIcon.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "hidden_30.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/submodules/TelegramUI/Images.xcassets/Peer Info/HiddenIcon.imageset/hidden_30.pdf b/submodules/TelegramUI/Images.xcassets/Peer Info/HiddenIcon.imageset/hidden_30.pdf new file mode 100644 index 0000000000..7ff4831d79 Binary files /dev/null and b/submodules/TelegramUI/Images.xcassets/Peer Info/HiddenIcon.imageset/hidden_30.pdf differ diff --git a/submodules/TelegramUI/Images.xcassets/Premium/StarsPerk/Contents.json b/submodules/TelegramUI/Images.xcassets/Premium/StarsPerk/Contents.json new file mode 100644 index 0000000000..6e965652df --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Premium/StarsPerk/Contents.json @@ -0,0 +1,9 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "provides-namespace" : true + } +} diff --git a/submodules/TelegramUI/Images.xcassets/Premium/StarsPerk/Gift.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Premium/StarsPerk/Gift.imageset/Contents.json new file mode 100644 index 0000000000..923ba188ab --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Premium/StarsPerk/Gift.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "gift_30 (4).pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/submodules/TelegramUI/Images.xcassets/Premium/StarsPerk/Gift.imageset/gift_30 (4).pdf b/submodules/TelegramUI/Images.xcassets/Premium/StarsPerk/Gift.imageset/gift_30 (4).pdf new file mode 100644 index 0000000000..0d6d36c603 Binary files /dev/null and b/submodules/TelegramUI/Images.xcassets/Premium/StarsPerk/Gift.imageset/gift_30 (4).pdf differ diff --git a/submodules/TelegramUI/Images.xcassets/Premium/StarsPerk/Media.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Premium/StarsPerk/Media.imageset/Contents.json new file mode 100644 index 0000000000..6a409d155b --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Premium/StarsPerk/Media.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "unlock_30.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/submodules/TelegramUI/Images.xcassets/Premium/StarsPerk/Media.imageset/unlock_30.pdf b/submodules/TelegramUI/Images.xcassets/Premium/StarsPerk/Media.imageset/unlock_30.pdf new file mode 100644 index 0000000000..67593f3698 Binary files /dev/null and b/submodules/TelegramUI/Images.xcassets/Premium/StarsPerk/Media.imageset/unlock_30.pdf differ diff --git a/submodules/TelegramUI/Images.xcassets/Premium/StarsPerk/Miniapp.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Premium/StarsPerk/Miniapp.imageset/Contents.json new file mode 100644 index 0000000000..e582efcfd3 --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Premium/StarsPerk/Miniapp.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "bot_30.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/submodules/TelegramUI/Images.xcassets/Premium/StarsPerk/Miniapp.imageset/bot_30.pdf b/submodules/TelegramUI/Images.xcassets/Premium/StarsPerk/Miniapp.imageset/bot_30.pdf new file mode 100644 index 0000000000..e682166cc8 Binary files /dev/null and b/submodules/TelegramUI/Images.xcassets/Premium/StarsPerk/Miniapp.imageset/bot_30.pdf differ diff --git a/submodules/TelegramUI/Images.xcassets/Premium/StarsPerk/Reaction.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Premium/StarsPerk/Reaction.imageset/Contents.json new file mode 100644 index 0000000000..a7e458b55c --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Premium/StarsPerk/Reaction.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "cash_30.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/submodules/TelegramUI/Images.xcassets/Premium/StarsPerk/Reaction.imageset/cash_30.pdf b/submodules/TelegramUI/Images.xcassets/Premium/StarsPerk/Reaction.imageset/cash_30.pdf new file mode 100644 index 0000000000..ccfbed1a33 --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Premium/StarsPerk/Reaction.imageset/cash_30.pdf @@ -0,0 +1,62 @@ +%PDF-1.7 + +1 0 obj + << >> +endobj + +2 0 obj + << /Filter /FlateDecode + /Length 3 0 R + >> +stream +xe•KŽT1 EçYE‘‰óõKxjRwK¨%ÖÏÉç%ÔÈïĹvl'õáóÓŸ_×Ó·/Ÿ¾›ûëz3¿·®ÿî6Þ/k­EBÔì·q½˜åö¿ñv½"6*Îæœ\©—&[Åå”É&§Qä!c;~sn›]flIØ›N ϱÚ6tð»Ì¦=`r~ni‚#­fŒx-t™¾¡e¸Ø0ðêK]ë&S§Ëü4ó .Ãþb¢-%Ö¸²óÙ¦”¼¦ ¼®]ô®Ñ:Ñ&6–J¾ýqZˆê ]_6œÑ &¶ÑéA:TÈnZèÍU<çoÖ«À—D/”)[õ}³°i±R%Ôò€9ÉA9t±©:ïÇq’N¿É8³¢¾¦“ªUýGp£ù2&¬\Š<›jcФß`õ¥’;*IK$…ØûìãĪ0p\sÂ{*ÏôÖ‡Ø\mЬ1´#Q¥æ§¶èðZˆ³y›|J=}6ˆûš 7…éžJú5Tªhw/Ç CpÂb#íb†ˆ¬¡ºàÙæ(™ÍÓ,„ÞêÉ„©éÑ’Œ^¾aËZmˆ>ĺY¥]ýÈ…rDñu +$>äá`¹Öš!*ŽHÕjM@%ŸIZ/R*‘é^l5b)dÅ[,Û’Äa1fï¸ö #0ëÔxÎMb_Ô¤ì[,ÚØæ«â`.–Šh+͘ր|o +h–p!êºæAÁšßV;Ð Ú·NHv¼Ò9’ -æÄ©`òx™e—f—xþŠp×âÌc:Ð;àœ#ô$}¯­!mà”*rë[ +¡{bݼX«Õ=ì ¶byîFÜWïAµP¶ÖŒ­+vÌp^Ævo,å¡oÌ´xž¯ÈpƒHËYZÚ“¶g^=Ù”Cs<©p }-zPؼQ×ìTY=™Ë1óÚiS±™|Ûë’E: íaê,(¹'c7Ó‘yÂ9ä¢dq·f+n†öн)™S7áâ3(‰vbñüi)ÊsNCÆDñx:§™R²û¤óI}æ‘ÿñμš¯æ/²ô¤r +endstream +endobj + +3 0 obj + 797 +endobj + +4 0 obj + << /Annots [] + /Type /Page + /MediaBox [ 0.000000 0.000000 30.000000 30.000000 ] + /Resources 1 0 R + /Contents 2 0 R + /Parent 5 0 R + >> +endobj + +5 0 obj + << /Kids [ 4 0 R ] + /Count 1 + /Type /Pages + >> +endobj + +6 0 obj + << /Pages 5 0 R + /Type /Catalog + >> +endobj + +xref +0 7 +0000000000 65535 f +0000000010 00000 n +0000000034 00000 n +0000000915 00000 n +0000000937 00000 n +0000001110 00000 n +0000001184 00000 n +trailer +<< /ID [ (some) (id) ] + /Root 6 0 R + /Size 7 +>> +startxref +1243 +%%EOF \ No newline at end of file diff --git a/submodules/TelegramUI/Sources/AccountContext.swift b/submodules/TelegramUI/Sources/AccountContext.swift index 31c602eaee..2b14a05883 100644 --- a/submodules/TelegramUI/Sources/AccountContext.swift +++ b/submodules/TelegramUI/Sources/AccountContext.swift @@ -578,8 +578,8 @@ public final class AccountContextImpl: AccountContext { } } - public func scheduleGroupCall(peerId: PeerId) { - let _ = self.sharedContext.callManager?.scheduleGroupCall(context: self, peerId: peerId, endCurrentIfAny: true) + public func scheduleGroupCall(peerId: PeerId, parentController: ViewController) { + let _ = self.sharedContext.callManager?.scheduleGroupCall(context: self, peerId: peerId, endCurrentIfAny: true, parentController: parentController) } public func joinGroupCall(peerId: PeerId, invite: String?, requestJoinAsPeerId: ((@escaping (PeerId?) -> Void) -> Void)?, activeCall: EngineGroupCallDescription) { diff --git a/submodules/TelegramUI/Sources/AppDelegate.swift b/submodules/TelegramUI/Sources/AppDelegate.swift index b39774b00d..1e853ce12b 100644 --- a/submodules/TelegramUI/Sources/AppDelegate.swift +++ b/submodules/TelegramUI/Sources/AppDelegate.swift @@ -1753,7 +1753,7 @@ private func extractAccountManagerState(records: AccountRecordsView Void)? - init(postbox: Postbox, audioSession: ManagedAudioSession, manager: UniversalVideoManager, content: UniversalVideoContent, close: @escaping () -> Void) { + init(accountId: AccountRecordId, postbox: Postbox, audioSession: ManagedAudioSession, manager: UniversalVideoManager, content: UniversalVideoContent, close: @escaping () -> Void) { self.close = close self.content = content var togglePlayPauseImpl: (() -> Void)? let decoration = OverlayInstantVideoDecoration(tapped: { togglePlayPauseImpl?() }) - self.videoNode = UniversalVideoNode(postbox: postbox, audioSession: audioSession, manager: manager, decoration: decoration, content: content, priority: .secondaryOverlay, snapshotContentWhenGone: true) + self.videoNode = UniversalVideoNode(accountId: accountId, postbox: postbox, audioSession: audioSession, manager: manager, decoration: decoration, content: content, priority: .secondaryOverlay, snapshotContentWhenGone: true) self.decoration = decoration super.init() diff --git a/submodules/TelegramUI/Sources/SharedAccountContext.swift b/submodules/TelegramUI/Sources/SharedAccountContext.swift index 1fcc5ef531..5d212212f5 100644 --- a/submodules/TelegramUI/Sources/SharedAccountContext.swift +++ b/submodules/TelegramUI/Sources/SharedAccountContext.swift @@ -72,6 +72,8 @@ import StarsWithdrawalScreen import MiniAppListScreen import GiftOptionsScreen import GiftViewScreen +import StarsIntroScreen +import ContentReportScreen private final class AccountUserInterfaceInUseContext { let subscribers = Bag<(Bool) -> Void>() @@ -2205,8 +2207,6 @@ public final class SharedAccountContextImpl: SharedAccountContext { public func makePremiumGiftController(context: AccountContext, source: PremiumGiftSource, completion: (([EnginePeer.Id]) -> Void)?) -> ViewController { let presentationData = context.sharedContext.currentPresentationData.with { $0 } -// let limit: Int32 = 10 -// var reachedLimitImpl: ((Int32) -> Void)? var presentBirthdayPickerImpl: (() -> Void)? var starsMode: ContactSelectionControllerMode = .generic var currentBirthdays: [EnginePeer.Id: TelegramBirthday]? @@ -2268,9 +2268,9 @@ public final class SharedAccountContextImpl: SharedAccountContext { )) let _ = combineLatest(queue: Queue.mainQueue(), contactsController.result, options.get()) .startStandalone(next: { [weak contactsController] result, options in - if let (peers, _, _, _, _, _) = result, let contactPeer = peers.first, case let .peer(peer, _, _) = contactPeer { + if let (peers, _, _, _, _, _) = result, let contactPeer = peers.first, case let .peer(peer, _, _) = contactPeer, let starsContext = context.starsContext { let premiumOptions = options.filter { $0.users == 1 }.map { CachedPremiumGiftOption(months: $0.months, currency: $0.currency, amount: $0.amount, botUrl: "", storeProductId: $0.storeProductId) } - let giftController = GiftOptionsScreen(context: context, peerId: peer.id, premiumOptions: premiumOptions) + let giftController = GiftOptionsScreen(context: context, starsContext: starsContext, peerId: peer.id, premiumOptions: premiumOptions) giftController.navigationPresentation = .modal contactsController?.push(giftController) @@ -2812,10 +2812,23 @@ public final class SharedAccountContextImpl: SharedAccountContext { return StarsTransactionScreen(context: context, subject: .boost(peerId, boost)) } + public func makeStarsIntroScreen(context: AccountContext) -> ViewController { + return StarsIntroScreen(context: context) + } + public func makeGiftViewScreen(context: AccountContext, message: EngineMessage) -> ViewController { return GiftViewScreen(context: context, subject: .message(message)) } + public func makeContentReportScreen(context: AccountContext, subject: ReportContentSubject, forceDark: Bool, present: @escaping (ViewController) -> Void, completion: @escaping () -> Void) { + let _ = (context.engine.messages.reportContent(subject: subject, option: nil, message: nil) + |> deliverOnMainQueue).startStandalone(next: { result in + if case let .options(title, options) = result { + present(ContentReportScreen(context: context, subject: subject, title: title, options: options, forceDark: forceDark, completed: completion)) + } + }) + } + public func makeMiniAppListScreenInitialData(context: AccountContext) -> Signal { return MiniAppListScreen.initialData(context: context) } diff --git a/submodules/TelegramUI/Sources/SharedMediaPlayer.swift b/submodules/TelegramUI/Sources/SharedMediaPlayer.swift index a5961e2876..6c6229f201 100644 --- a/submodules/TelegramUI/Sources/SharedMediaPlayer.swift +++ b/submodules/TelegramUI/Sources/SharedMediaPlayer.swift @@ -236,7 +236,7 @@ final class SharedMediaPlayer { if let mediaManager = strongSelf.mediaManager, let item = item as? MessageMediaPlaylistItem { switch playbackData.source { case let .telegramFile(fileReference, _, _): - let videoNode = OverlayInstantVideoNode(postbox: strongSelf.account.postbox, audioSession: strongSelf.audioSession, manager: mediaManager.universalVideoManager, content: NativeVideoContent(id: .message(item.message.stableId, fileReference.media.fileId), userLocation: .peer(item.message.id.peerId), fileReference: fileReference, enableSound: false, baseRate: rateValue, isAudioVideoMessage: true, captureProtected: item.message.isCopyProtected(), storeAfterDownload: nil), close: { [weak mediaManager] in + let videoNode = OverlayInstantVideoNode(accountId: strongSelf.account.id, postbox: strongSelf.account.postbox, audioSession: strongSelf.audioSession, manager: mediaManager.universalVideoManager, content: NativeVideoContent(id: .message(item.message.stableId, fileReference.media.fileId), userLocation: .peer(item.message.id.peerId), fileReference: fileReference, enableSound: false, baseRate: rateValue, isAudioVideoMessage: true, captureProtected: item.message.isCopyProtected(), storeAfterDownload: nil), close: { [weak mediaManager] in mediaManager?.setPlaylist(nil, type: .voice, control: .playback(.pause)) }) strongSelf.playbackItem = .instantVideo(videoNode) diff --git a/submodules/TelegramUniversalVideoContent/BUILD b/submodules/TelegramUniversalVideoContent/BUILD index 9baefbc0b0..b705ae876b 100644 --- a/submodules/TelegramUniversalVideoContent/BUILD +++ b/submodules/TelegramUniversalVideoContent/BUILD @@ -24,6 +24,7 @@ swift_library( "//submodules/AppBundle:AppBundle", "//submodules/Utils/RangeSet:RangeSet", "//submodules/TelegramVoip", + "//submodules/ManagedFile", ], visibility = [ "//visibility:public", diff --git a/submodules/TelegramUniversalVideoContent/Sources/HLSVideoContent.swift b/submodules/TelegramUniversalVideoContent/Sources/HLSVideoContent.swift index f04db2abf9..3cd8c3b4e3 100644 --- a/submodules/TelegramUniversalVideoContent/Sources/HLSVideoContent.swift +++ b/submodules/TelegramUniversalVideoContent/Sources/HLSVideoContent.swift @@ -12,6 +12,7 @@ import AccountContext import PhotoResources import RangeSet import TelegramVoip +import ManagedFile public final class HLSVideoContent: UniversalVideoContent { public let id: AnyHashable @@ -40,8 +41,8 @@ public final class HLSVideoContent: UniversalVideoContent { self.fetchAutomatically = fetchAutomatically } - public func makeContentNode(postbox: Postbox, audioSession: ManagedAudioSession) -> UniversalVideoContentNode & ASDisplayNode { - return HLSVideoContentNode(postbox: postbox, audioSessionManager: audioSession, userLocation: self.userLocation, fileReference: self.fileReference, streamVideo: self.streamVideo, loopVideo: self.loopVideo, enableSound: self.enableSound, baseRate: self.baseRate, fetchAutomatically: self.fetchAutomatically) + public func makeContentNode(accountId: AccountRecordId, postbox: Postbox, audioSession: ManagedAudioSession) -> UniversalVideoContentNode & ASDisplayNode { + return HLSVideoContentNode(accountId: accountId, postbox: postbox, audioSessionManager: audioSession, userLocation: self.userLocation, fileReference: self.fileReference, streamVideo: self.streamVideo, loopVideo: self.loopVideo, enableSound: self.enableSound, baseRate: self.baseRate, fetchAutomatically: self.fetchAutomatically) } public func isEqual(to other: UniversalVideoContent) -> Bool { @@ -60,7 +61,7 @@ public final class HLSVideoContent: UniversalVideoContent { private final class HLSVideoContentNode: ASDisplayNode, UniversalVideoContentNode { private final class HLSServerSource: SharedHLSServer.Source { - let id: UUID + let id: String let postbox: Postbox let userLocation: MediaResourceUserLocation let playlistFiles: [Int: FileMediaReference] @@ -68,8 +69,8 @@ private final class HLSVideoContentNode: ASDisplayNode, UniversalVideoContentNod private var playlistFetchDisposables: [Int: Disposable] = [:] - init(id: UUID, postbox: Postbox, userLocation: MediaResourceUserLocation, playlistFiles: [Int: FileMediaReference], qualityFiles: [Int: FileMediaReference]) { - self.id = id + init(accountId: Int64, fileId: Int64, postbox: Postbox, userLocation: MediaResourceUserLocation, playlistFiles: [Int: FileMediaReference], qualityFiles: [Int: FileMediaReference]) { + self.id = "\(UInt64(bitPattern: accountId))_\(fileId)" self.postbox = postbox self.userLocation = userLocation self.playlistFiles = playlistFiles @@ -143,10 +144,11 @@ private final class HLSVideoContentNode: ASDisplayNode, UniversalVideoContentNod return .never() } - func fileData(id: Int64, range: Range) -> Signal<(Data, Int)?, NoError> { - guard let file = self.qualityFiles.values.first(where: { $0.media.fileId.id == id }) else { + func fileData(id: Int64, range: Range) -> Signal<(TempBoxFile, Range, Int)?, NoError> { + guard let (quality, file) = self.qualityFiles.first(where: { $0.value.media.fileId.id == id }) else { return .single(nil) } + let _ = quality guard let size = file.media.size else { return .single(nil) } @@ -156,77 +158,80 @@ private final class HLSVideoContentNode: ASDisplayNode, UniversalVideoContentNod let mappedRange: Range = Int64(range.lowerBound) ..< Int64(range.upperBound) - return Signal { subscriber in - if let fetchResource = postbox.mediaBox.fetchResource { - let location = MediaResourceStorageLocation(userLocation: userLocation, reference: file.resourceReference(file.media.resource)) - let params = MediaResourceFetchParameters( - tag: TelegramMediaResourceFetchTag(statsCategory: .video, userContentType: .video), - info: TelegramCloudMediaResourceFetchInfo(reference: file.resourceReference(file.media.resource), preferBackgroundReferenceRevalidation: true, continueInBackground: true), - location: location, - contentType: .video, - isRandomAccessAllowed: true - ) - - final class StoredState { - let range: Range - var data: Data - var ranges: RangeSet - - init(range: Range) { - self.range = range - self.data = Data(count: Int(range.upperBound - range.lowerBound)) - self.ranges = RangeSet(range) - } - } - let storedState = Atomic(value: StoredState(range: mappedRange)) - - return fetchResource(file.media.resource, .single([(mappedRange, .elevated)]), params).start(next: { result in - switch result { - case let .dataPart(resourceOffset, data, _, _): - if !data.isEmpty { - let partRange = resourceOffset ..< (resourceOffset + Int64(data.count)) - var isReady = false - storedState.with { storedState in - let overlapRange = partRange.clamped(to: storedState.range) - guard !overlapRange.isEmpty else { - return - } - let innerRange = (overlapRange.lowerBound - storedState.range.lowerBound) ..< (overlapRange.upperBound - storedState.range.lowerBound) - let dataStart = overlapRange.lowerBound - partRange.lowerBound - let dataEnd = overlapRange.upperBound - partRange.lowerBound - let innerData = data.subdata(in: Int(dataStart) ..< Int(dataEnd)) - storedState.data.replaceSubrange(Int(innerRange.lowerBound) ..< Int(innerRange.upperBound), with: innerData) - storedState.ranges.subtract(RangeSet(overlapRange)) - if storedState.ranges.isEmpty { - isReady = true - } - } - if isReady { - subscriber.putNext((storedState.with({ $0.data }), Int(size))) - subscriber.putCompletion() - } - } - default: - break - } - }) - } else { + let queue = postbox.mediaBox.dataQueue + return Signal<(TempBoxFile, Range, Int)?, NoError> { subscriber in + guard let fetchResource = postbox.mediaBox.fetchResource else { return EmptyDisposable } - /*let fetchDisposable = freeMediaFileResourceInteractiveFetched(postbox: postbox, userLocation: userLocation, fileReference: file, resource: file.media.resource, range: (mappedRange, .elevated)).startStandalone() + let location = MediaResourceStorageLocation(userLocation: userLocation, reference: file.resourceReference(file.media.resource)) + let params = MediaResourceFetchParameters( + tag: TelegramMediaResourceFetchTag(statsCategory: .video, userContentType: .video), + info: TelegramCloudMediaResourceFetchInfo(reference: file.resourceReference(file.media.resource), preferBackgroundReferenceRevalidation: true, continueInBackground: true), + location: location, + contentType: .video, + isRandomAccessAllowed: true + ) - let dataDisposable = postbox.mediaBox.resourceData(file.media.resource, size: size, in: mappedRange).startStandalone(next: { value, isComplete in - if isComplete { - subscriber.putNext((value, Int(size))) - subscriber.putCompletion() + let completeFile = TempBox.shared.tempFile(fileName: "data") + let partialFile = TempBox.shared.tempFile(fileName: "data") + let metaFile = TempBox.shared.tempFile(fileName: "data") + + guard let fileContext = MediaBoxFileContextV2Impl( + queue: queue, + manager: postbox.mediaBox.dataFileManager, + storageBox: nil, + resourceId: file.media.resource.id.stringRepresentation.data(using: .utf8)!, + path: completeFile.path, + partialPath: partialFile.path, + metaPath: metaFile.path + ) else { + return EmptyDisposable + } + + let fetchDisposable = fileContext.fetched( + range: mappedRange, + priority: .default, + fetch: { intervals in + return fetchResource(file.media.resource, intervals, params) + }, + error: { _ in + }, + completed: { } - }) + ) + + #if DEBUG + let startTime = CFAbsoluteTimeGetCurrent() + #endif + + let dataDisposable = fileContext.data( + range: mappedRange, + waitUntilAfterInitialFetch: true, + next: { result in + if result.complete { + #if DEBUG + let fetchTime = CFAbsoluteTimeGetCurrent() - startTime + print("Fetching \(quality)p part took \(fetchTime * 1000.0) ms") + #endif + subscriber.putNext((partialFile, Int(result.offset) ..< Int(result.offset + result.size), Int(size))) + subscriber.putCompletion() + } + } + ) + return ActionDisposable { - fetchDisposable.dispose() - dataDisposable.dispose() - }*/ + queue.async { + fetchDisposable.dispose() + dataDisposable.dispose() + fileContext.cancelFullRangeFetches() + + TempBox.shared.dispose(completeFile) + TempBox.shared.dispose(metaFile) + } + } } + |> runOn(queue) } } @@ -244,6 +249,7 @@ private final class HLSVideoContentNode: ASDisplayNode, UniversalVideoContentNod private var initializedStatus = false private var statusValue = MediaPlayerStatus(generationTimestamp: 0.0, duration: 0.0, dimensions: CGSize(), timestamp: 0.0, baseRate: 1.0, seekId: 0, status: .paused, soundEnabled: true) + private var baseRate: Double = 1.0 private var isBuffering = false private var seekId: Int = 0 private let _status = ValuePromise() @@ -272,7 +278,7 @@ private final class HLSVideoContentNode: ASDisplayNode, UniversalVideoContentNod private let imageNode: TransformImageNode private var playerItem: AVPlayerItem? - private let player: AVPlayer + private var player: AVPlayer? private let playerNode: ASDisplayNode private var loadProgressDisposable: Disposable? @@ -296,24 +302,38 @@ private final class HLSVideoContentNode: ASDisplayNode, UniversalVideoContentNod private var preferredVideoQuality: UniversalVideoContentVideoQuality = .auto - init(postbox: Postbox, audioSessionManager: ManagedAudioSession, userLocation: MediaResourceUserLocation, fileReference: FileMediaReference, streamVideo: Bool, loopVideo: Bool, enableSound: Bool, baseRate: Double, fetchAutomatically: Bool) { + init(accountId: AccountRecordId, postbox: Postbox, audioSessionManager: ManagedAudioSession, userLocation: MediaResourceUserLocation, fileReference: FileMediaReference, streamVideo: Bool, loopVideo: Bool, enableSound: Bool, baseRate: Double, fetchAutomatically: Bool) { self.postbox = postbox self.fileReference = fileReference self.approximateDuration = fileReference.media.duration ?? 0.0 self.audioSessionManager = audioSessionManager self.userLocation = userLocation + self.baseRate = baseRate + + if var dimensions = fileReference.media.dimensions { + if let thumbnail = fileReference.media.previewRepresentations.first { + let dimensionsVertical = dimensions.width < dimensions.height + let thumbnailVertical = thumbnail.dimensions.width < thumbnail.dimensions.height + if dimensionsVertical != thumbnailVertical { + dimensions = PixelDimensions(width: dimensions.height, height: dimensions.width) + } + } + self.dimensions = dimensions.cgSize + } else { + self.dimensions = CGSize(width: 128.0, height: 128.0) + } self.imageNode = TransformImageNode() - var startTime = CFAbsoluteTimeGetCurrent() - - let player = AVPlayer(playerItem: nil) + var player: AVPlayer? + player = AVPlayer(playerItem: nil) self.player = player - if !enableSound { - player.volume = 0.0 + if #available(iOS 16.0, *) { + player?.defaultRate = Float(baseRate) + } + if !enableSound { + player?.volume = 0.0 } - - print("Player created in \((CFAbsoluteTimeGetCurrent() - startTime) * 1000.0) ms") self.playerNode = ASDisplayNode() self.playerNode.setLayerBlock({ @@ -363,10 +383,9 @@ private final class HLSVideoContentNode: ASDisplayNode, UniversalVideoContentNod } } if !playlistFiles.isEmpty && playlistFiles.keys == qualityFiles.keys { - self.playerSource = HLSServerSource(id: UUID(), postbox: postbox, userLocation: userLocation, playlistFiles: playlistFiles, qualityFiles: qualityFiles) + self.playerSource = HLSServerSource(accountId: accountId.int64, fileId: fileReference.media.fileId.id, postbox: postbox, userLocation: userLocation, playlistFiles: playlistFiles, qualityFiles: qualityFiles) } - super.init() self.imageNode.setSignal(internalMediaGridMessageVideo(postbox: postbox, userLocation: self.userLocation, videoReference: fileReference) |> map { [weak self] getSize, getData in @@ -386,49 +405,39 @@ private final class HLSVideoContentNode: ASDisplayNode, UniversalVideoContentNod self.addSubnode(self.imageNode) self.addSubnode(self.playerNode) - self.player.actionAtItemEnd = .pause + self.player?.actionAtItemEnd = .pause self.imageNode.imageUpdated = { [weak self] _ in self?._ready.set(.single(Void())) } - self.player.addObserver(self, forKeyPath: "rate", options: [], context: nil) + self.player?.addObserver(self, forKeyPath: "rate", options: [], context: nil) self._bufferingStatus.set(.single(nil)) - startTime = CFAbsoluteTimeGetCurrent() - if let playerSource = self.playerSource { - self.serverDisposable = SharedHLSServer.shared.registerPlayer(source: playerSource) - - let playerItem: AVPlayerItem - let assetUrl = "http://127.0.0.1:\(SharedHLSServer.shared.port)/\(playerSource.id)/master.m3u8" - #if DEBUG - print("HLSVideoContentNode: playing \(assetUrl)") - #endif - playerItem = AVPlayerItem(url: URL(string: assetUrl)!) - print("Player item created in \((CFAbsoluteTimeGetCurrent() - startTime) * 1000.0) ms") - - if #available(iOS 14.0, *) { - playerItem.startsOnFirstEligibleVariant = true - } - - startTime = CFAbsoluteTimeGetCurrent() - self.setPlayerItem(playerItem) - print("Set player item in \((CFAbsoluteTimeGetCurrent() - startTime) * 1000.0) ms") + self.serverDisposable = SharedHLSServer.shared.registerPlayer(source: playerSource, completion: { [weak self] in + Queue.mainQueue().async { + guard let self else { + return + } + + let playerItem: AVPlayerItem + let assetUrl = "http://127.0.0.1:\(SharedHLSServer.shared.port)/\(playerSource.id)/master.m3u8" + #if DEBUG + print("HLSVideoContentNode: playing \(assetUrl)") + #endif + playerItem = AVPlayerItem(url: URL(string: assetUrl)!) + + if #available(iOS 14.0, *) { + playerItem.startsOnFirstEligibleVariant = true + } + + self.setPlayerItem(playerItem) + } + }) } - self.didPlayToEndTimeObserver = NotificationCenter.default.addObserver(forName: NSNotification.Name.AVPlayerItemDidPlayToEndTime, object: self.player.currentItem, queue: nil, using: { [weak self] notification in - self?.performActionAtEnd() - }) - - self.failureObserverId = NotificationCenter.default.addObserver(forName: AVPlayerItem.failedToPlayToEndTimeNotification, object: self.player.currentItem, queue: .main, using: { notification in - print("Player Error: \(notification.description)") - }) - self.errorObserverId = NotificationCenter.default.addObserver(forName: AVPlayerItem.newErrorLogEntryNotification, object: self.player.currentItem, queue: .main, using: { notification in - print("Player Error: \(notification.description)") - }) - self.didBecomeActiveObserver = NotificationCenter.default.addObserver(forName: UIApplication.willEnterForegroundNotification, object: nil, queue: nil, using: { [weak self] _ in guard let strongSelf = self, let layer = strongSelf.playerNode.layer as? AVPlayerLayer else { return @@ -441,16 +450,10 @@ private final class HLSVideoContentNode: ASDisplayNode, UniversalVideoContentNod } layer.player = nil }) - if let currentItem = self.player.currentItem { - currentItem.addObserver(self, forKeyPath: "presentationSize", options: [], context: nil) - } } deinit { - self.player.removeObserver(self, forKeyPath: "rate") - if let currentItem = self.player.currentItem { - currentItem.removeObserver(self, forKeyPath: "presentationSize") - } + self.player?.removeObserver(self, forKeyPath: "rate") self.setPlayerItem(nil) @@ -459,15 +462,16 @@ private final class HLSVideoContentNode: ASDisplayNode, UniversalVideoContentNod self.loadProgressDisposable?.dispose() self.statusDisposable?.dispose() - if let didPlayToEndTimeObserver = self.didPlayToEndTimeObserver { - NotificationCenter.default.removeObserver(didPlayToEndTimeObserver) - } if let didBecomeActiveObserver = self.didBecomeActiveObserver { NotificationCenter.default.removeObserver(didBecomeActiveObserver) } if let willResignActiveObserver = self.willResignActiveObserver { NotificationCenter.default.removeObserver(willResignActiveObserver) } + + if let didPlayToEndTimeObserver = self.didPlayToEndTimeObserver { + NotificationCenter.default.removeObserver(didPlayToEndTimeObserver) + } if let failureObserverId = self.failureObserverId { NotificationCenter.default.removeObserver(failureObserverId) } @@ -486,14 +490,53 @@ private final class HLSVideoContentNode: ASDisplayNode, UniversalVideoContentNod playerItem.removeObserver(self, forKeyPath: "playbackLikelyToKeepUp") playerItem.removeObserver(self, forKeyPath: "playbackBufferFull") playerItem.removeObserver(self, forKeyPath: "status") - if let playerItemFailedToPlayToEndTimeObserver = self.playerItemFailedToPlayToEndTimeObserver { - NotificationCenter.default.removeObserver(playerItemFailedToPlayToEndTimeObserver) - self.playerItemFailedToPlayToEndTimeObserver = nil - } + playerItem.removeObserver(self, forKeyPath: "presentationSize") + } + + if let playerItemFailedToPlayToEndTimeObserver = self.playerItemFailedToPlayToEndTimeObserver { + self.playerItemFailedToPlayToEndTimeObserver = nil + NotificationCenter.default.removeObserver(playerItemFailedToPlayToEndTimeObserver) + } + + if let didPlayToEndTimeObserver = self.didPlayToEndTimeObserver { + self.didPlayToEndTimeObserver = nil + NotificationCenter.default.removeObserver(didPlayToEndTimeObserver) + } + if let failureObserverId = self.failureObserverId { + self.failureObserverId = nil + NotificationCenter.default.removeObserver(failureObserverId) + } + if let errorObserverId = self.errorObserverId { + self.errorObserverId = nil + NotificationCenter.default.removeObserver(errorObserverId) } self.playerItem = item + if let item { + self.didPlayToEndTimeObserver = NotificationCenter.default.addObserver(forName: NSNotification.Name.AVPlayerItemDidPlayToEndTime, object: item, queue: nil, using: { [weak self] notification in + self?.performActionAtEnd() + }) + + self.failureObserverId = NotificationCenter.default.addObserver(forName: AVPlayerItem.failedToPlayToEndTimeNotification, object: item, queue: .main, using: { notification in +#if DEBUG + print("Player Error: \(notification.description)") +#endif + }) + self.errorObserverId = NotificationCenter.default.addObserver(forName: AVPlayerItem.newErrorLogEntryNotification, object: item, queue: .main, using: { [weak item] notification in + if let item { + let event = item.errorLog()?.events.last + if let event { + let _ = event +#if DEBUG + print("Player Error: \(event.errorComment ?? "")") +#endif + } + } + }) + item.addObserver(self, forKeyPath: "presentationSize", options: [], context: nil) + } + if let playerItem = self.playerItem { playerItem.addObserver(self, forKeyPath: "playbackBufferEmpty", options: .new, context: nil) playerItem.addObserver(self, forKeyPath: "playbackLikelyToKeepUp", options: .new, context: nil) @@ -507,23 +550,26 @@ private final class HLSVideoContentNode: ASDisplayNode, UniversalVideoContentNod }) } - self.player.replaceCurrentItem(with: self.playerItem) + self.player?.replaceCurrentItem(with: self.playerItem) } private func updateStatus() { - let isPlaying = !self.player.rate.isZero + guard let player = self.player else { + return + } + let isPlaying = !player.rate.isZero let status: MediaPlayerPlaybackStatus if self.isBuffering { status = .buffering(initial: false, whilePlaying: isPlaying, progress: 0.0, display: true) } else { status = isPlaying ? .playing : .paused } - var timestamp = self.player.currentTime().seconds + var timestamp = player.currentTime().seconds if timestamp.isFinite && !timestamp.isNaN { } else { timestamp = 0.0 } - self.statusValue = MediaPlayerStatus(generationTimestamp: CACurrentMediaTime(), duration: Double(self.approximateDuration), dimensions: CGSize(), timestamp: timestamp, baseRate: Double(self.player.rate), seekId: self.seekId, status: status, soundEnabled: true) + self.statusValue = MediaPlayerStatus(generationTimestamp: CACurrentMediaTime(), duration: Double(self.approximateDuration), dimensions: CGSize(), timestamp: timestamp, baseRate: self.baseRate, seekId: self.seekId, status: status, soundEnabled: true) self._status.set(self.statusValue) if case .playing = status { @@ -543,9 +589,11 @@ private final class HLSVideoContentNode: ASDisplayNode, UniversalVideoContentNod override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) { if keyPath == "rate" { - let isPlaying = !self.player.rate.isZero - if isPlaying { - self.isBuffering = false + if let player = self.player { + let isPlaying = !player.rate.isZero + if isPlaying { + self.isBuffering = false + } } self.updateStatus() } else if keyPath == "playbackBufferEmpty" { @@ -555,7 +603,7 @@ private final class HLSVideoContentNode: ASDisplayNode, UniversalVideoContentNod self.isBuffering = false self.updateStatus() } else if keyPath == "presentationSize" { - if let currentItem = self.player.currentItem { + if let currentItem = self.player?.currentItem { print("Presentation size: \(Int(currentItem.presentationSize.height))") } } @@ -573,42 +621,57 @@ private final class HLSVideoContentNode: ASDisplayNode, UniversalVideoContentNod transition.updateFrame(node: self.imageNode, frame: CGRect(origin: CGPoint(), size: size)) - let makeImageLayout = self.imageNode.asyncLayout() - let applyImageLayout = makeImageLayout(TransformImageArguments(corners: ImageCorners(), imageSize: size, boundingSize: size, intrinsicInsets: UIEdgeInsets())) - applyImageLayout() + if let dimensions = self.dimensions { + let imageSize = CGSize(width: floor(dimensions.width / 2.0), height: floor(dimensions.height / 2.0)) + let makeLayout = self.imageNode.asyncLayout() + let applyLayout = makeLayout(TransformImageArguments(corners: ImageCorners(), imageSize: imageSize, boundingSize: imageSize, intrinsicInsets: UIEdgeInsets(), emptyColor: .clear)) + applyLayout() + } } func play() { assert(Queue.mainQueue().isCurrent()) if !self.initializedStatus { - self._status.set(MediaPlayerStatus(generationTimestamp: 0.0, duration: Double(self.approximateDuration), dimensions: CGSize(), timestamp: 0.0, baseRate: 1.0, seekId: self.seekId, status: .buffering(initial: true, whilePlaying: true, progress: 0.0, display: true), soundEnabled: true)) + self._status.set(MediaPlayerStatus(generationTimestamp: 0.0, duration: Double(self.approximateDuration), dimensions: CGSize(), timestamp: 0.0, baseRate: self.baseRate, seekId: self.seekId, status: .buffering(initial: true, whilePlaying: true, progress: 0.0, display: true), soundEnabled: true)) } if !self.hasAudioSession { - if self.player.volume != 0.0 { + if self.player?.volume != 0.0 { self.audioSessionDisposable.set(self.audioSessionManager.push(audioSessionType: .play(mixWithOthers: false), activate: { [weak self] _ in - self?.hasAudioSession = true - self?.player.play() + guard let self else { + return + } + self.hasAudioSession = true + self.player?.play() }, deactivate: { [weak self] _ in - self?.hasAudioSession = false - self?.player.pause() + guard let self else { + return .complete() + } + self.hasAudioSession = false + self.player?.pause() + return .complete() })) } else { - self.player.play() + self.player?.play() } } else { - self.player.play() + self.player?.play() } } func pause() { assert(Queue.mainQueue().isCurrent()) - self.player.pause() + self.player?.pause() } func togglePlayPause() { assert(Queue.mainQueue().isCurrent()) - if self.player.rate.isZero { + + guard let player = self.player else { + return + } + + if player.rate.isZero { self.play() } else { self.pause() @@ -621,15 +684,15 @@ private final class HLSVideoContentNode: ASDisplayNode, UniversalVideoContentNod if !self.hasAudioSession { self.audioSessionDisposable.set(self.audioSessionManager.push(audioSessionType: .play(mixWithOthers: false), activate: { [weak self] _ in self?.hasAudioSession = true - self?.player.volume = 1.0 + self?.player?.volume = 1.0 }, deactivate: { [weak self] _ in self?.hasAudioSession = false - self?.player.pause() + self?.player?.pause() return .complete() })) } } else { - self.player.volume = 0.0 + self.player?.volume = 0.0 self.hasAudioSession = false self.audioSessionDisposable.set(nil) } @@ -638,16 +701,16 @@ private final class HLSVideoContentNode: ASDisplayNode, UniversalVideoContentNod func seek(_ timestamp: Double) { assert(Queue.mainQueue().isCurrent()) self.seekId += 1 - self.player.seek(to: CMTime(seconds: timestamp, preferredTimescale: 30)) + self.player?.seek(to: CMTime(seconds: timestamp, preferredTimescale: 30)) } func playOnceWithSound(playAndRecord: Bool, seek: MediaPlayerSeek, actionAtEnd: MediaPlayerPlayOnceWithSoundActionAtEnd) { - self.player.volume = 1.0 + self.player?.volume = 1.0 self.play() } func setSoundMuted(soundMuted: Bool) { - self.player.volume = soundMuted ? 0.0 : 1.0 + self.player?.volume = soundMuted ? 0.0 : 1.0 } func continueWithOverridingAmbientMode(isAmbient: Bool) { @@ -657,7 +720,7 @@ private final class HLSVideoContentNode: ASDisplayNode, UniversalVideoContentNod } func continuePlayingWithoutSound(actionAtEnd: MediaPlayerPlayOnceWithSoundActionAtEnd) { - self.player.volume = 0.0 + self.player?.volume = 0.0 self.hasAudioSession = false self.audioSessionDisposable.set(nil) } @@ -666,13 +729,23 @@ private final class HLSVideoContentNode: ASDisplayNode, UniversalVideoContentNod } func setBaseRate(_ baseRate: Double) { - self.player.rate = Float(baseRate) + guard let player = self.player else { + return + } + self.baseRate = baseRate + if #available(iOS 16.0, *) { + player.defaultRate = Float(baseRate) + } + if player.rate != 0.0 { + player.rate = Float(baseRate) + } + self.updateStatus() } func setVideoQuality(_ videoQuality: UniversalVideoContentVideoQuality) { self.preferredVideoQuality = videoQuality - guard let currentItem = self.player.currentItem else { + guard let currentItem = self.player?.currentItem else { return } guard let playerSource = self.playerSource else { @@ -694,7 +767,7 @@ private final class HLSVideoContentNode: ASDisplayNode, UniversalVideoContentNod } func videoQualityState() -> (current: Int, preferred: UniversalVideoContentVideoQuality, available: [Int])? { - guard let currentItem = self.player.currentItem else { + guard let currentItem = self.player?.currentItem else { return nil } guard let playerSource = self.playerSource else { diff --git a/submodules/TelegramUniversalVideoContent/Sources/NativeVideoContent.swift b/submodules/TelegramUniversalVideoContent/Sources/NativeVideoContent.swift index 6bc07d1c0c..e7fd264a92 100644 --- a/submodules/TelegramUniversalVideoContent/Sources/NativeVideoContent.swift +++ b/submodules/TelegramUniversalVideoContent/Sources/NativeVideoContent.swift @@ -137,7 +137,7 @@ public final class NativeVideoContent: UniversalVideoContent { self.hasSentFramesToDisplay = hasSentFramesToDisplay } - public func makeContentNode(postbox: Postbox, audioSession: ManagedAudioSession) -> UniversalVideoContentNode & ASDisplayNode { + public func makeContentNode(accountId: AccountRecordId, postbox: Postbox, audioSession: ManagedAudioSession) -> UniversalVideoContentNode & ASDisplayNode { return NativeVideoContentNode(postbox: postbox, audioSessionManager: audioSession, userLocation: self.userLocation, fileReference: self.fileReference, imageReference: self.imageReference, streamVideo: self.streamVideo, loopVideo: self.loopVideo, enableSound: self.enableSound, soundMuted: self.soundMuted, beginWithAmbientSound: self.beginWithAmbientSound, mixWithOthers: self.mixWithOthers, baseRate: self.baseRate, baseVideoQuality: self.baseVideoQuality, fetchAutomatically: self.fetchAutomatically, onlyFullSizeThumbnail: self.onlyFullSizeThumbnail, useLargeThumbnail: self.useLargeThumbnail, autoFetchFullSizeThumbnail: self.autoFetchFullSizeThumbnail, startTimestamp: self.startTimestamp, endTimestamp: self.endTimestamp, continuePlayingWithoutSoundOnLostAudioSession: self.continuePlayingWithoutSoundOnLostAudioSession, placeholderColor: self.placeholderColor, tempFilePath: self.tempFilePath, isAudioVideoMessage: self.isAudioVideoMessage, captureProtected: self.captureProtected, hintDimensions: self.hintDimensions, storeAfterDownload: self.storeAfterDownload, displayImage: self.displayImage, hasSentFramesToDisplay: self.hasSentFramesToDisplay) } diff --git a/submodules/TelegramUniversalVideoContent/Sources/OverlayUniversalVideoNode.swift b/submodules/TelegramUniversalVideoContent/Sources/OverlayUniversalVideoNode.swift index 1d8cc7c36a..d71b1eec99 100644 --- a/submodules/TelegramUniversalVideoContent/Sources/OverlayUniversalVideoNode.swift +++ b/submodules/TelegramUniversalVideoContent/Sources/OverlayUniversalVideoNode.swift @@ -41,7 +41,7 @@ public final class OverlayUniversalVideoNode: OverlayMediaItemNode, AVPictureInP private var statusDisposable: Disposable? private var status: MediaPlayerStatus? - public init(postbox: Postbox, audioSession: ManagedAudioSession, manager: UniversalVideoManager, content: UniversalVideoContent, shouldBeDismissed: Signal = .single(false), expand: @escaping () -> Void, close: @escaping () -> Void) { + public init(accountId: AccountRecordId, postbox: Postbox, audioSession: ManagedAudioSession, manager: UniversalVideoManager, content: UniversalVideoContent, shouldBeDismissed: Signal = .single(false), expand: @escaping () -> Void, close: @escaping () -> Void) { self.content = content self.defaultExpand = expand @@ -62,7 +62,7 @@ public final class OverlayUniversalVideoNode: OverlayMediaItemNode, AVPictureInP }, controlsAreShowingUpdated: { value in controlsAreShowingUpdatedImpl?(value) }) - self.videoNode = UniversalVideoNode(postbox: postbox, audioSession: audioSession, manager: manager, decoration: decoration, content: content, priority: .overlay) + self.videoNode = UniversalVideoNode(accountId: accountId, postbox: postbox, audioSession: audioSession, manager: manager, decoration: decoration, content: content, priority: .overlay) self.decoration = decoration super.init() diff --git a/submodules/TelegramUniversalVideoContent/Sources/PlatformVideoContent.swift b/submodules/TelegramUniversalVideoContent/Sources/PlatformVideoContent.swift index f7a6529cb1..e673a81ed4 100644 --- a/submodules/TelegramUniversalVideoContent/Sources/PlatformVideoContent.swift +++ b/submodules/TelegramUniversalVideoContent/Sources/PlatformVideoContent.swift @@ -95,7 +95,7 @@ public final class PlatformVideoContent: UniversalVideoContent { self.fetchAutomatically = fetchAutomatically } - public func makeContentNode(postbox: Postbox, audioSession: ManagedAudioSession) -> UniversalVideoContentNode & ASDisplayNode { + public func makeContentNode(accountId: AccountRecordId, postbox: Postbox, audioSession: ManagedAudioSession) -> UniversalVideoContentNode & ASDisplayNode { return PlatformVideoContentNode(postbox: postbox, audioSessionManager: audioSession, userLocation: self.userLocation, content: self.content, streamVideo: self.streamVideo, loopVideo: self.loopVideo, enableSound: self.enableSound, baseRate: self.baseRate, fetchAutomatically: self.fetchAutomatically) } diff --git a/submodules/TelegramUniversalVideoContent/Sources/SystemVideoContent.swift b/submodules/TelegramUniversalVideoContent/Sources/SystemVideoContent.swift index 66bca4d094..8f436da4c4 100644 --- a/submodules/TelegramUniversalVideoContent/Sources/SystemVideoContent.swift +++ b/submodules/TelegramUniversalVideoContent/Sources/SystemVideoContent.swift @@ -29,7 +29,7 @@ public final class SystemVideoContent: UniversalVideoContent { self.duration = duration } - public func makeContentNode(postbox: Postbox, audioSession: ManagedAudioSession) -> UniversalVideoContentNode & ASDisplayNode { + public func makeContentNode(accountId: AccountRecordId, postbox: Postbox, audioSession: ManagedAudioSession) -> UniversalVideoContentNode & ASDisplayNode { return SystemVideoContentNode(postbox: postbox, audioSessionManager: audioSession, userLocation: self.userLocation, url: self.url, imageReference: self.imageReference, intrinsicDimensions: self.dimensions, approximateDuration: self.duration) } } diff --git a/submodules/TelegramUniversalVideoContent/Sources/WebEmbedVideoContent.swift b/submodules/TelegramUniversalVideoContent/Sources/WebEmbedVideoContent.swift index 2cba6fdf64..5b204ce3d0 100644 --- a/submodules/TelegramUniversalVideoContent/Sources/WebEmbedVideoContent.swift +++ b/submodules/TelegramUniversalVideoContent/Sources/WebEmbedVideoContent.swift @@ -36,7 +36,7 @@ public final class WebEmbedVideoContent: UniversalVideoContent { self.openUrl = openUrl } - public func makeContentNode(postbox: Postbox, audioSession: ManagedAudioSession) -> UniversalVideoContentNode & ASDisplayNode { + public func makeContentNode(accountId: AccountRecordId, postbox: Postbox, audioSession: ManagedAudioSession) -> UniversalVideoContentNode & ASDisplayNode { return WebEmbedVideoContentNode(postbox: postbox, audioSessionManager: audioSession, userLocation: self.userLocation, webPage: self.webPage, webpageContent: self.webpageContent, forcedTimestamp: self.forcedTimestamp, openUrl: self.openUrl) } } diff --git a/submodules/TelegramVoip/BUILD b/submodules/TelegramVoip/BUILD index 62595d2fb6..ab815d0e7f 100644 --- a/submodules/TelegramVoip/BUILD +++ b/submodules/TelegramVoip/BUILD @@ -18,6 +18,7 @@ swift_library( "//submodules/TgVoip:TgVoip", "//submodules/TgVoipWebrtc:TgVoipWebrtc", "//submodules/FFMpegBinding", + "//submodules/ManagedFile", ], visibility = [ "//visibility:public", diff --git a/submodules/TelegramVoip/Sources/WrappedMediaStreamingContext.swift b/submodules/TelegramVoip/Sources/WrappedMediaStreamingContext.swift index 1acafab9a5..8610235ba8 100644 --- a/submodules/TelegramVoip/Sources/WrappedMediaStreamingContext.swift +++ b/submodules/TelegramVoip/Sources/WrappedMediaStreamingContext.swift @@ -5,7 +5,7 @@ import TelegramCore import Network import Postbox import FFMpegBinding - +import ManagedFile @available(iOS 12.0, macOS 14.0, *) public final class WrappedMediaStreamingContext { @@ -275,7 +275,7 @@ public final class ExternalMediaStreamingContext: SharedHLSServerSource { } } - func fileData(id: Int64, range: Range) -> Signal<(Data, Int)?, NoError> { + func fileData(id: Int64, range: Range) -> Signal<(TempBoxFile, Range, Int)?, NoError> { return .never() } } @@ -285,8 +285,8 @@ public final class ExternalMediaStreamingContext: SharedHLSServerSource { private let impl: QueueLocalObject private var hlsServerDisposable: Disposable? - public var id: UUID { - return self.internalId + public var id: String { + return self.internalId.uuidString } public init(id: CallSessionInternalId, rejoinNeeded: @escaping () -> Void) { @@ -296,7 +296,7 @@ public final class ExternalMediaStreamingContext: SharedHLSServerSource { return Impl(queue: queue, rejoinNeeded: rejoinNeeded) }) - self.hlsServerDisposable = SharedHLSServer.shared.registerPlayer(source: self) + self.hlsServerDisposable = SharedHLSServer.shared.registerPlayer(source: self, completion: {}) } deinit { @@ -331,7 +331,7 @@ public final class ExternalMediaStreamingContext: SharedHLSServerSource { } } - public func fileData(id: Int64, range: Range) -> Signal<(Data, Int)?, NoError> { + public func fileData(id: Int64, range: Range) -> Signal<(TempBoxFile, Range, Int)?, NoError> { return self.impl.signalWith { impl, subscriber in impl.fileData(id: id, range: range).start(next: subscriber.putNext) } @@ -339,12 +339,12 @@ public final class ExternalMediaStreamingContext: SharedHLSServerSource { } public protocol SharedHLSServerSource: AnyObject { - var id: UUID { get } + var id: String { get } func masterPlaylistData() -> Signal func playlistData(quality: Int) -> Signal func partData(index: Int, quality: Int) -> Signal - func fileData(id: Int64, range: Range) -> Signal<(Data, Int)?, NoError> + func fileData(id: Int64, range: Range) -> Signal<(TempBoxFile, Range, Int)?, NoError> } @available(iOS 12.0, macOS 14.0, *) @@ -387,14 +387,66 @@ public final class SharedHLSServer { private var listener: NWListener? private var sourceReferences = Bag() + private var referenceCheckTimer: SwiftSignalKit.Timer? + private var shutdownTimer: SwiftSignalKit.Timer? init(queue: Queue, port: UInt16) { self.queue = queue self.port = NWEndpoint.Port(rawValue: port)! - self.start() } - func start() { + deinit { + self.referenceCheckTimer?.invalidate() + self.shutdownTimer?.invalidate() + } + + private func updateNeedsListener() { + var isEmpty = true + for item in self.sourceReferences.copyItems() { + if let _ = item.source { + isEmpty = false + break + } + } + + if isEmpty { + if self.listener != nil { + if self.shutdownTimer == nil { + self.shutdownTimer = SwiftSignalKit.Timer(timeout: 1.0, repeat: false, completion: { [weak self] in + guard let self else { + return + } + self.shutdownTimer = nil + self.stopListener() + }, queue: self.queue) + self.shutdownTimer?.start() + } + } + if let referenceCheckTimer = self.referenceCheckTimer { + self.referenceCheckTimer = nil + referenceCheckTimer.invalidate() + } + } else { + if let shutdownTimer = self.shutdownTimer { + self.shutdownTimer = nil + shutdownTimer.invalidate() + } + if self.listener == nil { + self.startListener() + } + if self.referenceCheckTimer == nil { + self.referenceCheckTimer = SwiftSignalKit.Timer(timeout: 1.0, repeat: true, completion: { [weak self] in + guard let self else { + return + } + self.updateNeedsListener() + }, queue: self.queue) + self.referenceCheckTimer?.start() + } + } + } + + private func startListener() { let listener: NWListener do { listener = try NWListener(using: .tcp, on: self.port) @@ -411,8 +463,8 @@ public final class SharedHLSServer { self.handleConnection(connection: connection) } - listener.stateUpdateHandler = { [weak self] state in - guard let self else { + listener.stateUpdateHandler = { [weak self, weak listener] state in + guard let self, let listener else { return } switch state { @@ -420,9 +472,9 @@ public final class SharedHLSServer { Logger.shared.log("SharedHLSServer", "Server is ready on port \(self.port)") case let .failed(error): Logger.shared.log("SharedHLSServer", "Server failed with error: \(error)") - self.listener?.cancel() + listener.cancel() - self.listener?.start(queue: self.queue.queue) + listener.start(queue: self.queue.queue) default: break } @@ -431,9 +483,17 @@ public final class SharedHLSServer { listener.start(queue: self.queue.queue) } + private func stopListener() { + guard let listener = self.listener else { + return + } + self.listener = nil + listener.cancel() + } + private func handleConnection(connection: NWConnection) { connection.start(queue: self.queue.queue) - connection.receive(minimumIncompleteLength: 1, maximumLength: 1024, completion: { [weak self] data, _, isComplete, error in + connection.receive(minimumIncompleteLength: 1, maximumLength: 32 * 1024, completion: { [weak self] data, _, isComplete, error in guard let self else { return } @@ -488,10 +548,7 @@ public final class SharedHLSServer { self.sendErrorAndClose(connection: connection, error: .notFound) return } - guard let streamId = UUID(uuidString: String(requestPath[requestPath.startIndex ..< firstSlash.lowerBound])) else { - self.sendErrorAndClose(connection: connection) - return - } + let streamId = String(requestPath[requestPath.startIndex ..< firstSlash.lowerBound]) guard let source = self.sourceReferences.copyItems().first(where: { $0.source?.id == streamId })?.source else { self.sendErrorAndClose(connection: connection) return @@ -581,13 +638,14 @@ public final class SharedHLSServer { } let _ = (source.fileData(id: fileIdValue, range: requestRange.lowerBound ..< requestRange.upperBound + 1) |> deliverOn(self.queue) + //|> timeout(5.0, queue: self.queue, alternate: .single(nil)) |> take(1)).start(next: { [weak self] result in guard let self else { return } - if let (data, totalSize) = result { - self.sendResponseAndClose(connection: connection, data: data, range: requestRange, totalSize: totalSize) + if let (tempFile, tempFileRange, totalSize) = result { + self.sendResponseFileAndClose(connection: connection, file: tempFile, fileRange: tempFileRange, range: requestRange, totalSize: totalSize) } else { self.sendErrorAndClose(connection: connection, error: .internalServerError) } @@ -628,9 +686,62 @@ public final class SharedHLSServer { }) } - func registerPlayer(source: SharedHLSServerSource) -> Disposable { + private static func sendRemainingFileRange(queue: Queue, connection: NWConnection, tempFile: TempBoxFile, managedFile: ManagedFile, remainingRange: Range, fileSize: Int) -> Void { + let blockSize = 256 * 1024 + + let clippedLowerBound = min(remainingRange.lowerBound, fileSize) + var clippedUpperBound = min(remainingRange.upperBound, fileSize) + clippedUpperBound = min(clippedUpperBound, clippedLowerBound + blockSize) + + if clippedUpperBound == clippedLowerBound { + TempBox.shared.dispose(tempFile) + connection.cancel() + } else { + let _ = managedFile.seek(position: Int64(clippedLowerBound)) + let data = managedFile.readData(count: Int(clippedUpperBound - clippedLowerBound)) + let nextRange = clippedUpperBound ..< remainingRange.upperBound + + connection.send(content: data, completion: .contentProcessed { error in + queue.async { + if let error { + Logger.shared.log("SharedHLSServer", "Failed to send response: \(error)") + connection.cancel() + TempBox.shared.dispose(tempFile) + } else { + sendRemainingFileRange(queue: queue, connection: connection, tempFile: tempFile, managedFile: managedFile, remainingRange: nextRange, fileSize: fileSize) + } + } + }) + } + } + + private func sendResponseFileAndClose(connection: NWConnection, file: TempBoxFile, fileRange: Range, range: Range, totalSize: Int) { + let queue = self.queue + + guard let managedFile = ManagedFile(queue: nil, path: file.path, mode: .read), let fileSize = managedFile.getSize() else { + self.sendErrorAndClose(connection: connection, error: .internalServerError) + TempBox.shared.dispose(file) + return + } + + var responseHeaders = "HTTP/1.1 200 OK\r\n" + responseHeaders.append("Content-Length: \(fileRange.upperBound - fileRange.lowerBound)\r\n") + responseHeaders.append("Content-Range: bytes \(range.lowerBound)-\(range.upperBound)/\(totalSize)\r\n") + responseHeaders.append("Content-Type: application/octet-stream\r\n") + responseHeaders.append("Connection: close\r\n") + responseHeaders.append("Access-Control-Allow-Origin: *\r\n") + responseHeaders.append("\r\n") + + connection.send(content: responseHeaders.data(using: .utf8)!, completion: .contentProcessed({ _ in })) + + Impl.sendRemainingFileRange(queue: queue, connection: connection, tempFile: file, managedFile: managedFile, remainingRange: fileRange, fileSize: Int(fileSize)) + } + + func registerPlayer(source: SharedHLSServerSource, completion: @escaping () -> Void) -> Disposable { let queue = self.queue let index = self.sourceReferences.add(SourceReference(source: source)) + self.updateNeedsListener() + completion() return ActionDisposable { [weak self] in queue.async { @@ -638,6 +749,7 @@ public final class SharedHLSServer { return } self.sourceReferences.remove(index) + self.updateNeedsListener() } } } @@ -655,11 +767,11 @@ public final class SharedHLSServer { }) } - public func registerPlayer(source: SharedHLSServerSource) -> Disposable { + public func registerPlayer(source: SharedHLSServerSource, completion: @escaping () -> Void) -> Disposable { let disposable = MetaDisposable() self.impl.with { impl in - disposable.set(impl.registerPlayer(source: source)) + disposable.set(impl.registerPlayer(source: source, completion: completion)) } return disposable diff --git a/submodules/Utils/DeviceModel/Sources/DeviceModel.swift b/submodules/Utils/DeviceModel/Sources/DeviceModel.swift index ba30871c3b..78cdfc5018 100644 --- a/submodules/Utils/DeviceModel/Sources/DeviceModel.swift +++ b/submodules/Utils/DeviceModel/Sources/DeviceModel.swift @@ -48,7 +48,9 @@ public enum DeviceModel: CaseIterable, Equatable { .iPhone15, .iPhone15Plus, .iPhone15Pro, - .iPhone15ProMax + .iPhone15ProMax, + .iPhone16Pro, + .iPhone16ProMax ] } @@ -116,6 +118,9 @@ public enum DeviceModel: CaseIterable, Equatable { case iPhone15Pro case iPhone15ProMax + case iPhone16Pro + case iPhone16ProMax + case unknown(String) public var modelId: [String] { @@ -218,6 +223,10 @@ public enum DeviceModel: CaseIterable, Equatable { return ["iPhone16,1"] case .iPhone15ProMax: return ["iPhone16,2"] + case .iPhone16Pro: + return ["iPhone17,1"] + case .iPhone16ProMax: + return ["iPhone17,2"] case let .unknown(modelId): return [modelId] } @@ -323,6 +332,10 @@ public enum DeviceModel: CaseIterable, Equatable { return "iPhone 15 Pro" case .iPhone15ProMax: return "iPhone 15 Pro Max" + case .iPhone16Pro: + return "iPhone 16 Pro" + case .iPhone16ProMax: + return "iPhone 16 Pro Max" case let .unknown(modelId): if modelId.hasPrefix("iPhone") { return "Unknown iPhone" diff --git a/submodules/WebSearchUI/Sources/WebSearchVideoGalleryItem.swift b/submodules/WebSearchUI/Sources/WebSearchVideoGalleryItem.swift index 142323c37d..d3696d5d0d 100644 --- a/submodules/WebSearchUI/Sources/WebSearchVideoGalleryItem.swift +++ b/submodules/WebSearchUI/Sources/WebSearchVideoGalleryItem.swift @@ -165,7 +165,7 @@ final class WebSearchVideoGalleryItemNode: ZoomableContentGalleryItemNode { let mediaManager = item.context.sharedContext.mediaManager - let videoNode = UniversalVideoNode(postbox: item.context.account.postbox, audioSession: mediaManager.audioSession, manager: mediaManager.universalVideoManager, decoration: GalleryVideoDecoration(), content: item.content, priority: .gallery) + let videoNode = UniversalVideoNode(accountId: item.context.account.id, postbox: item.context.account.postbox, audioSession: mediaManager.audioSession, manager: mediaManager.universalVideoManager, decoration: GalleryVideoDecoration(), content: item.content, priority: .gallery) let videoSize = CGSize(width: item.content.dimensions.width * 2.0, height: item.content.dimensions.height * 2.0) videoNode.updateLayout(size: videoSize, transition: .immediate) self.videoNode = videoNode diff --git a/versions.json b/versions.json index 2d777b2e9e..bc1c16abd1 100644 --- a/versions.json +++ b/versions.json @@ -1,6 +1,6 @@ { - "app": "11.1.1", - "xcode": "15.2", + "app": "11.1.2", + "xcode": "16.0", "bazel": "7.3.1", - "macos": "13.0" + "macos": "15.0" }