From 254aea276b4c0a9b3cec2baeb01aac63f88fbdb7 Mon Sep 17 00:00:00 2001 From: Isaac <> Date: Tue, 29 Apr 2025 16:43:18 +0200 Subject: [PATCH 01/29] Update shimmer effect --- .../ShimmerEffect/Sources/ShimmerEffect.swift | 41 ++++++++++++------- 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/submodules/ShimmerEffect/Sources/ShimmerEffect.swift b/submodules/ShimmerEffect/Sources/ShimmerEffect.swift index 5b37a045b5..2c586b59b0 100644 --- a/submodules/ShimmerEffect/Sources/ShimmerEffect.swift +++ b/submodules/ShimmerEffect/Sources/ShimmerEffect.swift @@ -174,6 +174,10 @@ public final class ShimmerEffectForegroundView: UIView { } } +private let shadowImage: UIImage? = { + UIImage(named: "Stories/PanelGradient") +}() + public final class ShimmerEffectForegroundNode: ASDisplayNode { private var currentBackgroundColor: UIColor? private var currentForegroundColor: UIColor? @@ -232,23 +236,32 @@ public final class ShimmerEffectForegroundNode: ASDisplayNode { let image: UIImage? if horizontal { - image = generateImage(CGSize(width: effectSize ?? 320.0, height: 16.0), opaque: false, scale: 1.0, rotatedContext: { size, context in + let baseAlpha: CGFloat = 0.1 + image = generateImage(CGSize(width: effectSize ?? 200.0, height: 16.0), opaque: false, scale: 1.0, rotatedContext: { size, context in context.clear(CGRect(origin: CGPoint(), size: size)) - context.setFillColor(backgroundColor.cgColor) - context.fill(CGRect(origin: CGPoint(), size: size)) - context.clip(to: CGRect(origin: CGPoint(), size: size)) + let foregroundColor = UIColor(white: 1.0, alpha: min(1.0, baseAlpha * 4.0)) - let transparentColor = foregroundColor.withAlphaComponent(0.0).cgColor - let peakColor = foregroundColor.cgColor - - var locations: [CGFloat] = [0.0, 0.5, 1.0] - let colors: [CGColor] = [transparentColor, peakColor, transparentColor] - - let colorSpace = CGColorSpaceCreateDeviceRGB() - let gradient = CGGradient(colorsSpace: colorSpace, colors: colors as CFArray, locations: &locations)! - - context.drawLinearGradient(gradient, start: CGPoint(x: 0.0, y: 0.0), end: CGPoint(x: size.width, y: 0.0), options: CGGradientDrawingOptions()) + if let shadowImage { + UIGraphicsPushContext(context) + + for i in 0 ..< 2 { + let shadowFrame = CGRect(origin: CGPoint(x: CGFloat(i) * (size.width * 0.5), y: 0.0), size: CGSize(width: size.width * 0.5, height: size.height)) + + context.saveGState() + context.translateBy(x: shadowFrame.midX, y: shadowFrame.midY) + context.rotate(by: CGFloat(i == 0 ? 1.0 : -1.0) * CGFloat.pi * 0.5) + let adjustedRect = CGRect(origin: CGPoint(x: -shadowFrame.height * 0.5, y: -shadowFrame.width * 0.5), size: CGSize(width: shadowFrame.height, height: shadowFrame.width)) + + context.clip(to: adjustedRect, mask: shadowImage.cgImage!) + context.setFillColor(foregroundColor.cgColor) + context.fill(adjustedRect) + + context.restoreGState() + } + + UIGraphicsPopContext() + } }) } else { image = generateImage(CGSize(width: 16.0, height: 320.0), opaque: false, scale: 1.0, rotatedContext: { size, context in From 9a2aff5b1e3e4d51516d2198fa8f356e4b3d021f Mon Sep 17 00:00:00 2001 From: Isaac <> Date: Tue, 29 Apr 2025 16:43:39 +0200 Subject: [PATCH 02/29] Update lottie-cpp --- submodules/LottieCpp/lottiecpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/submodules/LottieCpp/lottiecpp b/submodules/LottieCpp/lottiecpp index b885e63e76..4a3144b5d5 160000 --- a/submodules/LottieCpp/lottiecpp +++ b/submodules/LottieCpp/lottiecpp @@ -1 +1 @@ -Subproject commit b885e63e766890d1cbf36b66cfe27cca55a6ec90 +Subproject commit 4a3144b5d527429f7bbd0f07003cb372bf8939ce From d4443f9682bfa5ba72b1d311dd2862c07a8857e6 Mon Sep 17 00:00:00 2001 From: Isaac <> Date: Tue, 29 Apr 2025 16:44:43 +0200 Subject: [PATCH 03/29] Improve story video playback --- .../Sources/ChunkMediaPlayerV2.swift | 184 ++++--- .../Components/LivestreamVideoViewV1.swift | 2 +- .../Sources/StoryContent.swift | 2 + .../Sources/StoryItemContentComponent.swift | 452 ++++++++++++++---- .../StoryItemSetContainerComponent.swift | 15 +- .../Sources/HLSVideoJSNativeContentNode.swift | 2 +- .../Sources/NativeVideoContent.swift | 2 +- 7 files changed, 515 insertions(+), 144 deletions(-) diff --git a/submodules/MediaPlayer/Sources/ChunkMediaPlayerV2.swift b/submodules/MediaPlayer/Sources/ChunkMediaPlayerV2.swift index 2026c7b8c7..9825eb7b4c 100644 --- a/submodules/MediaPlayer/Sources/ChunkMediaPlayerV2.swift +++ b/submodules/MediaPlayer/Sources/ChunkMediaPlayerV2.swift @@ -33,6 +33,101 @@ private final class ChunkMediaPlayerExternalSourceImpl: ChunkMediaPlayerSourceIm } public final class ChunkMediaPlayerV2: ChunkMediaPlayer { + public final class AudioContext { + fileprivate let audioSessionManager: ManagedAudioSession + private var audioSessionDisposable: Disposable? + private(set) var hasAudioSession: Bool = false + private(set) var isAmbientMode: Bool = false + private(set) var isInitialized: Bool = false + + private var updatedListeners = Bag<() -> Void>() + + public init( + audioSessionManager: ManagedAudioSession + ) { + self.audioSessionManager = audioSessionManager + } + + deinit { + self.audioSessionDisposable?.dispose() + } + + func onUpdated(_ f: @escaping () -> Void) -> Disposable { + let index = self.updatedListeners.add(f) + return ActionDisposable { [weak self] in + Queue.mainQueue().async { + guard let self else { + return + } + self.updatedListeners.remove(index) + } + } + } + + func setIsAmbient(isAmbient: Bool) { + self.hasAudioSession = false + + for f in self.updatedListeners.copyItems() { + f() + } + + self.audioSessionDisposable?.dispose() + self.audioSessionDisposable = nil + } + + func update(type: ManagedAudioSessionType?) { + if let type { + if self.audioSessionDisposable == nil { + self.isInitialized = true + + self.audioSessionDisposable = self.audioSessionManager.push(params: ManagedAudioSessionClientParams( + audioSessionType: type, + activateImmediately: false, + manualActivate: { [weak self] control in + control.setupAndActivate(synchronous: false, { state in + Queue.mainQueue().async { + guard let self else { + return + } + self.hasAudioSession = true + for f in self.updatedListeners.copyItems() { + f() + } + } + }) + }, + deactivate: { [weak self] _ in + return Signal { subscriber in + guard let self else { + subscriber.putCompletion() + return EmptyDisposable + } + + self.hasAudioSession = false + for f in self.updatedListeners.copyItems() { + f() + } + subscriber.putCompletion() + + return EmptyDisposable + } + |> runOn(.mainQueue()) + }, + headsetConnectionStatusChanged: { _ in }, + availableOutputsChanged: { _, _ in } + )) + } + } else { + if let audioSessionDisposable = self.audioSessionDisposable { + self.audioSessionDisposable = nil + audioSessionDisposable.dispose() + } + + self.hasAudioSession = false + } + } + } + public enum SourceDescription { public final class ResourceDescription { public let postbox: Postbox @@ -166,10 +261,10 @@ public final class ChunkMediaPlayerV2: ChunkMediaPlayer { private let dataQueue: Queue private let mediaDataReaderParams: MediaDataReaderParams - private let audioSessionManager: ManagedAudioSession private let onSeeked: (() -> Void)? private weak var playerNode: MediaPlayerNode? + private let audioContext: AudioContext private let renderSynchronizer: AVSampleBufferRenderSynchronizer private var videoRenderer: AVSampleBufferDisplayLayer private var audioRenderer: AVSampleBufferAudioRenderer? @@ -198,13 +293,20 @@ public final class ChunkMediaPlayerV2: ChunkMediaPlayer { } public var actionAtEnd: MediaPlayerActionAtEnd = .stop + public weak var migrateToNextPlayerOnEnd: ChunkMediaPlayerV2? { + didSet { + if self.migrateToNextPlayerOnEnd !== oldValue { + self.updateInternalState() + } + } + } private var didSeekOnce: Bool = false private var isPlaying: Bool = false private var baseRate: Double = 1.0 private var isSoundEnabled: Bool private var isMuted: Bool - private var isAmbientMode: Bool + private var initialIsAmbient: Bool private var seekId: Int = 0 private var seekTimestamp: Double = 0.0 @@ -223,12 +325,11 @@ public final class ChunkMediaPlayerV2: ChunkMediaPlayer { private var partsStateDisposable: Disposable? private var updateTimer: Foundation.Timer? - private var audioSessionDisposable: Disposable? - private var hasAudioSession: Bool = false + private var audioContextUpdatedDisposable: Disposable? public init( params: MediaDataReaderParams, - audioSessionManager: ManagedAudioSession, + audioContext: AudioContext, source: SourceDescription, video: Bool, playAutomatically: Bool = false, @@ -247,7 +348,7 @@ public final class ChunkMediaPlayerV2: ChunkMediaPlayer { self.dataQueue = ChunkMediaPlayerV2.sharedDataQueue self.mediaDataReaderParams = params - self.audioSessionManager = audioSessionManager + self.audioContext = audioContext self.onSeeked = onSeeked self.playerNode = playerNode @@ -257,7 +358,7 @@ public final class ChunkMediaPlayerV2: ChunkMediaPlayer { self.isSoundEnabled = enableSound self.isMuted = soundMuted - self.isAmbientMode = ambient + self.initialIsAmbient = ambient self.baseRate = baseRate self.renderSynchronizer = AVSampleBufferRenderSynchronizer() @@ -296,12 +397,19 @@ public final class ChunkMediaPlayerV2: ChunkMediaPlayer { } else { self.renderSynchronizer.addRenderer(self.videoRenderer) } + + self.audioContextUpdatedDisposable = self.audioContext.onUpdated({ [weak self] in + guard let self else { + return + } + self.updateInternalState() + }) } deinit { self.partsStateDisposable?.dispose() self.updateTimer?.invalidate() - self.audioSessionDisposable?.dispose() + self.audioContextUpdatedDisposable?.dispose() if #available(iOS 17.0, *) { self.videoRenderer.sampleBufferRenderer.stopRequestingMediaData() @@ -321,51 +429,19 @@ public final class ChunkMediaPlayerV2: ChunkMediaPlayer { } private func updateInternalState() { + var audioSessionType: ManagedAudioSessionType? if self.isSoundEnabled && self.hasSound { - if self.audioSessionDisposable == nil { - self.audioSessionDisposable = self.audioSessionManager.push(params: ManagedAudioSessionClientParams( - audioSessionType: self.isAmbientMode ? .ambient : .play(mixWithOthers: false), - activateImmediately: false, - manualActivate: { [weak self] control in - control.setupAndActivate(synchronous: false, { state in - Queue.mainQueue().async { - guard let self else { - return - } - self.hasAudioSession = true - self.updateInternalState() - } - }) - }, - deactivate: { [weak self] _ in - return Signal { subscriber in - guard let self else { - subscriber.putCompletion() - return EmptyDisposable - } - - self.hasAudioSession = false - self.updateInternalState() - subscriber.putCompletion() - - return EmptyDisposable - } - |> runOn(.mainQueue()) - }, - headsetConnectionStatusChanged: { _ in }, - availableOutputsChanged: { _, _ in } - )) + let isAmbient: Bool + if self.audioContext.isInitialized { + isAmbient = self.audioContext.isAmbientMode + } else { + isAmbient = self.initialIsAmbient } - } else { - if let audioSessionDisposable = self.audioSessionDisposable { - self.audioSessionDisposable = nil - audioSessionDisposable.dispose() - } - - self.hasAudioSession = false + audioSessionType = isAmbient ? .ambient : .play(mixWithOthers: false) } + self.audioContext.update(type: audioSessionType) - if self.isSoundEnabled && self.hasSound && self.hasAudioSession { + if self.isSoundEnabled && self.hasSound && self.audioContext.hasAudioSession { if self.audioRenderer == nil { let audioRenderer = AVSampleBufferAudioRenderer() audioRenderer.isMuted = self.isMuted @@ -799,13 +875,9 @@ public final class ChunkMediaPlayerV2: ChunkMediaPlayer { } public func continueWithOverridingAmbientMode(isAmbient: Bool) { - if self.isAmbientMode != isAmbient { - self.isAmbientMode = isAmbient - - self.hasAudioSession = false - self.updateInternalState() - self.audioSessionDisposable?.dispose() - self.audioSessionDisposable = nil + if self.audioContext.isAmbientMode != isAmbient { + self.initialIsAmbient = isAmbient + self.audioContext.setIsAmbient(isAmbient: isAmbient) let currentTimestamp: CMTime if let pendingSeekTimestamp = self.pendingSeekTimestamp { diff --git a/submodules/TelegramCallsUI/Sources/Components/LivestreamVideoViewV1.swift b/submodules/TelegramCallsUI/Sources/Components/LivestreamVideoViewV1.swift index e9d61c78a6..030ecff2b5 100644 --- a/submodules/TelegramCallsUI/Sources/Components/LivestreamVideoViewV1.swift +++ b/submodules/TelegramCallsUI/Sources/Components/LivestreamVideoViewV1.swift @@ -70,7 +70,7 @@ final class LivestreamVideoViewV1: UIView { var onSeeked: (() -> Void)? self.player = ChunkMediaPlayerV2( params: ChunkMediaPlayerV2.MediaDataReaderParams(context: context), - audioSessionManager: audioSessionManager, + audioContext: ChunkMediaPlayerV2.AudioContext(audioSessionManager: audioSessionManager), source: .externalParts(self.chunkPlayerPartsState.get()), video: true, enableSound: true, diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContent.swift b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContent.swift index b4e4c11784..725a27a253 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContent.swift +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContent.swift @@ -6,6 +6,7 @@ import SwiftSignalKit import TelegramCore import Postbox import TelegramPresentationData +import UniversalMediaPlayer public final class StoryContentItem: Equatable { public final class ExternalState { @@ -32,6 +33,7 @@ public final class StoryContentItem: Equatable { public final class SharedState { public var replyDrafts: [StoryId: NSAttributedString] = [:] public var baseRate: Double = 1.0 + public var audioContext: ChunkMediaPlayerV2.AudioContext? public init() { } diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemContentComponent.swift b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemContentComponent.swift index f7adeea7cd..e50d333e51 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemContentComponent.swift +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemContentComponent.swift @@ -15,6 +15,275 @@ import ButtonComponent import MultilineTextComponent import TelegramPresentationData +private protocol StoryVideoView: UIView { + var audioMode: StoryContentItem.AudioMode { get set } + var playbackCompleted: (() -> Void)? { get set } + var status: Signal { get } + + func play() + func pause() + func seek(timestamp: Double) + func setSoundMuted(soundMuted: Bool) + func continueWithOverridingAmbientMode(isAmbient: Bool) + func setBaseRate(baseRate: Double) + func update(size: CGSize, transition: ComponentTransition) +} + +private final class LegacyStoryVideoView: UIView, StoryVideoView { + private let videoNode: UniversalVideoNode + + var audioMode: StoryContentItem.AudioMode + var playbackCompleted: (() -> Void)? + + var status: Signal { + return self.videoNode.status + } + + init( + context: AccountContext, + file: FileMediaReference, + audioMode: StoryContentItem.AudioMode, + baseRate: Double, + isCaptureProtected: Bool + ) { + self.audioMode = audioMode + + var userLocation: MediaResourceUserLocation = .other + switch file { + case let .story(peer, _, _): + userLocation = .peer(peer.id) + default: + break + } + var hasSentFramesToDisplay: (() -> Void)? + self.videoNode = UniversalVideoNode( + context: context, + postbox: context.account.postbox, + audioSession: context.sharedContext.mediaManager.audioSession, + manager: context.sharedContext.mediaManager.universalVideoManager, + decoration: StoryVideoDecoration(), + content: NativeVideoContent( + id: .contextResult(0, "\(UInt64.random(in: 0 ... UInt64.max))"), + userLocation: userLocation, + fileReference: file, + imageReference: nil, + streamVideo: .story, + loopVideo: true, + enableSound: true, + soundMuted: audioMode == .off, + beginWithAmbientSound: audioMode == .ambient, + mixWithOthers: true, + useLargeThumbnail: false, + autoFetchFullSizeThumbnail: false, + tempFilePath: nil, + captureProtected: isCaptureProtected, + hintDimensions: file.media.dimensions?.cgSize, + storeAfterDownload: nil, + displayImage: false, + hasSentFramesToDisplay: { + hasSentFramesToDisplay?() + } + ), + priority: .gallery + ) + self.videoNode.isHidden = true + self.videoNode.setBaseRate(baseRate) + + super.init(frame: CGRect()) + + hasSentFramesToDisplay = { [weak self] in + guard let self else { + return + } + self.videoNode.isHidden = false + } + + self.videoNode.playbackCompleted = { [weak self] in + guard let self else { + return + } + self.playbackCompleted?() + } + + self.addSubview(self.videoNode.view) + + self.videoNode.ownsContentNodeUpdated = { [weak self] value in + guard let self else { + return + } + if value { + self.videoNode.seek(0.0) + if self.audioMode != .off { + self.videoNode.playOnceWithSound(playAndRecord: false, actionAtEnd: .stop) + } else { + self.videoNode.play() + } + } + } + self.videoNode.canAttachContent = true + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func play() { + self.videoNode.play() + } + + func pause() { + self.videoNode.pause() + } + + func seek(timestamp: Double) { + self.videoNode.seek(timestamp) + } + + func setSoundMuted(soundMuted: Bool) { + self.videoNode.setSoundMuted(soundMuted: soundMuted) + } + + func continueWithOverridingAmbientMode(isAmbient: Bool) { + self.videoNode.continueWithOverridingAmbientMode(isAmbient: isAmbient) + } + + func setBaseRate(baseRate: Double) { + self.videoNode.setBaseRate(baseRate) + } + + func update(size: CGSize, transition: ComponentTransition) { + transition.setFrame(view: self.videoNode.view, frame: CGRect(origin: CGPoint(), size: size)) + self.videoNode.updateLayout(size: size, transition: transition.containedViewLayoutTransition) + } +} + +private final class ModernStoryVideoView: UIView, StoryVideoView { + private let player: ChunkMediaPlayerV2 + private let playerNode: MediaPlayerNode + + var audioMode: StoryContentItem.AudioMode + var playbackCompleted: (() -> Void)? + var isFirstPlay: Bool = true + + var status: Signal { + return self.player.status |> map(Optional.init) + } + + init( + context: AccountContext, + audioContext: ChunkMediaPlayerV2.AudioContext, + file: FileMediaReference, + audioMode: StoryContentItem.AudioMode, + baseRate: Double, + isCaptureProtected: Bool + ) { + self.audioMode = audioMode + + self.playerNode = MediaPlayerNode( + backgroundThread: false, + captureProtected: isCaptureProtected + ) + + var userLocation: MediaResourceUserLocation = .other + switch file { + case let .story(peer, _, _): + userLocation = .peer(peer.id) + default: + break + } + + self.player = ChunkMediaPlayerV2( + params: ChunkMediaPlayerV2.MediaDataReaderParams(context: context), + audioContext: audioContext, + source: .directFetch(ChunkMediaPlayerV2.SourceDescription.ResourceDescription( + postbox: context.account.postbox, + size: file.media.size ?? 0, + reference: file.resourceReference(file.media.resource), + userLocation: userLocation, + userContentType: .story, + statsCategory: statsCategoryForFileWithAttributes(file.media.attributes), + fetchAutomatically: false + )), + video: true, + playAutomatically: false, + enableSound: true, + baseRate: baseRate, + soundMuted: audioMode == .off, + ambient: audioMode == .ambient, + mixWithOthers: true, + continuePlayingWithoutSoundOnLostAudioSession: false, + isAudioVideoMessage: false, + playerNode: self.playerNode + ) + self.playerNode.isHidden = true + self.player.setBaseRate(baseRate) + + super.init(frame: CGRect()) + + self.addSubview(self.playerNode.view) + + self.playerNode.hasSentFramesToDisplay = { [weak self] in + guard let self else { + return + } + self.playerNode.isHidden = false + } + + self.player.actionAtEnd = .action({ [weak self] in + guard let self else { + return + } + self.playbackCompleted?() + }) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func play() { + if self.isFirstPlay { + self.isFirstPlay = false + + if self.audioMode != .off { + self.player.playOnceWithSound(playAndRecord: false, seek: .start) + } else { + self.player.play() + } + } else { + self.player.play() + } + } + + func pause() { + self.player.pause() + } + + func seek(timestamp: Double) { + self.player.seek(timestamp: timestamp, play: nil) + } + + func setSoundMuted(soundMuted: Bool) { + self.player.setSoundMuted(soundMuted: soundMuted) + } + + func continueWithOverridingAmbientMode(isAmbient: Bool) { + self.player.continueWithOverridingAmbientMode(isAmbient: isAmbient) + } + + func setBaseRate(baseRate: Double) { + self.player.setBaseRate(baseRate) + } + + func update(size: CGSize, transition: ComponentTransition) { + transition.containedViewLayoutTransition.updateFrame(node: self.playerNode, frame: CGRect(origin: CGPoint(), size: size)) + } + + func updateNext(nextVideoView: ModernStoryVideoView?) { + self.player.migrateToNextPlayerOnEnd = nextVideoView?.player + } +} + final class StoryItemContentComponent: Component { typealias EnvironmentType = StoryContentItem.Environment @@ -91,10 +360,11 @@ final class StoryItemContentComponent: Component { final class View: StoryContentItem.View { private let imageView: StoryItemImageView private let overlaysView: StoryItemOverlaysView - private var videoNode: UniversalVideoNode? private var loadingEffectView: StoryItemLoadingEffectView? private var loadingEffectAppearanceTimer: SwiftSignalKit.Timer? + private var videoView: StoryVideoView? + private var mediaAreasEffectView: StoryItemLoadingEffectView? private var currentMessageMedia: EngineMedia? @@ -129,6 +399,8 @@ final class StoryItemContentComponent: Component { private var fetchPriorityResourceId: String? private var currentFetchPriority: (isMain: Bool, disposable: Disposable)? + private weak var nextItemView: StoryItemContentComponent.View? + override init(frame: CGRect) { self.hierarchyTrackingLayer = HierarchyTrackingLayer() self.imageView = StoryItemImageView() @@ -186,10 +458,7 @@ final class StoryItemContentComponent: Component { } private func initializeVideoIfReady(update: Bool) { - if self.videoNode != nil { - return - } - if case .pause = self.progressMode { + if self.videoView != nil { return } @@ -197,48 +466,49 @@ final class StoryItemContentComponent: Component { return } + var useLegacyImplementation = false + if let data = component.context.currentAppConfiguration.with({ $0 }).data, let value = data["ios_video_legacystoryplayer"] as? Double { + useLegacyImplementation = value != 0.0 + } + + if case .pause = self.progressMode { + if useLegacyImplementation { + return + } + } + if case let .file(file) = currentMessageMedia, let peerReference = PeerReference(component.peer._asPeer()) { - if self.videoNode == nil { - let videoNode = UniversalVideoNode( - context: component.context, - postbox: component.context.account.postbox, - audioSession: component.context.sharedContext.mediaManager.audioSession, - manager: component.context.sharedContext.mediaManager.universalVideoManager, - decoration: StoryVideoDecoration(), - content: NativeVideoContent( - id: .contextResult(0, "\(UInt64.random(in: 0 ... UInt64.max))"), - userLocation: .peer(peerReference.id), - fileReference: .story(peer: peerReference, id: component.item.id, media: file), - imageReference: nil, - streamVideo: .story, - loopVideo: true, - enableSound: true, - soundMuted: component.audioMode == .off, - beginWithAmbientSound: component.audioMode == .ambient, - mixWithOthers: true, - useLargeThumbnail: false, - autoFetchFullSizeThumbnail: false, - tempFilePath: nil, - captureProtected: component.item.isForwardingDisabled, - hintDimensions: file.dimensions?.cgSize, - storeAfterDownload: nil, - displayImage: false, - hasSentFramesToDisplay: { [weak self] in - guard let self else { - return - } - self.videoNode?.isHidden = false - } - ), - priority: .gallery - ) - videoNode.isHidden = true - videoNode.setBaseRate(component.baseRate) + if self.videoView == nil { + let videoView: StoryVideoView + if useLegacyImplementation { + videoView = LegacyStoryVideoView( + context: component.context, + file: .story(peer: peerReference, id: component.item.id, media: file), + audioMode: component.audioMode, + baseRate: component.baseRate, + isCaptureProtected: component.item.isForwardingDisabled + ) + } else { + let audioContext: ChunkMediaPlayerV2.AudioContext + if let current = self.environment?.sharedState.audioContext { + audioContext = current + } else { + audioContext = ChunkMediaPlayerV2.AudioContext(audioSessionManager: component.context.sharedContext.mediaManager.audioSession) + self.environment?.sharedState.audioContext = audioContext + } + videoView = ModernStoryVideoView( + context: component.context, + audioContext: audioContext, + file: .story(peer: peerReference, id: component.item.id, media: file), + audioMode: component.audioMode, + baseRate: component.baseRate, + isCaptureProtected: component.item.isForwardingDisabled + ) + } + self.videoView = videoView + self.insertSubview(videoView, aboveSubview: self.imageView) - self.videoNode = videoNode - self.insertSubview(videoNode.view, aboveSubview: self.imageView) - - videoNode.playbackCompleted = { [weak self] in + videoView.playbackCompleted = { [weak self] in guard let self else { return } @@ -253,38 +523,24 @@ final class StoryItemContentComponent: Component { if shouldLoop { self.rewind() - if let videoNode = self.videoNode { + if let videoView = self.videoView { if self.contentLoaded { - videoNode.play() + videoView.play() } } } else { self.environment?.presentationProgressUpdated(1.0, false, true) } } - videoNode.ownsContentNodeUpdated = { [weak self] value in - guard let self, let component = self.component else { - return - } - if value { - self.videoNode?.seek(0.0) - if component.audioMode != .off { - self.videoNode?.playOnceWithSound(playAndRecord: false, actionAtEnd: .stop) - } else { - self.videoNode?.play() - } - } - } - videoNode.canAttachContent = true if update { self.state?.updated(transition: .immediate) } } } - if let videoNode = self.videoNode { + if let videoView = self.videoView { if self.videoProgressDisposable == nil { - self.videoProgressDisposable = (videoNode.status + self.videoProgressDisposable = (videoView.status |> deliverOnMainQueue).start(next: { [weak self] status in guard let self, let status else { return @@ -296,7 +552,17 @@ final class StoryItemContentComponent: Component { } }) } + + let canPlay = self.progressMode != .pause && self.contentLoaded && self.hierarchyTrackingLayer.isInHierarchy + + if canPlay { + videoView.play() + } else { + videoView.pause() + } } + + self.updateVideoNextItem() } override func setProgressMode(_ progressMode: StoryContentItem.ProgressMode) { @@ -310,48 +576,62 @@ final class StoryItemContentComponent: Component { } } + func setNextItemView(nextItemView: StoryItemContentComponent.View?) { + if self.nextItemView !== nextItemView { + self.nextItemView = nextItemView + self.updateVideoNextItem() + } + } + + private func updateVideoNextItem() { + if let videoView = self.videoView as? ModernStoryVideoView { + let nextVideoView = self.nextItemView?.videoView as? ModernStoryVideoView + videoView.updateNext(nextVideoView: nextVideoView) + } + } + override func rewind() { self.currentProgressTimerValue = 0.0 - if let videoNode = self.videoNode { + if let videoView = self.videoView { if self.contentLoaded { - videoNode.seek(0.0) + videoView.seek(timestamp: 0.0) } } } override func leaveAmbientMode() { - if let videoNode = self.videoNode { + if let videoView = self.videoView { self.ignoreBufferingTimestamp = CFAbsoluteTimeGetCurrent() - videoNode.setSoundMuted(soundMuted: false) - videoNode.continueWithOverridingAmbientMode(isAmbient: false) + videoView.setSoundMuted(soundMuted: false) + videoView.continueWithOverridingAmbientMode(isAmbient: false) } } override func enterAmbientMode(ambient: Bool) { - if let videoNode = self.videoNode { + if let videoView = self.videoView { self.ignoreBufferingTimestamp = CFAbsoluteTimeGetCurrent() if ambient { - videoNode.continueWithOverridingAmbientMode(isAmbient: true) + videoView.continueWithOverridingAmbientMode(isAmbient: true) } else { - videoNode.setSoundMuted(soundMuted: true) + videoView.setSoundMuted(soundMuted: true) } } } override func setBaseRate(_ baseRate: Double) { - if let videoNode = self.videoNode { - videoNode.setBaseRate(baseRate) + if let videoView = self.videoView { + videoView.setBaseRate(baseRate: baseRate) } } private func updateProgressMode(update: Bool) { - if let videoNode = self.videoNode { + if let videoView = self.videoView { let canPlay = self.progressMode != .pause && self.contentLoaded && self.hierarchyTrackingLayer.isInHierarchy if canPlay { - videoNode.play() + videoView.play() } else { - videoNode.pause() + videoView.pause() } } @@ -566,11 +846,11 @@ final class StoryItemContentComponent: Component { private var isSeeking = false func seekTo(_ timestamp: Double, apply: Bool) { - guard let videoNode = self.videoNode else { + guard let videoView = self.videoView else { return } if apply { - videoNode.seek(min(timestamp, self.effectiveDuration - 0.3)) + videoView.seek(timestamp: min(timestamp, self.effectiveDuration - 0.3)) } self.isSeeking = true self.updateVideoPlaybackProgress(timestamp) @@ -588,6 +868,10 @@ final class StoryItemContentComponent: Component { let environment = environment[StoryContentItem.Environment.self].value self.environment = environment + if let videoView = self.videoView { + videoView.audioMode = component.audioMode + } + var synchronousLoad = false if let hint = transition.userData(Hint.self) { synchronousLoad = hint.synchronousLoad @@ -632,12 +916,12 @@ final class StoryItemContentComponent: Component { self.currentMessageMedia = messageMedia reloadMedia = true - if let videoNode = self.videoNode { + if let videoView = self.videoView { self.videoProgressDisposable?.dispose() self.videoProgressDisposable = nil - self.videoNode = nil - videoNode.view.removeFromSuperview() + self.videoView = nil + videoView.removeFromSuperview() } } self.currentMessageMetadataMedia = component.item.media @@ -767,10 +1051,10 @@ final class StoryItemContentComponent: Component { } let _ = imageSize - if let videoNode = self.videoNode { + if let videoView = self.videoView { let videoSize = dimensions.aspectFilled(availableSize) - videoNode.frame = CGRect(origin: CGPoint(x: floor((availableSize.width - videoSize.width) * 0.5), y: floor((availableSize.height - videoSize.height) * 0.5)), size: videoSize) - videoNode.updateLayout(size: videoSize, transition: .immediate) + videoView.frame = CGRect(origin: CGPoint(x: floor((availableSize.width - videoSize.width) * 0.5), y: floor((availableSize.height - videoSize.height) * 0.5)), size: videoSize) + videoView.update(size: videoSize, transition: .immediate) } } } diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift index 856df6d79b..27b69c1316 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift @@ -1478,7 +1478,7 @@ public final class StoryItemSetContainerComponent: Component { } if itemLayout.contentScaleFraction <= 0.0001 && !self.preparingToDisplayViewList { - if index != centralIndex { + if index != centralIndex && index != centralIndex + 1 { itemVisible = false } } @@ -1870,6 +1870,19 @@ public final class StoryItemSetContainerComponent: Component { } } + for i in 0 ..< component.slice.allItems.count { + guard let visibleItem = self.visibleItems[component.slice.allItems[i].id] else { + continue + } + var nextVisibleItem: VisibleItem? + if i != component.slice.allItems.count { + nextVisibleItem = self.visibleItems[component.slice.allItems[i + 1].id] + } + if let itemView = visibleItem.view.view as? StoryItemContentComponent.View { + itemView.setNextItemView(nextItemView: nextVisibleItem?.view.view as? StoryItemContentComponent.View) + } + } + self.trulyValidIds = trulyValidIds var removeIds: [StoryId] = [] diff --git a/submodules/TelegramUniversalVideoContent/Sources/HLSVideoJSNativeContentNode.swift b/submodules/TelegramUniversalVideoContent/Sources/HLSVideoJSNativeContentNode.swift index 2115c94488..e4dca05f71 100644 --- a/submodules/TelegramUniversalVideoContent/Sources/HLSVideoJSNativeContentNode.swift +++ b/submodules/TelegramUniversalVideoContent/Sources/HLSVideoJSNativeContentNode.swift @@ -1093,7 +1093,7 @@ final class HLSVideoJSNativeContentNode: ASDisplayNode, UniversalVideoContentNod var onSeeked: (() -> Void)? self.player = ChunkMediaPlayerV2( params: ChunkMediaPlayerV2.MediaDataReaderParams(context: context), - audioSessionManager: audioSessionManager, + audioContext: ChunkMediaPlayerV2.AudioContext(audioSessionManager: audioSessionManager), source: .externalParts(self.chunkPlayerPartsState.get()), video: true, enableSound: self.enableSound, diff --git a/submodules/TelegramUniversalVideoContent/Sources/NativeVideoContent.swift b/submodules/TelegramUniversalVideoContent/Sources/NativeVideoContent.swift index e8b67bd497..afb8dadbba 100644 --- a/submodules/TelegramUniversalVideoContent/Sources/NativeVideoContent.swift +++ b/submodules/TelegramUniversalVideoContent/Sources/NativeVideoContent.swift @@ -520,7 +520,7 @@ private final class NativeVideoContentNode: ASDisplayNode, UniversalVideoContent } else { let mediaPlayer = ChunkMediaPlayerV2( params: ChunkMediaPlayerV2.MediaDataReaderParams(context: context), - audioSessionManager: audioSessionManager, + audioContext: ChunkMediaPlayerV2.AudioContext(audioSessionManager: audioSessionManager), source: .directFetch(ChunkMediaPlayerV2.SourceDescription.ResourceDescription( postbox: postbox, size: selectedFile.size ?? 0, From fb853d50b937069bf1cb07e1323959a577a76250 Mon Sep 17 00:00:00 2001 From: Ilya Laktyushin Date: Tue, 29 Apr 2025 18:54:14 +0400 Subject: [PATCH 04/29] Various fixes --- Telegram/Telegram-iOS/en.lproj/Localizable.strings | 2 ++ submodules/PremiumUI/Sources/PremiumLimitScreen.swift | 3 ++- .../Sources/MediaEditorStoryCompletion.swift | 7 +++++-- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/Telegram/Telegram-iOS/en.lproj/Localizable.strings b/Telegram/Telegram-iOS/en.lproj/Localizable.strings index aa26391efb..b806c2063c 100644 --- a/Telegram/Telegram-iOS/en.lproj/Localizable.strings +++ b/Telegram/Telegram-iOS/en.lproj/Localizable.strings @@ -14296,3 +14296,5 @@ Sorry for the inconvenience."; "Gift.Transfer.Unavailable.Title" = "Transfer Gift"; "Gift.Transfer.Unavailable.Text" = "Sorry, you can't transfer this gift yet.\n\Transferring will be available on %@."; + +"Premium.CreateMultipleStories" = "Create Multiple Stories"; diff --git a/submodules/PremiumUI/Sources/PremiumLimitScreen.swift b/submodules/PremiumUI/Sources/PremiumLimitScreen.swift index be2c2e03cc..e47d2a92ab 100644 --- a/submodules/PremiumUI/Sources/PremiumLimitScreen.swift +++ b/submodules/PremiumUI/Sources/PremiumLimitScreen.swift @@ -1124,7 +1124,8 @@ private final class LimitSheetContent: CombinedComponent { premiumValue = component.count >= premiumLimit ? "" : "\(premiumLimit)" badgePosition = max(0.32, CGFloat(component.count) / CGFloat(premiumLimit)) badgeGraphPosition = badgePosition - + titleText = strings.Premium_CreateMultipleStories + if isPremiumDisabled { badgeText = "\(limit)" let numberString = strings.Premium_MaxExpiringStoriesNoPremiumTextNumberFormat(Int32(limit)) diff --git a/submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorStoryCompletion.swift b/submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorStoryCompletion.swift index 56541d36e5..1b48021c76 100644 --- a/submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorStoryCompletion.swift +++ b/submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorStoryCompletion.swift @@ -597,7 +597,7 @@ extension MediaEditorScreenImpl { orderedResults.append(item) } } - self.completion(results, { [weak self] finished in + self.completion(orderedResults, { [weak self] finished in self?.node.animateOut(finished: true, saveDraft: false, completion: { [weak self] in self?.dismiss() Queue.mainQueue().justDispatch { @@ -737,7 +737,10 @@ extension MediaEditorScreenImpl { DispatchQueue.main.async { if let image { itemMediaEditor.replaceSource(image, additionalImage: nil, time: .zero, mirror: false) - + if itemMediaEditor.values.gradientColors == nil { + itemMediaEditor.setGradientColors(mediaEditorGetGradientColors(from: image)) + } + if let resultImage = itemMediaEditor.resultImage { makeEditorImageComposition( context: self.node.ciContext, From e060b91947a0b3bbb6109bccfd87f202503ceb6f Mon Sep 17 00:00:00 2001 From: Isaac <> Date: Tue, 29 Apr 2025 17:36:16 +0200 Subject: [PATCH 05/29] Fix typo and crash --- .../Sources/StoryItemContentComponent.swift | 2 +- .../Sources/StoryItemSetContainerComponent.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemContentComponent.swift b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemContentComponent.swift index e50d333e51..513d067018 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemContentComponent.swift +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemContentComponent.swift @@ -466,7 +466,7 @@ final class StoryItemContentComponent: Component { return } - var useLegacyImplementation = false + var useLegacyImplementation = true if let data = component.context.currentAppConfiguration.with({ $0 }).data, let value = data["ios_video_legacystoryplayer"] as? Double { useLegacyImplementation = value != 0.0 } diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift index 27b69c1316..f88ddc8fd8 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift @@ -1875,7 +1875,7 @@ public final class StoryItemSetContainerComponent: Component { continue } var nextVisibleItem: VisibleItem? - if i != component.slice.allItems.count { + if i != component.slice.allItems.count - 1 { nextVisibleItem = self.visibleItems[component.slice.allItems[i + 1].id] } if let itemView = visibleItem.view.view as? StoryItemContentComponent.View { From d09a563200398808b485eea7ca71c35f760d168b Mon Sep 17 00:00:00 2001 From: Ilya Laktyushin Date: Wed, 30 Apr 2025 16:55:02 +0400 Subject: [PATCH 06/29] Various fixes --- .../Telegram-iOS/en.lproj/Localizable.strings | 11 ++++--- .../Sources/ContactsPeerItem.swift | 4 +++ .../Sources/AvatarEditorScreen.swift | 29 +++++++++++++++---- .../Sources/AccountFreezeInfoScreen.swift | 28 ++++++++++++++++-- 4 files changed, 61 insertions(+), 11 deletions(-) diff --git a/Telegram/Telegram-iOS/en.lproj/Localizable.strings b/Telegram/Telegram-iOS/en.lproj/Localizable.strings index b806c2063c..d2f419b70a 100644 --- a/Telegram/Telegram-iOS/en.lproj/Localizable.strings +++ b/Telegram/Telegram-iOS/en.lproj/Localizable.strings @@ -14291,10 +14291,13 @@ Sorry for the inconvenience."; "MediaPicker.CreateStory_any" = "Create %@ Stories"; "MediaPicker.CombineIntoCollage" = "Combine into Collage"; -"Gift.Resale.Unavailable.Title" = "Resell Gift"; -"Gift.Resale.Unavailable.Text" = "Sorry, you can't list this gift yet.\n\Reselling will be available on %@."; +"Gift.Resale.Unavailable.Title" = "Try Later"; +"Gift.Resale.Unavailable.Text" = "You will be able to resell this gift on %@."; -"Gift.Transfer.Unavailable.Title" = "Transfer Gift"; -"Gift.Transfer.Unavailable.Text" = "Sorry, you can't transfer this gift yet.\n\Transferring will be available on %@."; +"Gift.Transfer.Unavailable.Title" = "Try Later"; +"Gift.Transfer.Unavailable.Text" = "You will be able to transfer this gift on %@."; "Premium.CreateMultipleStories" = "Create Multiple Stories"; + +"FrozenAccount.Violation.TextNew" = "Your account was frozen for breaking Telegram's [Terms and Conditions]()."; +"FrozenAccount.Violation.TextNew_URL" = "https://telegram.org/tos"; diff --git a/submodules/ContactsPeerItem/Sources/ContactsPeerItem.swift b/submodules/ContactsPeerItem/Sources/ContactsPeerItem.swift index 79ce46c3fc..dba6b75463 100644 --- a/submodules/ContactsPeerItem/Sources/ContactsPeerItem.swift +++ b/submodules/ContactsPeerItem/Sources/ContactsPeerItem.swift @@ -1106,6 +1106,10 @@ public class ContactsPeerItemNode: ItemListRevealOptionsItemNode { } } + if let rightLabelTextLayoutAndApply { + additionalTitleInset += rightLabelTextLayoutAndApply.0.size.width + 36.0 + } + let (titleLayout, titleApply) = makeTitleLayout(TextNodeLayoutArguments(attributedString: titleAttributedString, backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: max(0.0, params.width - leftInset - rightInset - additionalTitleInset), height: CGFloat.infinity), alignment: .natural, cutout: nil, insets: UIEdgeInsets())) var maxStatusWidth: CGFloat = params.width - leftInset - rightInset - badgeSize diff --git a/submodules/TelegramUI/Components/AvatarEditorScreen/Sources/AvatarEditorScreen.swift b/submodules/TelegramUI/Components/AvatarEditorScreen/Sources/AvatarEditorScreen.swift index 27b026e833..bcce0831af 100644 --- a/submodules/TelegramUI/Components/AvatarEditorScreen/Sources/AvatarEditorScreen.swift +++ b/submodules/TelegramUI/Components/AvatarEditorScreen/Sources/AvatarEditorScreen.swift @@ -1331,11 +1331,7 @@ final class AvatarEditorScreenComponent: Component { isEnabled: true, displaysProgress: false, action: { [weak self] in - if isLocked { - self?.presentPremiumToast() - } else { - self?.complete() - } + self?.complete() } ) ), @@ -1389,11 +1385,34 @@ final class AvatarEditorScreenComponent: Component { parentController.present(controller, in: .window(.root)) } + private func isPremiumRequired() -> Bool { + guard let component = self.component, let state = self.state else { + return false + } + if component.peerType != .suggest, !component.context.isPremium { + if state.selectedBackground.isPremium { + return true + } + if let selectedFile = state.selectedFile { + if selectedFile.isSticker { + return true + } + } + } + return false + } + private let queue = Queue() func complete() { guard let state = self.state, let file = state.selectedFile, let controller = self.controller?() else { return } + + if self.isPremiumRequired() { + self.presentPremiumToast() + return + } + let context = controller.context let _ = context.animationCache.getFirstFrame(queue: self.queue, sourceId: file.resource.id.stringRepresentation, size: CGSize(width: 640.0, height: 640.0), fetch: animationCacheFetchFile(context: context, userLocation: .other, userContentType: .sticker, resource: .media(media: .standalone(media: file), resource: file.resource), type: AnimationCacheAnimationType(file: file), keyframeOnly: true, customColor: nil), completion: { result in guard let item = result.item else { diff --git a/submodules/TelegramUI/Components/Settings/AccountFreezeInfoScreen/Sources/AccountFreezeInfoScreen.swift b/submodules/TelegramUI/Components/Settings/AccountFreezeInfoScreen/Sources/AccountFreezeInfoScreen.swift index e6b664a823..480ec2ea73 100644 --- a/submodules/TelegramUI/Components/Settings/AccountFreezeInfoScreen/Sources/AccountFreezeInfoScreen.swift +++ b/submodules/TelegramUI/Components/Settings/AccountFreezeInfoScreen/Sources/AccountFreezeInfoScreen.swift @@ -22,17 +22,20 @@ private final class SheetContent: CombinedComponent { let context: AccountContext let configuration: AccountFreezeConfiguration + let openTerms: () -> Void let submitAppeal: () -> Void let dismiss: () -> Void init( context: AccountContext, configuration: AccountFreezeConfiguration, + openTerms: @escaping () -> Void, submitAppeal: @escaping () -> Void, dismiss: @escaping () -> Void ) { self.context = context self.configuration = configuration + self.openTerms = openTerms self.submitAppeal = submitAppeal self.dismiss = dismiss } @@ -132,10 +135,14 @@ private final class SheetContent: CombinedComponent { component: AnyComponent(ParagraphComponent( title: strings.FrozenAccount_Violation_Title, titleColor: textColor, - text: strings.FrozenAccount_Violation_Text, + text: strings.FrozenAccount_Violation_TextNew, textColor: secondaryTextColor, iconName: "Account Freeze/Violation", - iconColor: linkColor + iconColor: linkColor, + action: { + component.openTerms() + component.dismiss() + } )) ) ) @@ -257,15 +264,18 @@ private final class SheetContainerComponent: CombinedComponent { let context: AccountContext let configuration: AccountFreezeConfiguration + let openTerms: () -> Void let submitAppeal: () -> Void init( context: AccountContext, configuration: AccountFreezeConfiguration, + openTerms: @escaping () -> Void, submitAppeal: @escaping () -> Void ) { self.context = context self.configuration = configuration + self.openTerms = openTerms self.submitAppeal = submitAppeal } @@ -292,6 +302,7 @@ private final class SheetContainerComponent: CombinedComponent { content: AnyComponent(SheetContent( context: context.component.context, configuration: context.component.configuration, + openTerms: context.component.openTerms, submitAppeal: context.component.submitAppeal, dismiss: { animateOut.invoke(Action { _ in @@ -367,12 +378,16 @@ public final class AccountFreezeInfoScreen: ViewControllerComponentContainer { self.context = context let configuration = AccountFreezeConfiguration.with(appConfiguration: context.currentAppConfiguration.with { $0 }) + var openTermsImpl: (() -> Void)? var submitAppealImpl: (() -> Void)? super.init( context: context, component: SheetContainerComponent( context: context, configuration: configuration, + openTerms: { + openTermsImpl?() + }, submitAppeal: { submitAppealImpl?() } @@ -384,6 +399,15 @@ public final class AccountFreezeInfoScreen: ViewControllerComponentContainer { self.navigationPresentation = .flatModal + openTermsImpl = { [weak self] in + guard let self, let navigationController = self.navigationController as? NavigationController else { + return + } + let presentationData = context.sharedContext.currentPresentationData.with { $0 } + Queue.mainQueue().after(0.4) { + context.sharedContext.openExternalUrl(context: context, urlContext: .generic, url: presentationData.strings.FrozenAccount_Violation_TextNew_URL, forceExternal: false, presentationData: presentationData, navigationController: navigationController, dismissInput: {}) + } + } submitAppealImpl = { [weak self] in guard let self, let navigationController = self.navigationController as? NavigationController, let url = configuration.freezeAppealUrl else { return From 874fac0c6341455d7e9a7a7b65d66ad1ea4fbf64 Mon Sep 17 00:00:00 2001 From: Ilya Laktyushin Date: Thu, 1 May 2025 20:38:45 +0400 Subject: [PATCH 07/29] Various fixes --- .../Telegram-iOS/en.lproj/Localizable.strings | 13 + .../Sources/AccountContext.swift | 1 + .../AccountContext/Sources/Premium.swift | 1 + ...quenceCountrySelectionControllerNode.swift | 6 +- .../ChatRecentActionsControllerNode.swift | 2 + .../ChatTitleView/Sources/ChatTitleView.swift | 11 +- .../Sources/GiftStoreScreen.swift | 11 + .../Sources/GiftPagerComponent.swift | 241 ++ .../Sources/GiftViewScreen.swift | 2619 +++++++---------- .../Sources/TableComponent.swift | 278 ++ .../Sources/MediaScrubberComponent.swift | 68 +- .../Sources/PeerInfoGiftsPaneNode.swift | 5 + .../Sources/StarsPurchaseScreen.swift | 6 +- .../Sources/StarsWithdrawalScreen.swift | 27 +- .../TelegramUI/Sources/OpenResolvedUrl.swift | 11 +- submodules/TelegramUI/Sources/OpenUrl.swift | 4 +- 16 files changed, 1773 insertions(+), 1531 deletions(-) create mode 100644 submodules/TelegramUI/Components/Gifts/GiftViewScreen/Sources/GiftPagerComponent.swift create mode 100644 submodules/TelegramUI/Components/Gifts/GiftViewScreen/Sources/TableComponent.swift diff --git a/Telegram/Telegram-iOS/en.lproj/Localizable.strings b/Telegram/Telegram-iOS/en.lproj/Localizable.strings index d2f419b70a..d578975f89 100644 --- a/Telegram/Telegram-iOS/en.lproj/Localizable.strings +++ b/Telegram/Telegram-iOS/en.lproj/Localizable.strings @@ -14301,3 +14301,16 @@ Sorry for the inconvenience."; "FrozenAccount.Violation.TextNew" = "Your account was frozen for breaking Telegram's [Terms and Conditions]()."; "FrozenAccount.Violation.TextNew_URL" = "https://telegram.org/tos"; + +"Stars.Purchase.BuyStarGiftInfo" = "Buy Stars to acquire a unique collectible."; + +"Stars.Purchase.EnoughStars" = "You have enough stars at the moment."; +"Stars.Purchase.BuyAnyway" = "Buy Anyway"; + +"Gift.Buy.Confirm.Title" = "Confirm Payment"; +"Gift.Buy.Confirm.Text" = "Do you really want to buy **%1$@** for %2$@?"; +"Gift.Buy.Confirm.GiftText" = "Do you really want to buy **%1$@** for %2$@ and gift it to **%3$@**?"; +"Gift.Buy.Confirm.Text.Stars_1" = "**%@** Star"; +"Gift.Buy.Confirm.Text.Stars_any" = "**%@** Stars"; +"Gift.Buy.Confirm.BuyFor_1" = "Buy for %@ Star"; +"Gift.Buy.Confirm.BuyFor_any" = "Buy for %@ Stars"; diff --git a/submodules/AccountContext/Sources/AccountContext.swift b/submodules/AccountContext/Sources/AccountContext.swift index 1fbf3888c4..6cad8ed0c5 100644 --- a/submodules/AccountContext/Sources/AccountContext.swift +++ b/submodules/AccountContext/Sources/AccountContext.swift @@ -322,6 +322,7 @@ public enum ResolvedUrl { case premiumMultiGift(reference: String?) case collectible(gift: StarGift.UniqueGift?) case messageLink(link: TelegramResolvedMessageLink?) + case stars } public enum ResolveUrlResult { diff --git a/submodules/AccountContext/Sources/Premium.swift b/submodules/AccountContext/Sources/Premium.swift index fffbc9fa04..e1b5e521bb 100644 --- a/submodules/AccountContext/Sources/Premium.swift +++ b/submodules/AccountContext/Sources/Premium.swift @@ -141,6 +141,7 @@ public enum StarsPurchasePurpose: Equatable { case upgradeStarGift(requiredStars: Int64) case transferStarGift(requiredStars: Int64) case sendMessage(peerId: EnginePeer.Id, requiredStars: Int64) + case buyStarGift(requiredStars: Int64) } public struct PremiumConfiguration { diff --git a/submodules/CountrySelectionUI/Sources/AuthorizationSequenceCountrySelectionControllerNode.swift b/submodules/CountrySelectionUI/Sources/AuthorizationSequenceCountrySelectionControllerNode.swift index 9c9cd26cf9..5563711e0d 100644 --- a/submodules/CountrySelectionUI/Sources/AuthorizationSequenceCountrySelectionControllerNode.swift +++ b/submodules/CountrySelectionUI/Sources/AuthorizationSequenceCountrySelectionControllerNode.swift @@ -167,10 +167,12 @@ public func searchCountries(items: [((String, String), String, [Int])], query: S let componentsOne = item.0.0.components(separatedBy: " ") let abbrOne = componentsOne.compactMap { $0.first.flatMap { String($0) } }.reduce(into: String(), { $0.append(contentsOf: $1) }).replacingOccurrences(of: "&", with: "") - let componentsTwo = item.0.0.components(separatedBy: " ") + let componentsTwo = item.0.1.components(separatedBy: " ") let abbrTwo = componentsTwo.compactMap { $0.first.flatMap { String($0) } }.reduce(into: String(), { $0.append(contentsOf: $1) }).replacingOccurrences(of: "&", with: "") - let string = "\(item.0.0) \((item.0.1)) \(item.1) \(abbrOne) \(abbrTwo)" + let phoneCodes = item.2.map { "\($0)" }.joined(separator: " ") + + let string = "\(item.0.0) \((item.0.1)) \(item.1) \(abbrOne) \(abbrTwo) \(phoneCodes)" let tokens = stringTokens(string) if matchStringTokens(tokens, with: queryTokens) { for code in item.2 { diff --git a/submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsControllerNode.swift b/submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsControllerNode.swift index 7e109c6f1a..2937054d2a 100644 --- a/submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsControllerNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsControllerNode.swift @@ -1441,6 +1441,8 @@ final class ChatRecentActionsControllerNode: ViewControllerTracingNode { break case .messageLink: break + case .stars: + break } } })) diff --git a/submodules/TelegramUI/Components/ChatTitleView/Sources/ChatTitleView.swift b/submodules/TelegramUI/Components/ChatTitleView/Sources/ChatTitleView.swift index 46d7132d47..773f9d1728 100644 --- a/submodules/TelegramUI/Components/ChatTitleView/Sources/ChatTitleView.swift +++ b/submodules/TelegramUI/Components/ChatTitleView/Sources/ChatTitleView.swift @@ -955,6 +955,7 @@ public final class ChatTitleView: UIView, NavigationBarTitleView { titleTransition = .immediate } + let statusSpacing: CGFloat = 3.0 let titleSideInset: CGFloat = 6.0 var titleFrame: CGRect if size.height > 40.0 { @@ -966,7 +967,12 @@ public final class ChatTitleView: UIView, NavigationBarTitleView { var titleSize = self.titleTextNode.updateLayout(size: CGSize(width: clearBounds.width - leftIconWidth - credibilityIconWidth - verifiedIconWidth - statusIconWidth - rightIconWidth - titleSideInset * 2.0, height: size.height), insets: titleInsets, animated: titleTransition.isAnimated) titleSize.width += credibilityIconWidth titleSize.width += verifiedIconWidth - titleSize.width += statusIconWidth + if statusIconWidth > 0.0 { + titleSize.width += statusIconWidth + if credibilityIconWidth > 0.0 { + titleSize.width += statusSpacing + } + } let activitySize = self.activityNode.updateLayout(CGSize(width: clearBounds.size.width - titleSideInset * 2.0, height: clearBounds.size.height), alignment: .center) let titleInfoSpacing: CGFloat = 0.0 @@ -1006,6 +1012,9 @@ public final class ChatTitleView: UIView, NavigationBarTitleView { self.titleCredibilityIconView.frame = CGRect(origin: CGPoint(x: nextIconX - titleCredibilitySize.width, y: floor((titleFrame.height - titleCredibilitySize.height) / 2.0)), size: titleCredibilitySize) nextIconX -= titleCredibilitySize.width + if credibilityIconWidth > 0.0 { + nextIconX -= statusSpacing + } self.titleStatusIconView.frame = CGRect(origin: CGPoint(x: nextIconX - titleStatusSize.width, y: floor((titleFrame.height - titleStatusSize.height) / 2.0)), size: titleStatusSize) nextIconX -= titleStatusSize.width diff --git a/submodules/TelegramUI/Components/Gifts/GiftStoreScreen/Sources/GiftStoreScreen.swift b/submodules/TelegramUI/Components/Gifts/GiftStoreScreen/Sources/GiftStoreScreen.swift index 8dc7594761..835092e294 100644 --- a/submodules/TelegramUI/Components/Gifts/GiftStoreScreen/Sources/GiftStoreScreen.swift +++ b/submodules/TelegramUI/Components/Gifts/GiftStoreScreen/Sources/GiftStoreScreen.swift @@ -240,9 +240,20 @@ final class GiftStoreScreenComponent: Component { } else { mainController = controller } + + let allSubjects: [GiftViewScreen.Subject] = (self.effectiveGifts ?? []).compactMap { gift in + if case let .unique(uniqueGift) = gift { + return .uniqueGift(uniqueGift, state.peerId) + } + return nil + } + let index = self.effectiveGifts?.firstIndex(where: { $0 == .unique(uniqueGift) }) ?? 0 + let giftController = GiftViewScreen( context: component.context, subject: .uniqueGift(uniqueGift, state.peerId), + allSubjects: allSubjects, + index: index, buyGift: { slug, peerId in return self.state?.starGiftsContext.buyStarGift(slug: slug, peerId: peerId) ?? .complete() }, diff --git a/submodules/TelegramUI/Components/Gifts/GiftViewScreen/Sources/GiftPagerComponent.swift b/submodules/TelegramUI/Components/Gifts/GiftViewScreen/Sources/GiftPagerComponent.swift new file mode 100644 index 0000000000..83def8af34 --- /dev/null +++ b/submodules/TelegramUI/Components/Gifts/GiftViewScreen/Sources/GiftPagerComponent.swift @@ -0,0 +1,241 @@ +import Foundation +import UIKit +import ComponentFlow +import Display +import TelegramPresentationData +import ViewControllerComponent +import AccountContext + +final class GiftPagerComponent: Component { + typealias EnvironmentType = ViewControllerComponentContainer.Environment + + public final class Item: Equatable { + let id: AnyHashable + let subject: GiftViewScreen.Subject + + public init(id: AnyHashable, subject: GiftViewScreen.Subject) { + self.id = id + self.subject = subject + } + + public static func ==(lhs: Item, rhs: Item) -> Bool { + if lhs.id != rhs.id { + return false + } + if lhs.subject != rhs.subject { + return false + } + + return true + } + } + + let context: AccountContext + let items: [Item] + let index: Int + let itemSpacing: CGFloat + let updated: (CGFloat, Int) -> Void + + public init( + context: AccountContext, + items: [Item], + index: Int = 0, + itemSpacing: CGFloat = 0.0, + updated: @escaping (CGFloat, Int) -> Void + ) { + self.context = context + self.items = items + self.index = index + self.itemSpacing = itemSpacing + self.updated = updated + } + + public static func ==(lhs: GiftPagerComponent, rhs: GiftPagerComponent) -> Bool { + if lhs.items != rhs.items { + return false + } + if lhs.index != rhs.index { + return false + } + if lhs.itemSpacing != rhs.itemSpacing { + return false + } + return true + } + + final class View: UIView, UIScrollViewDelegate { + private let scrollView: UIScrollView + private var itemViews: [AnyHashable: ComponentHostView] = [:] + + private var component: GiftPagerComponent? + private var environment: Environment? + + override init(frame: CGRect) { + self.scrollView = UIScrollView(frame: frame) + self.scrollView.isPagingEnabled = true + self.scrollView.showsHorizontalScrollIndicator = false + self.scrollView.showsVerticalScrollIndicator = false + self.scrollView.alwaysBounceHorizontal = false + self.scrollView.bounces = false + self.scrollView.layer.cornerRadius = 10.0 + if #available(iOSApplicationExtension 11.0, iOS 11.0, *) { + self.scrollView.contentInsetAdjustmentBehavior = .never + } + + super.init(frame: frame) + + self.scrollView.delegate = self + + self.addSubview(self.scrollView) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private var isSwiping: Bool = false + private var lastScrollTime: TimeInterval = 0 + private let swipeInactiveThreshold: TimeInterval = 0.5 + + public func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + self.isSwiping = true + self.lastScrollTime = CACurrentMediaTime() + } + + public func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { + if !decelerate { + self.isSwiping = false + } + } + + public func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { + self.isSwiping = false + } + + private var ignoreContentOffsetChange = false + public func scrollViewDidScroll(_ scrollView: UIScrollView) { + guard let component = self.component, let environment = self.environment, !self.ignoreContentOffsetChange && !self.isUpdating else { + return + } + + if self.isSwiping { + self.lastScrollTime = CACurrentMediaTime() + } + + self.ignoreContentOffsetChange = true + let _ = self.update(component: component, availableSize: self.bounds.size, environment: environment, transition: .immediate) + component.updated(self.scrollView.contentOffset.x / (self.scrollView.contentSize.width - self.scrollView.frame.width), component.items.count) + self.ignoreContentOffsetChange = false + } + + private var isUpdating = true + func update(component: GiftPagerComponent, availableSize: CGSize, environment: Environment, transition: ComponentTransition) -> CGSize { + self.isUpdating = true + defer { + self.isUpdating = false + } + var validIds: [AnyHashable] = [] + + self.component = component + self.environment = environment + + let firstTime = self.itemViews.isEmpty + + let itemWidth = availableSize.width + let totalWidth = itemWidth * CGFloat(component.items.count) + component.itemSpacing * CGFloat(max(0, component.items.count - 1)) + + let contentSize = CGSize(width: totalWidth, height: availableSize.height) + if self.scrollView.contentSize != contentSize { + self.scrollView.contentSize = contentSize + } + let scrollFrame = CGRect(origin: .zero, size: availableSize) + if self.scrollView.frame != scrollFrame { + self.scrollView.frame = scrollFrame + } + + if firstTime { + let initialOffset = CGFloat(component.index) * (itemWidth + component.itemSpacing) + self.scrollView.contentOffset = CGPoint(x: initialOffset, y: 0.0) + + var position: CGFloat + if self.scrollView.contentSize.width > self.scrollView.frame.width { + position = self.scrollView.contentOffset.x / (self.scrollView.contentSize.width - self.scrollView.frame.width) + } else { + position = 0.0 + } + component.updated(position, component.items.count) + } + let viewportCenter = self.scrollView.contentOffset.x + availableSize.width * 0.5 + + let currentTime = CACurrentMediaTime() + let isSwipingActive = self.isSwiping || (currentTime - self.lastScrollTime < self.swipeInactiveThreshold) + + var i = 0 + for item in component.items { + let itemOriginX = (itemWidth + component.itemSpacing) * CGFloat(i) + let itemFrame = CGRect(origin: CGPoint(x: itemOriginX, y: 0.0), size: CGSize(width: itemWidth, height: availableSize.height)) + + let centerDelta = itemFrame.midX - viewportCenter + let position = centerDelta / (availableSize.width * 0.75) + + i += 1 + + if !isSwipingActive && abs(position) > 0.5 { + continue + } else if isSwipingActive && abs(position) > 1.5 { + continue + } + + validIds.append(item.id) + + let itemView: ComponentHostView + var itemTransition = transition + + if let current = self.itemViews[item.id] { + itemView = current + } else { + itemTransition = transition.withAnimation(.none) + itemView = ComponentHostView() + self.itemViews[item.id] = itemView + + self.scrollView.addSubview(itemView) + } + + let environment = environment[EnvironmentType.self] + + let _ = itemView.update( + transition: itemTransition, + component: AnyComponent(GiftViewSheetComponent( + context: component.context, + subject: item.subject + )), + environment: { environment }, + containerSize: availableSize + ) + + itemView.frame = itemFrame + } + + var removeIds: [AnyHashable] = [] + for (id, itemView) in self.itemViews { + if !validIds.contains(id) { + removeIds.append(id) + itemView.removeFromSuperview() + } + } + for id in removeIds { + self.itemViews.removeValue(forKey: id) + } + + 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, environment: environment, transition: transition) + } +} diff --git a/submodules/TelegramUI/Components/Gifts/GiftViewScreen/Sources/GiftViewScreen.swift b/submodules/TelegramUI/Components/Gifts/GiftViewScreen/Sources/GiftViewScreen.swift index dc9af27814..924c0aa70a 100644 --- a/submodules/TelegramUI/Components/Gifts/GiftViewScreen/Sources/GiftViewScreen.swift +++ b/submodules/TelegramUI/Components/Gifts/GiftViewScreen/Sources/GiftViewScreen.swift @@ -35,79 +35,23 @@ import TelegramNotices import PremiumLockButtonSubtitleComponent import StarsBalanceOverlayComponent -private let modelButtonTag = GenericComponentViewTag() -private let backdropButtonTag = GenericComponentViewTag() -private let symbolButtonTag = GenericComponentViewTag() -private let statusTag = GenericComponentViewTag() - private final class GiftViewSheetContent: CombinedComponent { typealias EnvironmentType = ViewControllerComponentContainer.Environment let context: AccountContext let subject: GiftViewScreen.Subject - let cancel: (Bool) -> Void - let openPeer: (EnginePeer) -> Void - let openAddress: (String) -> Void - let copyAddress: (String) -> Void - let updateSavedToProfile: (Bool) -> Void - let convertToStars: () -> Void - let openStarsIntro: () -> Void - let sendGift: (EnginePeer.Id) -> Void - let changeRecipient: () -> Void - let openMyGifts: () -> Void - let transferGift: () -> Void - let upgradeGift: ((Int64?, Bool) -> Signal) - let buyGift: ((String, EnginePeer.Id) -> Signal) - let shareGift: () -> Void - let resellGift: (Bool) -> Void - let showAttributeInfo: (Any, String) -> Void - let viewUpgraded: (EngineMessage.Id) -> Void - let openMore: (ASDisplayNode, ContextGesture?) -> Void + let animateOut: ActionSlot> let getController: () -> ViewController? init( context: AccountContext, subject: GiftViewScreen.Subject, - cancel: @escaping (Bool) -> Void, - openPeer: @escaping (EnginePeer) -> Void, - openAddress: @escaping (String) -> Void, - copyAddress: @escaping (String) -> Void, - updateSavedToProfile: @escaping (Bool) -> Void, - convertToStars: @escaping () -> Void, - openStarsIntro: @escaping () -> Void, - sendGift: @escaping (EnginePeer.Id) -> Void, - changeRecipient: @escaping () -> Void, - openMyGifts: @escaping () -> Void, - transferGift: @escaping () -> Void, - upgradeGift: @escaping ((Int64?, Bool) -> Signal), - buyGift: @escaping ((String, EnginePeer.Id) -> Signal), - shareGift: @escaping () -> Void, - resellGift: @escaping (Bool) -> Void, - showAttributeInfo: @escaping (Any, String) -> Void, - viewUpgraded: @escaping (EngineMessage.Id) -> Void, - openMore: @escaping (ASDisplayNode, ContextGesture?) -> Void, + animateOut: ActionSlot>, getController: @escaping () -> ViewController? ) { self.context = context self.subject = subject - self.cancel = cancel - self.openPeer = openPeer - self.openAddress = openAddress - self.copyAddress = copyAddress - self.updateSavedToProfile = updateSavedToProfile - self.convertToStars = convertToStars - self.openStarsIntro = openStarsIntro - self.sendGift = sendGift - self.changeRecipient = changeRecipient - self.openMyGifts = openMyGifts - self.transferGift = transferGift - self.upgradeGift = upgradeGift - self.buyGift = buyGift - self.shareGift = shareGift - self.resellGift = resellGift - self.showAttributeInfo = showAttributeInfo - self.viewUpgraded = viewUpgraded - self.openMore = openMore + self.animateOut = animateOut self.getController = getController } @@ -122,12 +66,14 @@ private final class GiftViewSheetContent: CombinedComponent { } final class State: ComponentState { + let modelButtonTag = GenericComponentViewTag() + let backdropButtonTag = GenericComponentViewTag() + let symbolButtonTag = GenericComponentViewTag() + let statusTag = GenericComponentViewTag() + private let context: AccountContext private(set) var subject: GiftViewScreen.Subject - private let upgradeGift: ((Int64?, Bool) -> Signal) - private let buyGift: ((String, EnginePeer.Id) -> Signal) - private let getController: () -> ViewController? private var disposable: Disposable? @@ -171,19 +117,25 @@ private final class GiftViewSheetContent: CombinedComponent { var keepOriginalInfo = false - private let optionsPromise = Promise<[StarsTopUpOption]?>(nil) - + private var optionsDisposable: Disposable? + private(set) var options: [StarsTopUpOption] = [] { + didSet { + self.optionsPromise.set(self.options) + } + } + private let optionsPromise = ValuePromise<[StarsTopUpOption]?>(nil) + + private let animateOut: ActionSlot> + init( context: AccountContext, subject: GiftViewScreen.Subject, - upgradeGift: @escaping ((Int64?, Bool) -> Signal), - buyGift: @escaping ((String, EnginePeer.Id) -> Signal), + animateOut: ActionSlot>, getController: @escaping () -> ViewController? ) { self.context = context self.subject = subject - self.upgradeGift = upgradeGift - self.buyGift = buyGift + self.animateOut = animateOut self.getController = getController super.init() @@ -326,15 +278,13 @@ private final class GiftViewSheetContent: CombinedComponent { } if let starsContext = context.starsContext, let state = starsContext.currentState, state.balance < minRequiredAmount { - self.optionsPromise.set(context.engine.payments.starsTopUpOptions() - |> map(Optional.init)) - } - - if let controller = getController() as? GiftViewScreen { - controller.updateSubject.connect { [weak self] subject in - self?.subject = subject - self?.updated(transition: .easeInOut(duration: 0.25)) - } + self.optionsDisposable = (context.engine.payments.starsTopUpOptions() + |> deliverOnMainQueue).start(next: { [weak self] options in + guard let self else { + return + } + self.options = options + }) } } @@ -346,8 +296,780 @@ private final class GiftViewSheetContent: CombinedComponent { self.buyFormDisposable?.dispose() self.buyDisposable?.dispose() self.levelsDisposable.dispose() + self.optionsDisposable?.dispose() } + func openPeer(_ peer: EnginePeer, gifts: Bool = false, dismiss: Bool = true) { + guard let controller = self.getController() as? GiftViewScreen, let navigationController = controller.navigationController as? NavigationController else { + return + } + + controller.dismissAllTooltips() + + if gifts { + if let profileController = self.context.sharedContext.makePeerInfoController( + context: self.context, + updatedPresentationData: nil, + peer: peer._asPeer(), + mode: peer.id == self.context.account.peerId ? .myProfileGifts : .gifts, + avatarInitiallyExpanded: false, + fromChat: false, + requestsContext: nil + ) { + controller.push(profileController) + } + } else { + self.context.sharedContext.navigateToChatController(NavigateToChatControllerParams( + navigationController: navigationController, + chatController: nil, + context: self.context, + chatLocation: .peer(peer), + subject: nil, + botStart: nil, + updateTextInputState: nil, + keepStack: .always, + useExisting: true, + purposefulAction: nil, + scrollToEndIfExists: false, + activateMessageSearch: nil, + animated: true + )) + } + + if dismiss { + Queue.mainQueue().after(0.6, { + self.dismiss(animated: false) + }) + } + } + + func openAddress(_ address: String) { + guard let controller = self.getController() as? GiftViewScreen, let navigationController = controller.navigationController as? NavigationController else { + return + } + let presentationData = self.context.sharedContext.currentPresentationData.with { $0 } + let configuration = GiftViewConfiguration.with(appConfiguration: context.currentAppConfiguration.with { $0 }) + let url = configuration.explorerUrl + address + + Queue.mainQueue().after(0.3) { + self.context.sharedContext.openExternalUrl( + context: self.context, + urlContext: .generic, + url: url, + forceExternal: false, + presentationData: presentationData, + navigationController: navigationController, + dismissInput: {} + ) + } + + self.dismiss(animated: true) + } + + func copyAddress(_ address: String) { + guard let controller = self.getController() as? GiftViewScreen else { + return + } + + UIPasteboard.general.string = address + controller.dismissAllTooltips() + + let presentationData = self.context.sharedContext.currentPresentationData.with { $0 } + controller.present( + UndoOverlayController( + presentationData: presentationData, + content: .copy(text: presentationData.strings.Gift_View_CopiedAddress), + elevatedLayout: false, + position: .bottom, + action: { _ in return true } + ), + in: .current + ) + + HapticFeedback().tap() + } + + func updateSavedToProfile(_ added: Bool) { + guard let controller = self.getController() as? GiftViewScreen, let arguments = self.subject.arguments, let reference = arguments.reference else { + return + } + + var animationFile: TelegramMediaFile? + switch arguments.gift { + case let .generic(gift): + animationFile = gift.file + case let .unique(gift): + for attribute in gift.attributes { + if case let .model(_, file, _) = attribute { + animationFile = file + break + } + } + } + + if let updateSavedToProfile = controller.updateSavedToProfile { + updateSavedToProfile(reference, added) + } else { + let _ = (self.context.engine.payments.updateStarGiftAddedToProfile(reference: reference, added: added) + |> deliverOnMainQueue).startStandalone() + } + + controller.dismissAnimated() + + let giftsPeerId: EnginePeer.Id? + let presentationData = self.context.sharedContext.currentPresentationData.with { $0 } + let text: String + + if case let .peer(peerId, _) = arguments.reference, peerId.namespace == Namespaces.Peer.CloudChannel { + giftsPeerId = peerId + text = added ? presentationData.strings.Gift_Displayed_ChannelText : presentationData.strings.Gift_Hidden_ChannelText + } else { + giftsPeerId = context.account.peerId + text = added ? presentationData.strings.Gift_Displayed_NewText : presentationData.strings.Gift_Hidden_NewText + } + + if let navigationController = controller.navigationController as? NavigationController { + Queue.mainQueue().after(0.5) { + if let lastController = navigationController.viewControllers.last as? ViewController, let animationFile { + let resultController = UndoOverlayController( + presentationData: presentationData, + content: .sticker( + context: self.context, + file: animationFile, + loop: false, + title: nil, + text: text, + undoText: presentationData.strings.Gift_Displayed_View, + customAction: nil + ), + elevatedLayout: lastController is ChatController, + action: { [weak navigationController] action in + if case .undo = action, let navigationController, let giftsPeerId { + let _ = (self.context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: giftsPeerId)) + |> deliverOnMainQueue).start(next: { [weak navigationController] peer in + guard let peer, let navigationController else { + return + } + if let controller = self.context.sharedContext.makePeerInfoController( + context: self.context, + updatedPresentationData: nil, + peer: peer._asPeer(), + mode: giftsPeerId == self.context.account.peerId ? .myProfileGifts : .gifts, + avatarInitiallyExpanded: false, + fromChat: false, + requestsContext: nil + ) { + navigationController.pushViewController(controller, animated: true) + } + }) + } + return true + } + ) + lastController.present(resultController, in: .window(.root)) + } + } + } + } + + func convertToStars() { + guard let controller = self.getController() as? GiftViewScreen, let starsContext = context.starsContext, let arguments = self.subject.arguments, let reference = arguments.reference, let fromPeerName = arguments.fromPeerName, let convertStars = arguments.convertStars, let navigationController = controller.navigationController as? NavigationController else { + return + } + + let configuration = GiftConfiguration.with(appConfiguration: context.currentAppConfiguration.with { $0 }) + let starsConvertMaxDate = arguments.date + configuration.convertToStarsPeriod + + var isChannelGift = false + if case let .peer(peerId, _) = reference, peerId.namespace == Namespaces.Peer.CloudChannel { + isChannelGift = true + } + + let presentationData = self.context.sharedContext.currentPresentationData.with { $0 } + let currentTime = Int32(CFAbsoluteTimeGetCurrent() + kCFAbsoluteTimeIntervalSince1970) + + if currentTime > starsConvertMaxDate { + let days: Int32 = Int32(ceil(Float(configuration.convertToStarsPeriod) / 86400.0)) + let controller = textAlertController( + context: self.context, + title: presentationData.strings.Gift_Convert_Title, + text: presentationData.strings.Gift_Convert_Period_Unavailable_Text(presentationData.strings.Gift_Convert_Period_Unavailable_Days(days)).string, + actions: [ + TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_OK, action: {}) + ], + parseMarkdown: true + ) + controller.present(controller, in: .window(.root)) + } else { + let delta = starsConvertMaxDate - currentTime + let days: Int32 = Int32(ceil(Float(delta) / 86400.0)) + + let text = presentationData.strings.Gift_Convert_Period_Text( + fromPeerName, + presentationData.strings.Gift_Convert_Period_Stars(Int32(convertStars)), + presentationData.strings.Gift_Convert_Period_Days(days) + ).string + + let alertController = textAlertController( + context: self.context, + title: presentationData.strings.Gift_Convert_Title, + text: text, + actions: [ + TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_Cancel, action: {}), + TextAlertAction(type: .defaultAction, title: presentationData.strings.Gift_Convert_Convert, action: { [weak self, weak controller, weak navigationController] in + guard let self else { + return + } + + if let convertToStars = controller?.convertToStars { + convertToStars() + } else { + let _ = (self.context.engine.payments.convertStarGift(reference: reference) + |> deliverOnMainQueue).startStandalone() + } + + controller?.dismissAnimated() + + if let navigationController { + Queue.mainQueue().after(0.5) { + starsContext.load(force: true) + + let text: String + if isChannelGift { + text = presentationData.strings.Gift_Convert_Success_ChannelText( + presentationData.strings.Gift_Convert_Success_ChannelText_Stars(Int32(convertStars)) + ).string + } else { + text = presentationData.strings.Gift_Convert_Success_Text( + presentationData.strings.Gift_Convert_Success_Text_Stars(Int32(convertStars)) + ).string + if let starsContext = self.context.starsContext { + navigationController.pushViewController( + self.context.sharedContext.makeStarsTransactionsScreen( + context: self.context, + starsContext: starsContext + ), + animated: true + ) + } + } + + if let lastController = navigationController.viewControllers.last as? ViewController { + let resultController = UndoOverlayController( + presentationData: presentationData, + content: .universal( + animation: "StarsBuy", + scale: 0.066, + colors: [:], + title: presentationData.strings.Gift_Convert_Success_Title, + text: text, + customUndoText: nil, + timeout: nil + ), + elevatedLayout: lastController is ChatController, + action: { _ in return true } + ) + lastController.present(resultController, in: .window(.root)) + } + } + } + }) + ], + parseMarkdown: true + ) + controller.present(alertController, in: .window(.root)) + } + } + + func openStarsIntro() { + guard let controller = self.getController() else { + return + } + let introController = self.context.sharedContext.makeStarsIntroScreen(context: self.context) + controller.push(introController) + } + + func sendGift(peerId: EnginePeer.Id) { + guard let controller = self.getController() else { + return + } + let _ = (self.context.engine.payments.premiumGiftCodeOptions(peerId: nil, onlyCached: true) + |> filter { !$0.isEmpty } + |> deliverOnMainQueue).start(next: { [weak self, weak controller] giftOptions in + guard let self, let controller else { + return + } + let premiumOptions = giftOptions.filter { $0.users == 1 }.map { CachedPremiumGiftOption(months: $0.months, currency: $0.currency, amount: $0.amount, botUrl: "", storeProductId: $0.storeProductId) } + let giftController = self.context.sharedContext.makeGiftOptionsController(context: self.context, peerId: peerId, premiumOptions: premiumOptions, hasBirthday: false, completion: nil) + controller.push(giftController) + }) + + Queue.mainQueue().after(0.6, { + self.dismiss(animated: false) + }) + } + + func shareGift() { + guard let arguments = self.subject.arguments, case let .unique(gift) = arguments.gift, let controller = self.getController() as? GiftViewScreen else { + return + } + + let presentationData = self.context.sharedContext.currentPresentationData.with { $0 } + + var shareStoryImpl: (() -> Void)? + if let shareStory = controller.shareStory { + shareStoryImpl = { + shareStory(gift) + } + } + let link = "https://t.me/nft/\(gift.slug)" + let shareController = self.context.sharedContext.makeShareController( + context: self.context, + subject: .url(link), + forceExternal: false, + shareStory: shareStoryImpl, + enqueued: { [weak self, weak controller] peerIds, _ in + guard let self else { + return + } + let _ = (self.context.engine.data.get( + EngineDataList( + peerIds.map(TelegramEngine.EngineData.Item.Peer.Peer.init) + ) + ) + |> deliverOnMainQueue).startStandalone(next: { [weak self, weak controller] peerList in + guard let self else { + return + } + let peers = peerList.compactMap { $0 } + let presentationData = self.context.sharedContext.currentPresentationData.with { $0 } + let text: String + var savedMessages = false + if peerIds.count == 1, let peerId = peerIds.first, peerId == context.account.peerId { + text = presentationData.strings.Conversation_ForwardTooltip_SavedMessages_One + savedMessages = true + } else { + if peers.count == 1, let peer = peers.first { + var peerName = peer.id == context.account.peerId ? presentationData.strings.DialogList_SavedMessages : peer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder) + peerName = peerName.replacingOccurrences(of: "**", with: "") + text = presentationData.strings.Conversation_ForwardTooltip_Chat_One(peerName).string + } else if peers.count == 2, let firstPeer = peers.first, let secondPeer = peers.last { + var firstPeerName = firstPeer.id == context.account.peerId ? presentationData.strings.DialogList_SavedMessages : firstPeer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder) + firstPeerName = firstPeerName.replacingOccurrences(of: "**", with: "") + var secondPeerName = secondPeer.id == context.account.peerId ? presentationData.strings.DialogList_SavedMessages : secondPeer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder) + secondPeerName = secondPeerName.replacingOccurrences(of: "**", with: "") + text = presentationData.strings.Conversation_ForwardTooltip_TwoChats_One(firstPeerName, secondPeerName).string + } else if let peer = peers.first { + var peerName = peer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder) + peerName = peerName.replacingOccurrences(of: "**", with: "") + text = presentationData.strings.Conversation_ForwardTooltip_ManyChats_One(peerName, "\(peers.count - 1)").string + } else { + text = "" + } + } + + controller?.present(UndoOverlayController(presentationData: presentationData, content: .forward(savedMessages: savedMessages, text: text), elevatedLayout: false, animateInAsReplacement: false, action: { [weak self, weak controller] action in + if let self, savedMessages, action == .info { + let _ = (self.context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: self.context.account.peerId)) + |> deliverOnMainQueue).start(next: { [weak self, weak controller] peer in + guard let peer else { + return + } + self?.openPeer(peer) + Queue.mainQueue().after(0.6) { + controller?.dismiss(animated: false, completion: nil) + } + }) + } + return false + }, additionalView: nil), in: .current) + }) + }, + actionCompleted: { [weak controller] in + controller?.present(UndoOverlayController(presentationData: presentationData, content: .linkCopied(title: nil, text: presentationData.strings.Conversation_LinkCopied), elevatedLayout: false, animateInAsReplacement: false, action: { _ in return false }), in: .current) + } + ) + controller.present(shareController, in: .window(.root)) + } + + func transferGift() { + guard let arguments = self.subject.arguments, let controller = self.getController() as? GiftViewScreen, case let .unique(gift) = arguments.gift, let reference = arguments.reference, let transferStars = arguments.transferStars else { + return + } + + controller.dismissAllTooltips() + + let presentationData = self.context.sharedContext.currentPresentationData.with { $0 } + + let currentTime = Int32(CFAbsoluteTimeGetCurrent() + kCFAbsoluteTimeIntervalSince1970) + if let canTransferDate = arguments.canTransferDate, currentTime < canTransferDate { + let dateString = stringForFullDate(timestamp: canTransferDate, strings: presentationData.strings, dateTimeFormat: presentationData.dateTimeFormat) + let alertController = textAlertController( + context: self.context, + title: presentationData.strings.Gift_Transfer_Unavailable_Title, + text: presentationData.strings.Gift_Transfer_Unavailable_Text(dateString).string, + actions: [ + TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_OK, action: {}) + ], + parseMarkdown: true + ) + controller.present(alertController, in: .window(.root)) + return + } + + let context = self.context + let _ = (self.context.account.stateManager.contactBirthdays + |> take(1) + |> deliverOnMainQueue).start(next: { [weak self, weak controller] birthdays in + guard let self, let controller else { + return + } + var showSelf = false + if arguments.peerId?.namespace == Namespaces.Peer.CloudChannel { + showSelf = true + } + + let tranfserGiftImpl = controller.transferGift + + let transferController = self.context.sharedContext.makePremiumGiftController(context: context, source: .starGiftTransfer(birthdays, reference, gift, transferStars, arguments.canExportDate, showSelf), completion: { peerIds in + guard let peerId = peerIds.first else { + return .complete() + } + Queue.mainQueue().after(1.5, { + if transferStars > 0 { + context.starsContext?.load(force: true) + } + }) + + if let tranfserGiftImpl { + return tranfserGiftImpl(transferStars == 0, peerId) + } else { + return (context.engine.payments.transferStarGift(prepaid: transferStars == 0, reference: reference, peerId: peerId) + |> deliverOnMainQueue) + } + }) + controller.push(transferController) + }) + } + + func resellGift(update: Bool = false) { + guard let arguments = self.subject.arguments, case let .unique(gift) = arguments.gift, let controller = self.getController() as? GiftViewScreen else { + return + } + + controller.dismissAllTooltips() + + let presentationData = self.context.sharedContext.currentPresentationData.with { $0 } + + let currentTime = Int32(CFAbsoluteTimeGetCurrent() + kCFAbsoluteTimeIntervalSince1970) + if let canResaleDate = arguments.canResaleDate, currentTime < canResaleDate { + let dateString = stringForFullDate(timestamp: canResaleDate, strings: presentationData.strings, dateTimeFormat: presentationData.dateTimeFormat) + let alertController = textAlertController( + context: self.context, + title: presentationData.strings.Gift_Resale_Unavailable_Title, + text: presentationData.strings.Gift_Resale_Unavailable_Text(dateString).string, + actions: [ + TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_OK, action: {}) + ], + parseMarkdown: true + ) + controller.present(alertController, in: .window(.root)) + return + } + + let giftTitle = "\(gift.title) #\(presentationStringsFormattedNumber(gift.number, presentationData.dateTimeFormat.groupingSeparator))" + let reference = arguments.reference ?? .slug(slug: gift.slug) + + if let resellStars = gift.resellStars, resellStars > 0, !update { + let alertController = textAlertController( + context: context, + title: presentationData.strings.Gift_View_Resale_Unlist_Title, + text: presentationData.strings.Gift_View_Resale_Unlist_Text, + actions: [ + TextAlertAction(type: .defaultAction, title: presentationData.strings.Gift_View_Resale_Unlist_Unlist, action: { [weak self, weak controller] in + guard let self, let controller else { + return + } + let _ = ((controller.updateResellStars?(nil) ?? context.engine.payments.updateStarGiftResalePrice(reference: reference, price: nil)) + |> deliverOnMainQueue).startStandalone(error: { error in + + }, completed: { [weak self, weak controller] in + guard let self, let controller else { + return + } + switch self.subject { + case let .profileGift(peerId, currentSubject): + self.subject = .profileGift(peerId, currentSubject.withGift(.unique(gift.withResellStars(nil)))) + case let .uniqueGift(_, recipientPeerId): + self.subject = .uniqueGift(gift.withResellStars(nil), recipientPeerId) + default: + break + } + + let text = presentationData.strings.Gift_View_Resale_Unlist_Success(giftTitle).string + let tooltipController = UndoOverlayController( + presentationData: presentationData, + content: .universalImage( + image: generateTintedImage(image: UIImage(bundleImageName: "Premium/Collectible/Unlist"), color: .white)!, + size: nil, + title: nil, + text: text, + customUndoText: nil, + timeout: 3.0 + ), + position: .bottom, + animateInAsReplacement: false, + appearance: UndoOverlayController.Appearance(sideInset: 16.0, bottomInset: 62.0), + action: { action in + return false + } + ) + controller.present(tooltipController, in: .window(.root)) + }) + }), + TextAlertAction(type: .genericAction, title: presentationData.strings.Common_Cancel, action: { + }) + ], + actionLayout: .vertical + ) + controller.present(alertController, in: .window(.root)) + } else { + let resellController = self.context.sharedContext.makeStarGiftResellScreen(context: self.context, update: update, completion: { [weak self, weak controller] price in + guard let self, let controller else { + return + } + + let _ = ((controller.updateResellStars?(price) ?? context.engine.payments.updateStarGiftResalePrice(reference: reference, price: price)) + |> deliverOnMainQueue).startStandalone(error: { [weak self, weak controller] error in + guard let self else { + return + } + + let title: String? + let text: String + switch error { + case .generic: + title = nil + text = presentationData.strings.Gift_Send_ErrorUnknown + case let .starGiftResellTooEarly(canResaleDate): + let dateString = stringForFullDate(timestamp: canResaleDate, strings: presentationData.strings, dateTimeFormat: presentationData.dateTimeFormat) + title = presentationData.strings.Gift_Resale_Unavailable_Title + text = presentationData.strings.Gift_Resale_Unavailable_Text(dateString).string + } + + let alertController = textAlertController( + context: self.context, + title: title, + text: text, + actions: [ + TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_OK, action: {}) + ], + parseMarkdown: true + ) + controller?.present(alertController, in: .window(.root)) + }, completed: { [weak self, weak controller] in + guard let self, let controller else { + return + } + + switch self.subject { + case let .profileGift(peerId, currentSubject): + self.subject = .profileGift(peerId, currentSubject.withGift(.unique(gift.withResellStars(price)))) + case let .uniqueGift(_, recipientPeerId): + self.subject = .uniqueGift(gift.withResellStars(price), recipientPeerId) + default: + break + } + + var text = presentationData.strings.Gift_View_Resale_List_Success(giftTitle).string + if update { + let starsString = presentationData.strings.Gift_View_Resale_Relist_Success_Stars(Int32(price)) + text = presentationData.strings.Gift_View_Resale_Relist_Success(giftTitle, starsString).string + } + + let tooltipController = UndoOverlayController( + presentationData: presentationData, + content: .universalImage( + image: generateTintedImage(image: UIImage(bundleImageName: "Premium/Collectible/Sell"), color: .white)!, + size: nil, + title: nil, + text: text, + customUndoText: nil, + timeout: 3.0 + ), + position: .bottom, + animateInAsReplacement: false, + appearance: UndoOverlayController.Appearance(sideInset: 16.0, bottomInset: 62.0), + action: { action in + return false + } + ) + controller.present(tooltipController, in: .window(.root)) + }) + }) + controller.push(resellController) + } + } + + func viewUpgradedGift(messageId: EngineMessage.Id) { + guard let controller = self.getController(), let navigationController = controller.navigationController as? NavigationController else { + return + } + let _ = (self.context.engine.data.get( + TelegramEngine.EngineData.Item.Peer.Peer(id: messageId.peerId) + ) + |> deliverOnMainQueue).start(next: { [weak self, weak navigationController] peer in + guard let self, let navigationController, let peer else { + return + } + self.context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: self.context, chatLocation: .peer(peer), subject: .message(id: .id(messageId), highlight: ChatControllerSubject.MessageHighlight(quote: nil), timecode: nil, setupReply: false), keepStack: .always, useExisting: true, purposefulAction: {}, peekData: nil, forceAnimatedScroll: true)) + }) + } + + func showAttributeInfo(tag: Any, text: String) { + guard let controller = self.getController() as? GiftViewScreen else { + return + } + controller.dismissAllTooltips() + + guard let sourceView = controller.node.hostView.findTaggedView(tag: tag), let absoluteLocation = sourceView.superview?.convert(sourceView.center, to: controller.view) else { + return + } + + let location = CGRect(origin: CGPoint(x: absoluteLocation.x, y: absoluteLocation.y - 12.0), size: CGSize()) + let tooltipController = TooltipScreen(account: self.context.account, sharedContext: self.context.sharedContext, text: .plain(text: text), style: .wide, location: .point(location, .bottom), displayDuration: .default, inset: 16.0, shouldDismissOnTouch: { _, _ in + return .ignore + }) + controller.present(tooltipController, in: .current) + } + + func openMore(node: ASDisplayNode, gesture: ContextGesture?) { + guard let arguments = self.subject.arguments, case let .unique(gift) = arguments.gift else { + return + } + + let presentationData = self.context.sharedContext.currentPresentationData.with { $0 } + let link = "https://t.me/nft/\(gift.slug)" + + let _ = (self.context.engine.data.get( + TelegramEngine.EngineData.Item.Peer.Peer(id: arguments.peerId ?? context.account.peerId) + ) + |> deliverOnMainQueue).start(next: { [weak self] peer in + guard let self, let controller = self.getController() as? GiftViewScreen else { + return + } + var items: [ContextMenuItem] = [] + let strings = presentationData.strings + + if let _ = arguments.reference, case .unique = arguments.gift, let togglePinnedToTop = controller.togglePinnedToTop, let pinnedToTop = arguments.pinnedToTop { + items.append(.action(ContextMenuActionItem(text: pinnedToTop ? strings.PeerInfo_Gifts_Context_Unpin : strings.PeerInfo_Gifts_Context_Pin , icon: { theme in generateTintedImage(image: UIImage(bundleImageName: pinnedToTop ? "Chat/Context Menu/Unpin" : "Chat/Context Menu/Pin"), color: theme.contextMenu.primaryColor) }, action: { [weak self] c, f in + c?.dismiss(completion: { [weak self, weak controller] in + guard let self, let controller else { + return + } + + let pinnedToTop = !pinnedToTop + if togglePinnedToTop(pinnedToTop) { + if pinnedToTop { + controller.dismissAnimated() + } else { + let toastText = strings.PeerInfo_Gifts_ToastUnpinned_Text + controller.present(UndoOverlayController(presentationData: presentationData, content: .universal(animation: "anim_toastunpin", scale: 0.06, colors: [:], title: nil, text: toastText, customUndoText: nil, timeout: 5), elevatedLayout: false, animateInAsReplacement: false, action: { _ in return false }), in: .current) + if case let .profileGift(peerId, gift) = self.subject { + self.subject = .profileGift(peerId, gift.withPinnedToTop(false)) + } + } + } + }) + }))) + } + + if case let .unique(gift) = arguments.gift, let resellStars = gift.resellStars, resellStars > 0 { + if arguments.reference != nil || gift.owner.peerId == context.account.peerId { + items.append(.action(ContextMenuActionItem(text: presentationData.strings.Gift_View_Context_ChangePrice, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/PriceTag"), color: theme.contextMenu.primaryColor) + }, action: { [weak self] c, _ in + c?.dismiss(completion: nil) + + self?.resellGift(update: true) + }))) + } + } + + items.append(.action(ContextMenuActionItem(text: presentationData.strings.Gift_View_Context_CopyLink, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Link"), color: theme.contextMenu.primaryColor) + }, action: { [weak controller] c, _ in + c?.dismiss(completion: nil) + + UIPasteboard.general.string = link + + controller?.present(UndoOverlayController(presentationData: presentationData, content: .linkCopied(title: nil, text: presentationData.strings.Conversation_LinkCopied), elevatedLayout: false, animateInAsReplacement: false, action: { _ in return false }), in: .current) + }))) + + items.append(.action(ContextMenuActionItem(text: presentationData.strings.Gift_View_Context_Share, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Forward"), color: theme.contextMenu.primaryColor) + }, action: { [weak self] c, _ in + c?.dismiss(completion: nil) + + self?.shareGift() + }))) + + if let _ = arguments.transferStars { + if case let .channel(channel) = peer, !channel.flags.contains(.isCreator) { + + } else { + items.append(.action(ContextMenuActionItem(text: presentationData.strings.Gift_View_Context_Transfer, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Replace"), color: theme.contextMenu.primaryColor) + }, action: { [weak self] c, _ in + c?.dismiss(completion: nil) + + self?.transferGift() + }))) + } + } + + if let _ = arguments.resellStars, case let .uniqueGift(uniqueGift, recipientPeerId) = subject, let _ = recipientPeerId { + items.append(.action(ContextMenuActionItem(text: presentationData.strings.Gift_View_Context_ViewInProfile, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Peer Info/ShowIcon"), color: theme.contextMenu.primaryColor) + }, action: { [weak self] c, _ in + c?.dismiss(completion: nil) + + guard let self, case let .peerId(peerId) = uniqueGift.owner else { + return + } + let _ = (self.context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: peerId)) + |> deliverOnMainQueue).start(next: { [weak self] peer in + guard let self, let peer else { + return + } + self.openPeer(peer, gifts: true) + Queue.mainQueue().after(0.6) { + controller.dismiss(animated: false, completion: nil) + } + }) + }))) + } + + let contextController = ContextController(presentationData: presentationData, source: .reference(GiftViewContextReferenceContentSource(controller: controller, sourceNode: node)), items: .single(ContextController.Items(content: .list(items))), gesture: gesture) + controller.presentInGlobalOverlay(contextController) + }) + } + + func dismiss(animated: Bool) { + guard let controller = self.getController() as? GiftViewScreen else { + return + } + if animated { + controller.dismissAllTooltips() + controller.dismissBalanceOverlay() + self.animateOut.invoke(Action { [weak controller] _ in + controller?.dismiss(completion: nil) + }) + } else { + controller.dismiss(animated: false) + } + } + func requestWearPreview() { self.inWearPreview = true self.updated(transition: .spring(duration: 0.4)) @@ -403,30 +1125,7 @@ private final class GiftViewSheetContent: CombinedComponent { } } - func changeRecipient() { - let presentationData = self.context.sharedContext.currentPresentationData.with { $0 } - let mode = ContactSelectionControllerMode.starsGifting(birthdays: nil, hasActions: false, showSelf: true, selfSubtitle: presentationData.strings.Premium_Gift_ContactSelection_BuySelf) - - let controller = self.context.sharedContext.makeContactSelectionController(ContactSelectionControllerParams( - context: context, - mode: mode, - autoDismiss: true, - title: { _ in return "Change Recipient" }, - options: .single([]), - allowChannelsInSearch: false - )) - controller.navigationPresentation = .modal - let _ = (controller.result - |> deliverOnMainQueue).start(next: { [weak self] result in - if let (peers, _, _, _, _, _) = result, let contactPeer = peers.first, case let .peer(peer, _, _) = contactPeer { - self?.recipientPeerId = peer.id - } - }) - - self.getController()?.push(controller) - } - - func commitBuy() { + func commitBuy(skipConfirmation: Bool = false) { guard let resellStars = self.subject.arguments?.resellStars, let starsContext = self.context.starsContext, let starsState = starsContext.currentState, case let .unique(uniqueGift) = self.subject.arguments?.gift else { return } @@ -439,10 +1138,31 @@ private final class GiftViewSheetContent: CombinedComponent { let action = { let proceed: (Int64) -> Void = { formId in + guard let controller = self.getController() as? GiftViewScreen else { + return + } + self.inProgress = true self.updated() - self.buyDisposable = (self.buyGift(uniqueGift.slug, recipientPeerId) + let buyGiftImpl: ((String, EnginePeer.Id) -> Signal) + if let buyGift = controller.buyGift { + buyGiftImpl = { slug, peerId in + return buyGift(slug, peerId) + |> afterCompleted { + context.starsContext?.load(force: true) + } + } + } else { + buyGiftImpl = { slug, peerId in + return self.context.engine.payments.buyStarGift(slug: slug, peerId: peerId) + |> afterCompleted { + context.starsContext?.load(force: true) + } + } + } + + self.buyDisposable = (buyGiftImpl(uniqueGift.slug, recipientPeerId) |> deliverOnMainQueue).start(error: { [weak self] error in guard let self, let controller = self.getController() else { return @@ -524,6 +1244,10 @@ private final class GiftViewSheetContent: CombinedComponent { if let buyForm = self.buyForm, let price = buyForm.invoice.prices.first?.amount { if starsState.balance < StarsAmount(value: price, nanos: 0) { + if self.options.isEmpty { + self.inProgress = true + self.updated() + } let _ = (self.optionsPromise.get() |> filter { $0 != nil } |> take(1) @@ -535,7 +1259,7 @@ private final class GiftViewSheetContent: CombinedComponent { context: self.context, starsContext: starsContext, options: options ?? [], - purpose: .upgradeStarGift(requiredStars: price), + purpose: .buyStarGift(requiredStars: price), completion: { [weak self, weak starsContext] stars in guard let self, let starsContext else { return @@ -545,8 +1269,23 @@ private final class GiftViewSheetContent: CombinedComponent { starsContext.add(balance: StarsAmount(value: stars, nanos: 0)) let _ = (starsContext.onUpdate - |> deliverOnMainQueue).start(next: { - proceed(buyForm.id) + |> deliverOnMainQueue).start(next: { [weak self] in + guard let self else { + return + } + Queue.mainQueue().after(0.1, { [weak self] in + guard let self, let starsContext = self.context.starsContext, let starsState = starsContext.currentState else { + return + } + if starsState.balance < StarsAmount(value: price, nanos: 0) { + self.inProgress = false + self.updated() + + self.commitBuy(skipConfirmation: true) + } else { + proceed(buyForm.id) + } + }); }) } ) @@ -558,36 +1297,41 @@ private final class GiftViewSheetContent: CombinedComponent { } } - let _ = (self.context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: recipientPeerId)) - |> deliverOnMainQueue).start(next: { [weak self] peer in - guard let self, let peer else { - return - } - let text: String - if recipientPeerId == self.context.account.peerId { - text = "Do you really want to buy **\(giftTitle)** for **\(resellStars)** Stars?" - } else { - text = "Do you really want to buy **\(giftTitle)** for **\(resellStars)** Stars and gift it to **\(peer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder))**?" - } - - let alertController = textAlertController( - context: self.context, - title: "Confirm Payment", - text: text, - actions: [ - TextAlertAction(type: .defaultAction, title: "Buy for \(resellStars) Stars", action: { - action() - }), - TextAlertAction(type: .genericAction, title: "Cancel", action: { - }) - ], - actionLayout: .vertical, - parseMarkdown: true - ) - if let controller = self.getController() as? GiftViewScreen { - controller.present(alertController, in: .window(.root)) - } - }) + if skipConfirmation { + action() + } else { + let _ = (self.context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: recipientPeerId)) + |> deliverOnMainQueue).start(next: { [weak self] peer in + guard let self, let peer else { + return + } + let text: String + let starsString = presentationData.strings.Gift_Buy_Confirm_Text_Stars(Int32(resellStars)) + + if recipientPeerId == self.context.account.peerId { + text = presentationData.strings.Gift_Buy_Confirm_Text(giftTitle, starsString).string + } else { + text = presentationData.strings.Gift_Buy_Confirm_GiftText(giftTitle, starsString, peer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder)).string + } + let alertController = textAlertController( + context: self.context, + title: presentationData.strings.Gift_Buy_Confirm_Title, + text: text, + actions: [ + TextAlertAction(type: .defaultAction, title: presentationData.strings.Gift_Buy_Confirm_BuyFor(Int32(resellStars)), action: { + action() + }), + TextAlertAction(type: .genericAction, title: presentationData.strings.Common_Cancel, action: { + }) + ], + actionLayout: .vertical, + parseMarkdown: true + ) + if let controller = self.getController() as? GiftViewScreen { + controller.present(alertController, in: .window(.root)) + } + }) + } } func commitUpgrade() { @@ -596,14 +1340,40 @@ private final class GiftViewSheetContent: CombinedComponent { } let proceed: (Int64?) -> Void = { formId in + guard let controller = self.getController() as? GiftViewScreen else { + return + } self.inProgress = true self.updated() - if let controller = self.getController() as? GiftViewScreen { - controller.showBalance = false - } + controller.showBalance = false - self.upgradeDisposable = (self.upgradeGift(formId, self.keepOriginalInfo) + let context = self.context + let upgradeGiftImpl: ((Int64?, Bool) -> Signal) + if let upgradeGift = controller.upgradeGift { + upgradeGiftImpl = { formId, keepOriginalInfo in + return upgradeGift(formId, keepOriginalInfo) + |> afterCompleted { + if formId != nil { + context.starsContext?.load(force: true) + } + } + } + } else { + guard let reference = arguments.reference else { + return + } + upgradeGiftImpl = { formId, keepOriginalInfo in + return self.context.engine.payments.upgradeStarGift(formId: formId, reference: reference, keepOriginalInfo: keepOriginalInfo) + |> afterCompleted { + if formId != nil { + context.starsContext?.load(force: true) + } + } + } + } + + self.upgradeDisposable = (upgradeGiftImpl(formId, self.keepOriginalInfo) |> deliverOnMainQueue).start(next: { [weak self, weak starsContext] result in guard let self, let controller = self.getController() as? GiftViewScreen else { return @@ -611,7 +1381,7 @@ private final class GiftViewSheetContent: CombinedComponent { self.inProgress = false self.inUpgradePreview = false - controller.subject = .profileGift(peerId, result) + self.subject = .profileGift(peerId, result) controller.animateSuccess() self.updated(transition: .spring(duration: 0.4)) @@ -665,7 +1435,7 @@ private final class GiftViewSheetContent: CombinedComponent { } func makeState() -> State { - return State(context: self.context, subject: self.subject, upgradeGift: self.upgradeGift, buyGift: self.buyGift, getController: self.getController) + return State(context: self.context, subject: self.subject, animateOut: self.animateOut, getController: self.getController) } static var body: Body { @@ -819,7 +1589,6 @@ private final class GiftViewSheetContent: CombinedComponent { showWearPreview = true } - let cancel = component.cancel let buttons = buttons.update( component: ButtonsComponent( theme: theme, @@ -838,11 +1607,11 @@ private final class GiftViewSheetContent: CombinedComponent { } else if state.inUpgradePreview { state.cancelUpgradePreview() } else { - cancel(true) + state.dismiss(animated: true) } }, - morePressed: { node, gesture in - component.openMore(node, gesture) + morePressed: { [weak state] node, gesture in + state?.openMore(node: node, gesture: gesture) } ), availableSize: CGSize(width: 30.0, height: 30.0), @@ -1404,9 +2173,9 @@ private final class GiftViewSheetContent: CombinedComponent { return nil } }, - tapAction: { attributes, _ in + tapAction: { [weak state] attributes, _ in if let _ = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.URL)] as? String { - component.openStarsIntro() + state?.openStarsIntro() } } ), @@ -1479,179 +2248,136 @@ private final class GiftViewSheetContent: CombinedComponent { if !soldOut { if let uniqueGift { - if !"".isEmpty, case let .uniqueGift(_, recipientPeerIdValue) = component.subject, let _ = recipientPeerIdValue, let recipientPeerId = state.recipientPeerId { - if let peer = state.peerMap[recipientPeerId] { - tableItems.append(.init( - id: "recipient", - title: "Recipient", - component: AnyComponent( - Button( - content: AnyComponent( - HStack([ - AnyComponentWithIdentity( - id: AnyHashable(peer.id), - component: AnyComponent(PeerCellComponent( + switch uniqueGift.owner { + case let .peerId(peerId): + if let peer = state.peerMap[peerId] { + let ownerComponent: AnyComponent + if peer.id == component.context.account.peerId, peer.isPremium { + let animationContent: EmojiStatusComponent.Content + var color: UIColor? + var statusId: Int64 = 1 + if state.pendingWear { + var fileId: Int64? + for attribute in uniqueGift.attributes { + if case let .model(_, file, _) = attribute { + fileId = file.fileId.id + } + if case let .backdrop(_, _, innerColor, _, _, _, _) = attribute { + color = UIColor(rgb: UInt32(bitPattern: innerColor)) + } + } + if let fileId { + statusId = fileId + animationContent = .animation(content: .customEmoji(fileId: fileId), size: CGSize(width: 18.0, height: 18.0), placeholderColor: theme.list.mediaPlaceholderColor, themeColor: tableLinkColor, loopMode: .count(2)) + } else { + animationContent = .premium(color: tableLinkColor) + } + } else if let emojiStatus = peer.emojiStatus, !state.pendingTakeOff { + animationContent = .animation(content: .customEmoji(fileId: emojiStatus.fileId), size: CGSize(width: 18.0, height: 18.0), placeholderColor: theme.list.mediaPlaceholderColor, themeColor: tableLinkColor, loopMode: .count(2)) + if case let .starGift(id, _, _, _, _, innerColor, _, _, _) = emojiStatus.content { + color = UIColor(rgb: UInt32(bitPattern: innerColor)) + if id == uniqueGift.id { + isWearing = true + state.pendingWear = false + } + } + } else { + animationContent = .premium(color: tableLinkColor) + state.pendingTakeOff = false + } + + ownerComponent = AnyComponent( + HStack([ + AnyComponentWithIdentity( + id: AnyHashable(0), + component: AnyComponent(Button( + content: AnyComponent( + PeerCellComponent( context: component.context, theme: theme, strings: strings, peer: peer - )) + ) ), - AnyComponentWithIdentity( - id: AnyHashable(1), - component: AnyComponent(ButtonContentComponent( - context: component.context, - text: "change", - color: theme.list.itemAccentColor - )) - ) - ], spacing: 4.0) + action: { [weak state] in + state?.openPeer(peer) + } + )) ), - action: { [weak state] in - state?.changeRecipient() - } - ) - ) - )) - } - } else { - switch uniqueGift.owner { - case let .peerId(peerId): - if let peer = state.peerMap[peerId] { - let ownerComponent: AnyComponent - if peer.id == component.context.account.peerId, peer.isPremium { - let animationContent: EmojiStatusComponent.Content - var color: UIColor? - var statusId: Int64 = 1 - if state.pendingWear { - var fileId: Int64? - for attribute in uniqueGift.attributes { - if case let .model(_, file, _) = attribute { - fileId = file.fileId.id - } - if case let .backdrop(_, _, innerColor, _, _, _, _) = attribute { - color = UIColor(rgb: UInt32(bitPattern: innerColor)) - } - } - if let fileId { - statusId = fileId - animationContent = .animation(content: .customEmoji(fileId: fileId), size: CGSize(width: 18.0, height: 18.0), placeholderColor: theme.list.mediaPlaceholderColor, themeColor: tableLinkColor, loopMode: .count(2)) - } else { - animationContent = .premium(color: tableLinkColor) - } - } else if let emojiStatus = peer.emojiStatus, !state.pendingTakeOff { - animationContent = .animation(content: .customEmoji(fileId: emojiStatus.fileId), size: CGSize(width: 18.0, height: 18.0), placeholderColor: theme.list.mediaPlaceholderColor, themeColor: tableLinkColor, loopMode: .count(2)) - if case let .starGift(id, _, _, _, _, innerColor, _, _, _) = emojiStatus.content { - color = UIColor(rgb: UInt32(bitPattern: innerColor)) - if id == uniqueGift.id { - isWearing = true - state.pendingWear = false - } - } - } else { - animationContent = .premium(color: tableLinkColor) - state.pendingTakeOff = false - } - - ownerComponent = AnyComponent( - HStack([ - AnyComponentWithIdentity( - id: AnyHashable(0), - component: AnyComponent(Button( - content: AnyComponent( - PeerCellComponent( - context: component.context, - theme: theme, - strings: strings, - peer: peer - ) - ), - action: { - component.openPeer(peer) - Queue.mainQueue().after(0.6, { - component.cancel(false) - }) - } - )) - ), - AnyComponentWithIdentity( - id: AnyHashable(statusId), - component: AnyComponent(EmojiStatusComponent( - context: component.context, - animationCache: component.context.animationCache, - animationRenderer: component.context.animationRenderer, - content: animationContent, - particleColor: color, - size: CGSize(width: 18.0, height: 18.0), - isVisibleForAnimations: true, - action: { - - }, - tag: statusTag - )) - ) - ], spacing: 2.0) - ) - } else { - ownerComponent = AnyComponent(Button( - content: AnyComponent( - PeerCellComponent( + AnyComponentWithIdentity( + id: AnyHashable(statusId), + component: AnyComponent(EmojiStatusComponent( context: component.context, - theme: theme, - strings: strings, - peer: peer - ) - ), - action: { - component.openPeer(peer) - Queue.mainQueue().after(0.6, { - component.cancel(false) - }) - } - )) - } - tableItems.append(.init( - id: "owner", - title: strings.Gift_Unique_Owner, - component: ownerComponent + animationCache: component.context.animationCache, + animationRenderer: component.context.animationRenderer, + content: animationContent, + particleColor: color, + size: CGSize(width: 18.0, height: 18.0), + isVisibleForAnimations: true, + action: { + + }, + tag: state.statusTag + )) + ) + ], spacing: 2.0) + ) + } else { + ownerComponent = AnyComponent(Button( + content: AnyComponent( + PeerCellComponent( + context: component.context, + theme: theme, + strings: strings, + peer: peer + ) + ), + action: { [weak state] in + state?.openPeer(peer) + } )) } - case let .name(name): tableItems.append(.init( - id: "name_owner", + id: "owner", title: strings.Gift_Unique_Owner, - component: AnyComponent( - MultilineTextComponent(text: .plain(NSAttributedString(string: name, font: tableFont, textColor: tableTextColor))) - ) - )) - case let .address(address): - exported = true - - func formatAddress(_ str: String) -> String { - guard str.count == 48 && !str.hasSuffix(".ton") else { - return str - } - var result = str - let middleIndex = result.index(result.startIndex, offsetBy: str.count / 2) - result.insert("\n", at: middleIndex) - return result - } - - tableItems.append(.init( - id: "address_owner", - title: strings.Gift_Unique_Owner, - component: AnyComponent( - Button( - content: AnyComponent( - MultilineTextComponent(text: .plain(NSAttributedString(string: formatAddress(address), font: tableLargeMonospaceFont, textColor: tableLinkColor)), maximumNumberOfLines: 2, lineSpacing: 0.2) - ), - action: { - component.copyAddress(address) - } - ) - ) + component: ownerComponent )) } + case let .name(name): + tableItems.append(.init( + id: "name_owner", + title: strings.Gift_Unique_Owner, + component: AnyComponent( + MultilineTextComponent(text: .plain(NSAttributedString(string: name, font: tableFont, textColor: tableTextColor))) + ) + )) + case let .address(address): + exported = true + + func formatAddress(_ str: String) -> String { + guard str.count == 48 && !str.hasSuffix(".ton") else { + return str + } + var result = str + let middleIndex = result.index(result.startIndex, offsetBy: str.count / 2) + result.insert("\n", at: middleIndex) + return result + } + + tableItems.append(.init( + id: "address_owner", + title: strings.Gift_Unique_Owner, + component: AnyComponent( + Button( + content: AnyComponent( + MultilineTextComponent(text: .plain(NSAttributedString(string: formatAddress(address), font: tableLargeMonospaceFont, textColor: tableLinkColor)), maximumNumberOfLines: 2, lineSpacing: 0.2) + ), + action: { [weak state] in + state?.copyAddress(address) + } + ) + ) + )) } } else if let peerId = subject.arguments?.fromPeerId, let peer = state.peerMap[peerId] { var isBot = false @@ -1673,11 +2399,8 @@ private final class GiftViewSheetContent: CombinedComponent { peer: peer ) ), - action: { - component.openPeer(peer) - Queue.mainQueue().after(0.6, { - component.cancel(false) - }) + action: { [weak state] in + state?.openPeer(peer) } )) ), @@ -1689,11 +2412,8 @@ private final class GiftViewSheetContent: CombinedComponent { text: strings.Gift_View_Send, color: theme.list.itemAccentColor )), - action: { - component.sendGift(peerId) - Queue.mainQueue().after(0.6, { - component.cancel(false) - }) + action: { [weak state] in + state?.sendGift(peerId: peerId) } )) ) @@ -1709,11 +2429,8 @@ private final class GiftViewSheetContent: CombinedComponent { peer: peer ) ), - action: { - component.openPeer(peer) - Queue.mainQueue().after(0.6, { - component.cancel(false) - }) + action: { [weak state] in + state?.openPeer(peer) } )) } @@ -1768,8 +2485,8 @@ private final class GiftViewSheetContent: CombinedComponent { ) ), effectAlignment: .center, - action: { - component.transferGift() + action: { [weak state] in + state?.transferGift() } ), environment: {}, @@ -1798,7 +2515,7 @@ private final class GiftViewSheetContent: CombinedComponent { if isWearing { state.commitTakeOff() - component.showAttributeInfo(statusTag, strings.Gift_View_TookOff("\(uniqueGift.title) #\(presentationStringsFormattedNumber(uniqueGift.number, environment.dateTimeFormat.groupingSeparator))").string) + state.showAttributeInfo(tag: state.statusTag, text: strings.Gift_View_TookOff("\(uniqueGift.title) #\(presentationStringsFormattedNumber(uniqueGift.number, environment.dateTimeFormat.groupingSeparator))").string) } else { if let controller = controller() as? GiftViewScreen { controller.dismissAllTooltips() @@ -1825,7 +2542,7 @@ private final class GiftViewSheetContent: CombinedComponent { state.requestWearPreview() } else { state.commitWear(uniqueGift) - component.showAttributeInfo(statusTag, strings.Gift_View_PutOn("\(uniqueGift.title) #\(presentationStringsFormattedNumber(uniqueGift.number, environment.dateTimeFormat.groupingSeparator))").string) + state.showAttributeInfo(tag: state.statusTag, text: strings.Gift_View_PutOn("\(uniqueGift.title) #\(presentationStringsFormattedNumber(uniqueGift.number, environment.dateTimeFormat.groupingSeparator))").string) } }) } @@ -1852,8 +2569,8 @@ private final class GiftViewSheetContent: CombinedComponent { ) ), effectAlignment: .center, - action: { - component.resellGift(false) + action: { [weak state] in + state?.resellGift() } ), environment: {}, @@ -1866,9 +2583,7 @@ private final class GiftViewSheetContent: CombinedComponent { .disappear(.default(scale: true, alpha: true)) ) } - - let showAttributeInfo = component.showAttributeInfo - + let order: [StarGift.UniqueGift.Attribute.AttributeType] = [ .model, .backdrop, .pattern, .originalInfo ] @@ -1894,19 +2609,19 @@ private final class GiftViewSheetContent: CombinedComponent { title = strings.Gift_Unique_Model value = NSAttributedString(string: name, font: tableFont, textColor: tableTextColor) percentage = Float(rarity) * 0.1 - tag = modelButtonTag + tag = state.modelButtonTag case let .backdrop(name, _, _, _, _, _, rarity): id = "backdrop" title = strings.Gift_Unique_Backdrop value = NSAttributedString(string: name, font: tableFont, textColor: tableTextColor) percentage = Float(rarity) * 0.1 - tag = backdropButtonTag + tag = state.backdropButtonTag case let .pattern(name, _, rarity): id = "pattern" title = strings.Gift_Unique_Symbol value = NSAttributedString(string: name, font: tableFont, textColor: tableTextColor) percentage = Float(rarity) * 0.1 - tag = symbolButtonTag + tag = state.symbolButtonTag case let .originalInfo(senderPeerId, recipientPeerId, date, text, entities): id = "originalInfo" title = nil @@ -1986,10 +2701,7 @@ private final class GiftViewSheetContent: CombinedComponent { return } if let mention = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.PeerMention)] as? TelegramPeerMention, let peer = state.peerMap[mention.peerId] { - component.openPeer(peer) - Queue.mainQueue().after(0.6, { - component.cancel(false) - }) + state.openPeer(peer) } } ) @@ -2005,8 +2717,8 @@ private final class GiftViewSheetContent: CombinedComponent { text: formatPercentage(percentage), color: theme.list.itemAccentColor )), - action: { - showAttributeInfo(tag, strings.Gift_Unique_AttributeDescription(formatPercentage(percentage)).string) + action: { [weak state] in + state?.showAttributeInfo(tag: tag, text: strings.Gift_Unique_AttributeDescription(formatPercentage(percentage)).string) } ).tagged(tag)) )) @@ -2102,8 +2814,8 @@ private final class GiftViewSheetContent: CombinedComponent { text: strings.Gift_View_Sale(strings.Gift_View_Sale_Stars(Int32(convertStars))).string, color: theme.list.itemAccentColor )), - action: { - component.convertToStars() + action: { [weak state] in + state?.convertToStars() } )) ) @@ -2229,8 +2941,8 @@ private final class GiftViewSheetContent: CombinedComponent { PriceButtonComponent(price: presentationStringsFormattedNumber(Int32(resellStars), environment.dateTimeFormat.groupingSeparator)) ), effectAlignment: .center, - action: { - component.resellGift(true) + action: { [weak state] in + state?.resellGift(update: true) }, animateScale: false ), @@ -2308,15 +3020,14 @@ private final class GiftViewSheetContent: CombinedComponent { return nil } }, - tapAction: { attributes, _ in + tapAction: { [weak state] attributes, _ in if let _ = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.URL)] as? String { if let addressToOpen { - component.openAddress(addressToOpen) - component.cancel(true) + state?.openAddress(addressToOpen) } else { - component.updateSavedToProfile(!savedToProfile) + state?.updateSavedToProfile(!savedToProfile) Queue.mainQueue().after(0.6, { - component.cancel(false) + state?.dismiss(animated: false) }) } } @@ -2410,11 +3121,11 @@ private final class GiftViewSheetContent: CombinedComponent { queue: Queue.mainQueue(), context.engine.peers.getChannelBoostStatus(peerId: wearOwnerPeerId), context.engine.peers.getMyBoostStatus() - ).startStandalone(next: { [weak controller] boostStatus, myBoostStatus in - guard let controller, let boostStatus, let myBoostStatus else { + ).startStandalone(next: { [weak controller, weak state] boostStatus, myBoostStatus in + guard let controller, let state, let boostStatus, let myBoostStatus else { return } - component.cancel(true) + state.dismiss(animated: true) let levelsController = context.sharedContext.makePremiumBoostLevelsController(context: context, peerId: wearOwnerPeerId, subject: .wearGift, boostStatus: boostStatus, myBoostStatus: myBoostStatus, forceDark: false, openStats: nil) controller.push(levelsController) @@ -2430,13 +3141,14 @@ private final class GiftViewSheetContent: CombinedComponent { position: .bottom, animateInAsReplacement: false, appearance: UndoOverlayController.Appearance(sideInset: 16.0, bottomInset: 62.0), - action: { [weak controller] action in + action: { [weak controller, weak state] action in if case .info = action { controller?.dismissAllTooltips() let premiumController = context.sharedContext.makePremiumIntroController(context: context, source: .messageEffects, forceDark: false, dismissed: nil) controller?.push(premiumController) + Queue.mainQueue().after(0.6, { - component.cancel(false) + state?.dismiss(animated: false) }) } return false @@ -2447,10 +3159,10 @@ private final class GiftViewSheetContent: CombinedComponent { } else { state.commitWear(uniqueGift) if case .wearPreview = component.subject { - component.cancel(true) + state.dismiss(animated: true) } else { Queue.mainQueue().after(0.2) { - component.showAttributeInfo(statusTag, strings.Gift_View_PutOn("\(uniqueGift.title) #\(presentationStringsFormattedNumber(uniqueGift.number, environment.dateTimeFormat.groupingSeparator))").string) + state.showAttributeInfo(tag: state.statusTag, text: strings.Gift_View_PutOn("\(uniqueGift.title) #\(presentationStringsFormattedNumber(uniqueGift.number, environment.dateTimeFormat.groupingSeparator))").string) } } } @@ -2502,9 +3214,9 @@ private final class GiftViewSheetContent: CombinedComponent { ), isEnabled: true, displaysProgress: false, - action: { - component.cancel(true) - component.viewUpgraded(upgradeMessageId) + action: { [weak state] in + state?.dismiss(animated: true) + state?.viewUpgradedGift(messageId: upgradeMessageId) }), availableSize: buttonSize, transition: context.transition @@ -2551,8 +3263,8 @@ private final class GiftViewSheetContent: CombinedComponent { ), isEnabled: true, displaysProgress: state.inProgress, - action: { - component.updateSavedToProfile(!savedToProfile) + action: { [weak state] in + state?.updateSavedToProfile(!savedToProfile) }), availableSize: buttonSize, transition: context.transition @@ -2597,8 +3309,8 @@ private final class GiftViewSheetContent: CombinedComponent { ), isEnabled: true, displaysProgress: state.inProgress, - action: { - component.cancel(true) + action: { [weak state] in + state?.dismiss(animated: true) }), availableSize: buttonSize, transition: context.transition @@ -2622,69 +3334,18 @@ private final class GiftViewSheetContent: CombinedComponent { } } -private final class GiftViewSheetComponent: CombinedComponent { +final class GiftViewSheetComponent: CombinedComponent { typealias EnvironmentType = ViewControllerComponentContainer.Environment let context: AccountContext let subject: GiftViewScreen.Subject - let openPeer: (EnginePeer) -> Void - let openAddress: (String) -> Void - let copyAddress: (String) -> Void - let updateSavedToProfile: (Bool) -> Void - let convertToStars: () -> Void - let openStarsIntro: () -> Void - let sendGift: (EnginePeer.Id) -> Void - let changeRecipient: () -> Void - let openMyGifts: () -> Void - let transferGift: () -> Void - let upgradeGift: ((Int64?, Bool) -> Signal) - let buyGift: ((String, EnginePeer.Id) -> Signal) - let shareGift: () -> Void - let resellGift: (Bool) -> Void - let viewUpgraded: (EngineMessage.Id) -> Void - let openMore: (ASDisplayNode, ContextGesture?) -> Void - let showAttributeInfo: (Any, String) -> Void init( context: AccountContext, - subject: GiftViewScreen.Subject, - openPeer: @escaping (EnginePeer) -> Void, - openAddress: @escaping (String) -> Void, - copyAddress: @escaping (String) -> Void, - updateSavedToProfile: @escaping (Bool) -> Void, - convertToStars: @escaping () -> Void, - openStarsIntro: @escaping () -> Void, - sendGift: @escaping (EnginePeer.Id) -> Void, - changeRecipient: @escaping () -> Void, - openMyGifts: @escaping () -> Void, - transferGift: @escaping () -> Void, - upgradeGift: @escaping ((Int64?, Bool) -> Signal), - buyGift: @escaping ((String, EnginePeer.Id) -> Signal), - shareGift: @escaping () -> Void, - resellGift: @escaping (Bool) -> Void, - viewUpgraded: @escaping (EngineMessage.Id) -> Void, - openMore: @escaping (ASDisplayNode, ContextGesture?) -> Void, - showAttributeInfo: @escaping (Any, String) -> Void + subject: GiftViewScreen.Subject ) { self.context = context self.subject = subject - self.openPeer = openPeer - self.openAddress = openAddress - self.copyAddress = copyAddress - self.updateSavedToProfile = updateSavedToProfile - self.convertToStars = convertToStars - self.openStarsIntro = openStarsIntro - self.sendGift = sendGift - self.changeRecipient = changeRecipient - self.openMyGifts = openMyGifts - self.transferGift = transferGift - self.upgradeGift = upgradeGift - self.buyGift = buyGift - self.shareGift = shareGift - self.resellGift = resellGift - self.viewUpgraded = viewUpgraded - self.openMore = openMore - self.showAttributeInfo = showAttributeInfo } static func ==(lhs: GiftViewSheetComponent, rhs: GiftViewSheetComponent) -> Bool { @@ -2712,32 +3373,7 @@ private final class GiftViewSheetComponent: CombinedComponent { content: AnyComponent(GiftViewSheetContent( context: context.component.context, subject: context.component.subject, - cancel: { animate in - if animate { - if let controller = controller() as? GiftViewScreen { - controller.dismissAnimated() - } - } else if let controller = controller() { - controller.dismiss(animated: false, completion: nil) - } - }, - openPeer: context.component.openPeer, - openAddress: context.component.openAddress, - copyAddress: context.component.copyAddress, - updateSavedToProfile: context.component.updateSavedToProfile, - convertToStars: context.component.convertToStars, - openStarsIntro: context.component.openStarsIntro, - sendGift: context.component.sendGift, - changeRecipient: context.component.changeRecipient, - openMyGifts: context.component.openMyGifts, - transferGift: context.component.transferGift, - upgradeGift: context.component.upgradeGift, - buyGift: context.component.buyGift, - shareGift: context.component.shareGift, - resellGift: context.component.resellGift, - showAttributeInfo: context.component.showAttributeInfo, - viewUpgraded: context.component.viewUpgraded, - openMore: context.component.openMore, + animateOut: animateOut, getController: controller )), backgroundColor: .color(environment.theme.actionSheet.opaqueItemBackgroundColor), @@ -2884,14 +3520,7 @@ public class GiftViewScreen: ViewControllerComponentContainer { } private let context: AccountContext - fileprivate var subject: GiftViewScreen.Subject { - didSet { - self.updateSubject.invoke(self.subject) - } - } - let updateSubject = ActionSlot() - - public var disposed: () -> Void = {} + private let subject: GiftViewScreen.Subject fileprivate var showBalance = false { didSet { @@ -2900,11 +3529,22 @@ public class GiftViewScreen: ViewControllerComponentContainer { } private let balanceOverlay = ComponentView() - private let hapticFeedback = HapticFeedback() + fileprivate let updateSavedToProfile: ((StarGiftReference, Bool) -> Void)? + fileprivate let convertToStars: (() -> Void)? + fileprivate let transferGift: ((Bool, EnginePeer.Id) -> Signal)? + fileprivate let upgradeGift: ((Int64?, Bool) -> Signal)? + fileprivate let buyGift: ((String, EnginePeer.Id) -> Signal)? + fileprivate let updateResellStars: ((Int64?) -> Signal)? + fileprivate let togglePinnedToTop: ((Bool) -> Bool)? + fileprivate let shareStory: ((StarGift.UniqueGift) -> Void)? + + public var disposed: () -> Void = {} public init( context: AccountContext, subject: GiftViewScreen.Subject, + allSubjects: [GiftViewScreen.Subject]? = nil, + index: Int? = nil, forceDark: Bool = false, updateSavedToProfile: ((StarGiftReference, Bool) -> Void)? = nil, convertToStars: (() -> Void)? = nil, @@ -2918,800 +3558,43 @@ public class GiftViewScreen: ViewControllerComponentContainer { self.context = context self.subject = subject - var openPeerImpl: ((EnginePeer, Bool) -> Void)? - var openAddressImpl: ((String) -> Void)? - var copyAddressImpl: ((String) -> Void)? - var updateSavedToProfileImpl: ((Bool) -> Void)? - var convertToStarsImpl: (() -> Void)? - var openStarsIntroImpl: (() -> Void)? - var sendGiftImpl: ((EnginePeer.Id) -> Void)? - var openMyGiftsImpl: (() -> Void)? - var transferGiftImpl: (() -> Void)? - var upgradeGiftImpl: ((Int64?, Bool) -> Signal)? - var buyGiftImpl: ((String, EnginePeer.Id) -> Signal)? - var shareGiftImpl: (() -> Void)? - var resellGiftImpl: ((Bool) -> Void)? - var openMoreImpl: ((ASDisplayNode, ContextGesture?) -> Void)? - var showAttributeInfoImpl: ((Any, String) -> Void)? - var viewUpgradedImpl: ((EngineMessage.Id) -> Void)? - + self.updateSavedToProfile = updateSavedToProfile + self.convertToStars = convertToStars + self.transferGift = transferGift + self.upgradeGift = upgradeGift + self.buyGift = buyGift + self.updateResellStars = updateResellStars + self.togglePinnedToTop = togglePinnedToTop + self.shareStory = shareStory + + var items: [GiftPagerComponent.Item] = [GiftPagerComponent.Item(id: 0, subject: subject)] + if let allSubjects, !allSubjects.isEmpty { + items.removeAll() + for i in 0 ..< allSubjects.count { + items.append(GiftPagerComponent.Item(id: i, subject: allSubjects[i])) + } + } + var dismissTooltipsImpl: (() -> Void)? super.init( context: context, - component: GiftViewSheetComponent( + component: GiftPagerComponent( context: context, - subject: subject, - openPeer: { peerId in - openPeerImpl?(peerId, false) - }, - openAddress: { address in - openAddressImpl?(address) - }, - copyAddress: { address in - copyAddressImpl?(address) - }, - updateSavedToProfile: { added in - updateSavedToProfileImpl?(added) - }, - convertToStars: { - convertToStarsImpl?() - }, - openStarsIntro: { - openStarsIntroImpl?() - }, - sendGift: { peerId in - sendGiftImpl?(peerId) - }, - changeRecipient: { - - }, - openMyGifts: { - openMyGiftsImpl?() - }, - transferGift: { - transferGiftImpl?() - }, - upgradeGift: { formId, keepOriginalInfo in - return upgradeGiftImpl?(formId, keepOriginalInfo) ?? .complete() - }, - buyGift: { slug, peerId in - return buyGiftImpl?(slug, peerId) ?? .complete() - }, - shareGift: { - shareGiftImpl?() - }, - resellGift: { update in - resellGiftImpl?(update) - }, - viewUpgraded: { messageId in - viewUpgradedImpl?(messageId) - }, - openMore: { node, gesture in - openMoreImpl?(node, gesture) - }, - showAttributeInfo: { tag, text in - showAttributeInfoImpl?(tag, text) + items: items, + index: index ?? 0, + updated: { _, _ in + dismissTooltipsImpl?() } ), navigationBarAppearance: .none, statusBarStyle: .ignore, theme: forceDark ? .dark : .default ) + dismissTooltipsImpl = { [weak self] in + self?.dismissAllTooltips() + } self.navigationPresentation = .flatModal self.automaticallyControlPresentationContextLayout = false - - openPeerImpl = { [weak self] peer, gifts in - guard let self, let navigationController = self.navigationController as? NavigationController else { - return - } - self.dismissAllTooltips() - - if gifts { - if let controller = context.sharedContext.makePeerInfoController( - context: context, - updatedPresentationData: nil, - peer: peer._asPeer(), - mode: .gifts, - avatarInitiallyExpanded: false, - fromChat: false, - requestsContext: nil - ) { - self.push(controller) - } - } else { - context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, chatController: nil, context: context, chatLocation: .peer(peer), subject: nil, botStart: nil, updateTextInputState: nil, keepStack: .always, useExisting: true, purposefulAction: nil, scrollToEndIfExists: false, activateMessageSearch: nil, animated: true)) - } - } - - let presentationData = self.context.sharedContext.currentPresentationData.with { $0 } - openAddressImpl = { [weak self] address in - if let navigationController = self?.navigationController as? NavigationController { - let configuration = GiftViewConfiguration.with(appConfiguration: context.currentAppConfiguration.with { $0 }) - let url = configuration.explorerUrl + address - Queue.mainQueue().after(0.3) { - context.sharedContext.openExternalUrl(context: context, urlContext: .generic, url: url, forceExternal: false, presentationData: presentationData, navigationController: navigationController, dismissInput: {}) - } - } - } - copyAddressImpl = { [weak self] address in - guard let self else { - return - } - UIPasteboard.general.string = address - - self.dismissAllTooltips() - - self.present(UndoOverlayController(presentationData: presentationData, content: .copy(text: presentationData.strings.Gift_View_CopiedAddress), elevatedLayout: false, position: .bottom, action: { _ in return true }), in: .current) - - HapticFeedback().tap() - } - updateSavedToProfileImpl = { [weak self] added in - guard let self, let arguments = self.subject.arguments, let reference = arguments.reference else { - return - } - - var animationFile: TelegramMediaFile? - switch arguments.gift { - case let .generic(gift): - animationFile = gift.file - case let .unique(gift): - for attribute in gift.attributes { - if case let .model(_, file, _) = attribute { - animationFile = file - break - } - } - } - - if let updateSavedToProfile { - updateSavedToProfile(reference, added) - } else { - let _ = (context.engine.payments.updateStarGiftAddedToProfile(reference: reference, added: added) - |> deliverOnMainQueue).startStandalone() - } - - self.dismissAnimated() - - let giftsPeerId: EnginePeer.Id? - let text: String - if case let .peer(peerId, _) = arguments.reference, peerId.namespace == Namespaces.Peer.CloudChannel { - giftsPeerId = peerId - text = added ? presentationData.strings.Gift_Displayed_ChannelText : presentationData.strings.Gift_Hidden_ChannelText - } else { - giftsPeerId = context.account.peerId - text = added ? presentationData.strings.Gift_Displayed_NewText : presentationData.strings.Gift_Hidden_NewText - } - - if let navigationController = self.navigationController as? NavigationController { - Queue.mainQueue().after(0.5) { - if let lastController = navigationController.viewControllers.last as? ViewController, let animationFile { - let resultController = UndoOverlayController( - presentationData: presentationData, - content: .sticker(context: context, file: animationFile, loop: false, title: nil, text: text, undoText: updateSavedToProfile == nil ? presentationData.strings.Gift_Displayed_View : nil, customAction: nil), - elevatedLayout: lastController is ChatController, - action: { [weak navigationController] action in - if case .undo = action, let navigationController, let giftsPeerId { - let _ = (context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: giftsPeerId)) - |> deliverOnMainQueue).start(next: { [weak navigationController] peer in - guard let peer, let navigationController else { - return - } - if let controller = context.sharedContext.makePeerInfoController( - context: context, - updatedPresentationData: nil, - peer: peer._asPeer(), - mode: giftsPeerId == context.account.peerId ? .myProfileGifts : .gifts, - avatarInitiallyExpanded: false, - fromChat: false, - requestsContext: nil - ) { - navigationController.pushViewController(controller, animated: true) - } - }) - } - return true - } - ) - lastController.present(resultController, in: .window(.root)) - } - } - } - } - - convertToStarsImpl = { [weak self] in - guard let self, let starsContext = context.starsContext, let arguments = self.subject.arguments, let reference = arguments.reference, let fromPeerName = arguments.fromPeerName, let convertStars = arguments.convertStars, let navigationController = self.navigationController as? NavigationController else { - return - } - - let configuration = GiftConfiguration.with(appConfiguration: context.currentAppConfiguration.with { $0 }) - let starsConvertMaxDate = arguments.date + configuration.convertToStarsPeriod - - var isChannelGift = false - if case let .peer(peerId, _) = reference, peerId.namespace == Namespaces.Peer.CloudChannel { - isChannelGift = true - } - - let currentTime = Int32(CFAbsoluteTimeGetCurrent() + kCFAbsoluteTimeIntervalSince1970) - if currentTime > starsConvertMaxDate { - let days: Int32 = Int32(ceil(Float(configuration.convertToStarsPeriod) / 86400.0)) - let controller = textAlertController( - context: self.context, - title: presentationData.strings.Gift_Convert_Title, - text: presentationData.strings.Gift_Convert_Period_Unavailable_Text(presentationData.strings.Gift_Convert_Period_Unavailable_Days(days)).string, - actions: [ - TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_OK, action: {}) - ], - parseMarkdown: true - ) - self.present(controller, in: .window(.root)) - } else { - let delta = starsConvertMaxDate - currentTime - let days: Int32 = Int32(ceil(Float(delta) / 86400.0)) - - let text = presentationData.strings.Gift_Convert_Period_Text(fromPeerName, presentationData.strings.Gift_Convert_Period_Stars(Int32(convertStars)), presentationData.strings.Gift_Convert_Period_Days(days)).string - let controller = textAlertController( - context: self.context, - title: presentationData.strings.Gift_Convert_Title, - text: text, - actions: [ - TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_Cancel, action: {}), - TextAlertAction(type: .defaultAction, title: presentationData.strings.Gift_Convert_Convert, action: { [weak self, weak navigationController] in - if let convertToStars { - convertToStars() - } else { - let _ = (context.engine.payments.convertStarGift(reference: reference) - |> deliverOnMainQueue).startStandalone() - } - self?.dismissAnimated() - - if let navigationController { - Queue.mainQueue().after(0.5) { - starsContext.load(force: true) - - let text: String - if isChannelGift { - text = presentationData.strings.Gift_Convert_Success_ChannelText(presentationData.strings.Gift_Convert_Success_ChannelText_Stars(Int32(convertStars))).string - } else { - text = presentationData.strings.Gift_Convert_Success_Text(presentationData.strings.Gift_Convert_Success_Text_Stars(Int32(convertStars))).string - if let starsContext = context.starsContext { - navigationController.pushViewController(context.sharedContext.makeStarsTransactionsScreen(context: context, starsContext: starsContext), animated: true) - } - } - - if let lastController = navigationController.viewControllers.last as? ViewController { - let resultController = UndoOverlayController( - presentationData: presentationData, - content: .universal( - animation: "StarsBuy", - scale: 0.066, - colors: [:], - title: presentationData.strings.Gift_Convert_Success_Title, - text: text, - customUndoText: nil, - timeout: nil - ), - elevatedLayout: lastController is ChatController, - action: { _ in return true} - ) - lastController.present(resultController, in: .window(.root)) - } - } - } - }) - ], - parseMarkdown: true - ) - self.present(controller, in: .window(.root)) - } - } - - openStarsIntroImpl = { [weak self] in - guard let self else { - return - } - let introController = context.sharedContext.makeStarsIntroScreen(context: context) - self.push(introController) - } - - sendGiftImpl = { [weak self] peerId in - guard let self else { - return - } - let _ = (context.engine.payments.premiumGiftCodeOptions(peerId: nil, onlyCached: true) - |> filter { !$0.isEmpty } - |> deliverOnMainQueue).start(next: { giftOptions in - let premiumOptions = giftOptions.filter { $0.users == 1 }.map { CachedPremiumGiftOption(months: $0.months, currency: $0.currency, amount: $0.amount, botUrl: "", storeProductId: $0.storeProductId) } - let controller = context.sharedContext.makeGiftOptionsController(context: context, peerId: peerId, premiumOptions: premiumOptions, hasBirthday: false, completion: nil) - self.push(controller) - }) - } - - openMyGiftsImpl = { [weak self] in - guard let self, let navigationController = self.navigationController as? NavigationController else { - return - } - let _ = (context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: context.account.peerId)) - |> deliverOnMainQueue).start(next: { [weak navigationController] peer in - guard let peer, let navigationController else { - return - } - if let controller = context.sharedContext.makePeerInfoController( - context: context, - updatedPresentationData: nil, - peer: peer._asPeer(), - mode: .myProfileGifts, - avatarInitiallyExpanded: false, - fromChat: false, - requestsContext: nil - ) { - navigationController.pushViewController(controller, animated: true) - } - }) - } - - transferGiftImpl = { [weak self] in - guard let self, let arguments = self.subject.arguments, let navigationController = self.navigationController as? NavigationController, case let .unique(gift) = arguments.gift, let reference = arguments.reference, let transferStars = arguments.transferStars else { - return - } - - let currentTime = Int32(CFAbsoluteTimeGetCurrent() + kCFAbsoluteTimeIntervalSince1970) - if let canTransferDate = arguments.canTransferDate, currentTime < canTransferDate { - let dateString = stringForFullDate(timestamp: canTransferDate, strings: presentationData.strings, dateTimeFormat: presentationData.dateTimeFormat) - let controller = textAlertController( - context: self.context, - title: presentationData.strings.Gift_Transfer_Unavailable_Title, - text: presentationData.strings.Gift_Transfer_Unavailable_Text(dateString).string, - actions: [ - TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_OK, action: {}) - ], - parseMarkdown: true - ) - self.present(controller, in: .window(.root)) - return - } - - - let _ = (context.account.stateManager.contactBirthdays - |> take(1) - |> deliverOnMainQueue).start(next: { birthdays in - var showSelf = false - if arguments.peerId?.namespace == Namespaces.Peer.CloudChannel { - showSelf = true - } - let controller = context.sharedContext.makePremiumGiftController(context: context, source: .starGiftTransfer(birthdays, reference, gift, transferStars, arguments.canExportDate, showSelf), completion: { peerIds in - guard let peerId = peerIds.first else { - return .complete() - } - Queue.mainQueue().after(1.5, { - if transferStars > 0 { - context.starsContext?.load(force: true) - } - }) - if let transferGift { - return transferGift(transferStars == 0, peerId) - } else { - return (context.engine.payments.transferStarGift(prepaid: transferStars == 0, reference: reference, peerId: peerId) - |> deliverOnMainQueue) - } - }) - navigationController.pushViewController(controller) - }) - } - - upgradeGiftImpl = { [weak self] formId, keepOriginalInfo in - guard let self, let arguments = self.subject.arguments, let reference = arguments.reference else { - return .complete() - } - if let upgradeGift { - return upgradeGift(formId, keepOriginalInfo) - |> afterCompleted { - if formId != nil { - context.starsContext?.load(force: true) - } - } - } else { - return self.context.engine.payments.upgradeStarGift(formId: formId, reference: reference, keepOriginalInfo: keepOriginalInfo) - |> afterCompleted { - if formId != nil { - context.starsContext?.load(force: true) - } - } - } - } - - buyGiftImpl = { [weak self] slug, peerId in - guard let self else { - return .complete() - } - if let buyGift { - return buyGift(slug, peerId) - |> afterCompleted { - context.starsContext?.load(force: true) - } - } else { - return self.context.engine.payments.buyStarGift(slug: slug, peerId: peerId) - |> afterCompleted { - context.starsContext?.load(force: true) - } - } - } - - shareGiftImpl = { [weak self] in - guard let self, let arguments = self.subject.arguments, case let .unique(gift) = arguments.gift else { - return - } - - var shareStoryImpl: (() -> Void)? - if let shareStory { - shareStoryImpl = { - shareStory(gift) - } - } - let link = "https://t.me/nft/\(gift.slug)" - let shareController = context.sharedContext.makeShareController( - context: context, - subject: .url(link), - forceExternal: false, - shareStory: shareStoryImpl, - enqueued: { peerIds, _ in - let _ = (context.engine.data.get( - EngineDataList( - peerIds.map(TelegramEngine.EngineData.Item.Peer.Peer.init) - ) - ) - |> deliverOnMainQueue).startStandalone(next: { [weak self] peerList in - let peers = peerList.compactMap { $0 } - let presentationData = context.sharedContext.currentPresentationData.with { $0 } - let text: String - var savedMessages = false - if peerIds.count == 1, let peerId = peerIds.first, peerId == context.account.peerId { - text = presentationData.strings.Conversation_ForwardTooltip_SavedMessages_One - savedMessages = true - } else { - if peers.count == 1, let peer = peers.first { - var peerName = peer.id == context.account.peerId ? presentationData.strings.DialogList_SavedMessages : peer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder) - peerName = peerName.replacingOccurrences(of: "**", with: "") - text = presentationData.strings.Conversation_ForwardTooltip_Chat_One(peerName).string - } else if peers.count == 2, let firstPeer = peers.first, let secondPeer = peers.last { - var firstPeerName = firstPeer.id == context.account.peerId ? presentationData.strings.DialogList_SavedMessages : firstPeer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder) - firstPeerName = firstPeerName.replacingOccurrences(of: "**", with: "") - var secondPeerName = secondPeer.id == context.account.peerId ? presentationData.strings.DialogList_SavedMessages : secondPeer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder) - secondPeerName = secondPeerName.replacingOccurrences(of: "**", with: "") - text = presentationData.strings.Conversation_ForwardTooltip_TwoChats_One(firstPeerName, secondPeerName).string - } else if let peer = peers.first { - var peerName = peer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder) - peerName = peerName.replacingOccurrences(of: "**", with: "") - text = presentationData.strings.Conversation_ForwardTooltip_ManyChats_One(peerName, "\(peers.count - 1)").string - } else { - text = "" - } - } - - self?.present(UndoOverlayController(presentationData: presentationData, content: .forward(savedMessages: savedMessages, text: text), elevatedLayout: false, animateInAsReplacement: false, action: { action in - if savedMessages, action == .info { - let _ = (context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: context.account.peerId)) - |> deliverOnMainQueue).start(next: { peer in - guard let peer else { - return - } - openPeerImpl?(peer, false) - Queue.mainQueue().after(0.6) { - self?.dismiss(animated: false, completion: nil) - } - }) - } - return false - }, additionalView: nil), in: .current) - }) - }, - actionCompleted: { [weak self] in - self?.present(UndoOverlayController(presentationData: presentationData, content: .linkCopied(title: nil, text: presentationData.strings.Conversation_LinkCopied), elevatedLayout: false, animateInAsReplacement: false, action: { _ in return false }), in: .current) - } - ) - self.present(shareController, in: .window(.root)) - } - - resellGiftImpl = { [weak self] update in - guard let self, let arguments = self.subject.arguments, case let .unique(gift) = arguments.gift else { - return - } - - self.dismissAllTooltips() - - let currentTime = Int32(CFAbsoluteTimeGetCurrent() + kCFAbsoluteTimeIntervalSince1970) - if let canResaleDate = arguments.canResaleDate, currentTime < canResaleDate { - let dateString = stringForFullDate(timestamp: canResaleDate, strings: presentationData.strings, dateTimeFormat: presentationData.dateTimeFormat) - let controller = textAlertController( - context: self.context, - title: presentationData.strings.Gift_Resale_Unavailable_Title, - text: presentationData.strings.Gift_Resale_Unavailable_Text(dateString).string, - actions: [ - TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_OK, action: {}) - ], - parseMarkdown: true - ) - self.present(controller, in: .window(.root)) - return - } - - let presentationData = context.sharedContext.currentPresentationData.with { $0 } - let giftTitle = "\(gift.title) #\(presentationStringsFormattedNumber(gift.number, presentationData.dateTimeFormat.groupingSeparator))" - let reference = arguments.reference ?? .slug(slug: gift.slug) - - if let resellStars = gift.resellStars, resellStars > 0, !update { - let alertController = textAlertController( - context: context, - title: presentationData.strings.Gift_View_Resale_Unlist_Title, - text: presentationData.strings.Gift_View_Resale_Unlist_Text, - actions: [ - TextAlertAction(type: .defaultAction, title: presentationData.strings.Gift_View_Resale_Unlist_Unlist, action: { [weak self] in - guard let self else { - return - } - let _ = ((updateResellStars?(nil) ?? context.engine.payments.updateStarGiftResalePrice(reference: reference, price: nil)) - |> deliverOnMainQueue).startStandalone(error: { error in - - }, completed: { - switch self.subject { - case let .profileGift(peerId, currentSubject): - self.subject = .profileGift(peerId, currentSubject.withGift(.unique(gift.withResellStars(nil)))) - case let .uniqueGift(_, recipientPeerId): - self.subject = .uniqueGift(gift.withResellStars(nil), recipientPeerId) - default: - break - } - - let text = presentationData.strings.Gift_View_Resale_Unlist_Success(giftTitle).string - let tooltipController = UndoOverlayController( - presentationData: presentationData, - content: .universalImage( - image: generateTintedImage(image: UIImage(bundleImageName: "Premium/Collectible/Unlist"), color: .white)!, - size: nil, - title: nil, - text: text, - customUndoText: nil, - timeout: 3.0 - ), - position: .bottom, - animateInAsReplacement: false, - appearance: UndoOverlayController.Appearance(sideInset: 16.0, bottomInset: 62.0), - action: { action in - return false - } - ) - self.present(tooltipController, in: .window(.root)) - }) - }), - TextAlertAction(type: .genericAction, title: presentationData.strings.Common_Cancel, action: { - }) - ], - actionLayout: .vertical - ) - self.present(alertController, in: .window(.root)) - } else { - let resellController = context.sharedContext.makeStarGiftResellScreen(context: context, update: update, completion: { [weak self] price in - guard let self else { - return - } - - let _ = ((updateResellStars?(price) ?? context.engine.payments.updateStarGiftResalePrice(reference: reference, price: price)) - |> deliverOnMainQueue).startStandalone(error: { [weak self] error in - guard let self else { - return - } - - let title: String? - let text: String - switch error { - case .generic: - title = nil - text = presentationData.strings.Gift_Send_ErrorUnknown - case let .starGiftResellTooEarly(canResaleDate): - let dateString = stringForFullDate(timestamp: canResaleDate, strings: presentationData.strings, dateTimeFormat: presentationData.dateTimeFormat) - title = presentationData.strings.Gift_Resale_Unavailable_Title - text = presentationData.strings.Gift_Resale_Unavailable_Text(dateString).string - } - - let controller = textAlertController( - context: self.context, - title: title, - text: text, - actions: [ - TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_OK, action: {}) - ], - parseMarkdown: true - ) - self.present(controller, in: .window(.root)) - }, completed: { [weak self] in - guard let self else { - return - } - - switch self.subject { - case let .profileGift(peerId, currentSubject): - self.subject = .profileGift(peerId, currentSubject.withGift(.unique(gift.withResellStars(price)))) - case let .uniqueGift(_, recipientPeerId): - self.subject = .uniqueGift(gift.withResellStars(price), recipientPeerId) - default: - break - } - - var text = presentationData.strings.Gift_View_Resale_List_Success(giftTitle).string - if update { - let starsString = presentationData.strings.Gift_View_Resale_Relist_Success_Stars(Int32(price)) - text = presentationData.strings.Gift_View_Resale_Relist_Success(giftTitle, starsString).string - } - - let tooltipController = UndoOverlayController( - presentationData: presentationData, - content: .universalImage( - image: generateTintedImage(image: UIImage(bundleImageName: "Premium/Collectible/Sell"), color: .white)!, - size: nil, - title: nil, - text: text, - customUndoText: nil, - timeout: 3.0 - ), - position: .bottom, - animateInAsReplacement: false, - appearance: UndoOverlayController.Appearance(sideInset: 16.0, bottomInset: 62.0), - action: { action in - return false - } - ) - self.present(tooltipController, in: .window(.root)) - }) - }) - self.push(resellController) - } - } - - viewUpgradedImpl = { [weak self] messageId in - guard let self, let navigationController = self.navigationController as? NavigationController else { - return - } - let _ = (context.engine.data.get( - TelegramEngine.EngineData.Item.Peer.Peer(id: messageId.peerId) - ) - |> deliverOnMainQueue).start(next: { [weak navigationController] peer in - guard let peer, let navigationController else { - return - } - context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: context, chatLocation: .peer(peer), subject: .message(id: .id(messageId), highlight: ChatControllerSubject.MessageHighlight(quote: nil), timecode: nil, setupReply: false), keepStack: .always, useExisting: true, purposefulAction: {}, peekData: nil, forceAnimatedScroll: true)) - }) - } - - showAttributeInfoImpl = { [weak self] tag, text in - guard let self else { - return - } - self.dismissAllTooltips() - - guard let sourceView = self.node.hostView.findTaggedView(tag: tag), let absoluteLocation = sourceView.superview?.convert(sourceView.center, to: self.view) else { - return - } - - let location = CGRect(origin: CGPoint(x: absoluteLocation.x, y: absoluteLocation.y - 12.0), size: CGSize()) - let controller = TooltipScreen(account: self.context.account, sharedContext: self.context.sharedContext, text: .plain(text: text), style: .wide, location: .point(location, .bottom), displayDuration: .default, inset: 16.0, shouldDismissOnTouch: { _, _ in - return .ignore - }) - self.present(controller, in: .current) - } - - openMoreImpl = { [weak self] node, gesture in - guard let self, let arguments = self.subject.arguments, case let .unique(gift) = arguments.gift else { - return - } - - let presentationData = context.sharedContext.currentPresentationData.with { $0 } - let link = "https://t.me/nft/\(gift.slug)" - - let _ = (context.engine.data.get( - TelegramEngine.EngineData.Item.Peer.Peer(id: arguments.peerId ?? context.account.peerId) - ) - |> deliverOnMainQueue).start(next: { [weak self] peer in - guard let self else { - return - } - var items: [ContextMenuItem] = [] - let strings = presentationData.strings - - if let _ = arguments.reference, case .unique = arguments.gift, let togglePinnedToTop, let pinnedToTop = arguments.pinnedToTop { - items.append(.action(ContextMenuActionItem(text: pinnedToTop ? strings.PeerInfo_Gifts_Context_Unpin : strings.PeerInfo_Gifts_Context_Pin , icon: { theme in generateTintedImage(image: UIImage(bundleImageName: pinnedToTop ? "Chat/Context Menu/Unpin" : "Chat/Context Menu/Pin"), color: theme.contextMenu.primaryColor) }, action: { [weak self] c, f in - c?.dismiss(completion: { [weak self] in - guard let self else { - return - } - - let pinnedToTop = !pinnedToTop - if togglePinnedToTop(pinnedToTop) { - if pinnedToTop { - self.dismissAnimated() - } else { - let toastText = strings.PeerInfo_Gifts_ToastUnpinned_Text - self.present(UndoOverlayController(presentationData: presentationData, content: .universal(animation: "anim_toastunpin", scale: 0.06, colors: [:], title: nil, text: toastText, customUndoText: nil, timeout: 5), elevatedLayout: false, animateInAsReplacement: false, action: { _ in return false }), in: .current) - if case let .profileGift(peerId, gift) = self.subject { - self.subject = .profileGift(peerId, gift.withPinnedToTop(false)) - } - } - } - }) - }))) - } - - if case let .unique(gift) = arguments.gift, let resellStars = gift.resellStars, resellStars > 0 { - if arguments.reference != nil || gift.owner.peerId == context.account.peerId { - items.append(.action(ContextMenuActionItem(text: presentationData.strings.Gift_View_Context_ChangePrice, icon: { theme in - return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/PriceTag"), color: theme.contextMenu.primaryColor) - }, action: { c, _ in - c?.dismiss(completion: nil) - - resellGiftImpl?(true) - }))) - } - } - - items.append(.action(ContextMenuActionItem(text: presentationData.strings.Gift_View_Context_CopyLink, icon: { theme in - return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Link"), color: theme.contextMenu.primaryColor) - }, action: { [weak self] c, _ in - c?.dismiss(completion: nil) - - guard let self else { - return - } - - UIPasteboard.general.string = link - - self.present(UndoOverlayController(presentationData: presentationData, content: .linkCopied(title: nil, text: presentationData.strings.Conversation_LinkCopied), elevatedLayout: false, animateInAsReplacement: false, action: { _ in return false }), in: .current) - }))) - - items.append(.action(ContextMenuActionItem(text: presentationData.strings.Gift_View_Context_Share, icon: { theme in - return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Forward"), color: theme.contextMenu.primaryColor) - }, action: { c, _ in - c?.dismiss(completion: nil) - - shareGiftImpl?() - }))) - - if let _ = arguments.transferStars { - if case let .channel(channel) = peer, !channel.flags.contains(.isCreator) { - - } else { - items.append(.action(ContextMenuActionItem(text: presentationData.strings.Gift_View_Context_Transfer, icon: { theme in - return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Replace"), color: theme.contextMenu.primaryColor) - }, action: { c, _ in - c?.dismiss(completion: nil) - - transferGiftImpl?() - }))) - } - } - - if let _ = arguments.resellStars, case let .uniqueGift(uniqueGift, recipientPeerId) = subject, let _ = recipientPeerId { - items.append(.action(ContextMenuActionItem(text: presentationData.strings.Gift_View_Context_ViewInProfile, icon: { theme in - return generateTintedImage(image: UIImage(bundleImageName: "Peer Info/ShowIcon"), color: theme.contextMenu.primaryColor) - }, action: { c, _ in - c?.dismiss(completion: nil) - - if case let .peerId(peerId) = uniqueGift.owner { - let _ = (context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: peerId)) - |> deliverOnMainQueue).start(next: { [weak self] peer in - guard let self, let peer else { - return - } - openPeerImpl?(peer, true) - Queue.mainQueue().after(0.6) { - self.dismiss(animated: false, completion: nil) - } - }) - } - }))) - } - - let contextController = ContextController(presentationData: presentationData, source: .reference(GiftViewContextReferenceContentSource(controller: self, sourceNode: node)), items: .single(ContextController.Items(content: .list(items))), gesture: gesture) - self.presentInGlobalOverlay(contextController) - }) - } } required public init(coder aDecoder: NSCoder) { @@ -3853,277 +3736,7 @@ private func formatPercentage(_ value: Float) -> String { return String(format: "%0.1f%%", value).replacingOccurrences(of: ".0%", with: "%").replacingOccurrences(of: ",0%", with: "%") } -private final class TableComponent: CombinedComponent { - class Item: Equatable { - public let id: AnyHashable - public let title: String? - public let hasBackground: Bool - public let component: AnyComponent - public let insets: UIEdgeInsets? - public init(id: IdType, title: String?, hasBackground: Bool = false, component: AnyComponent, insets: UIEdgeInsets? = nil) { - self.id = AnyHashable(id) - self.title = title - self.hasBackground = hasBackground - self.component = component - self.insets = insets - } - - public static func == (lhs: Item, rhs: Item) -> Bool { - if lhs.id != rhs.id { - return false - } - if lhs.title != rhs.title { - return false - } - if lhs.hasBackground != rhs.hasBackground { - return false - } - if lhs.component != rhs.component { - return false - } - if lhs.insets != rhs.insets { - return false - } - return true - } - } - - private let theme: PresentationTheme - private let items: [Item] - - public init(theme: PresentationTheme, items: [Item]) { - self.theme = theme - self.items = items - } - - public static func ==(lhs: TableComponent, rhs: TableComponent) -> Bool { - if lhs.theme !== rhs.theme { - return false - } - if lhs.items != rhs.items { - return false - } - return true - } - - final class State: ComponentState { - var cachedBorderImage: (UIImage, PresentationTheme)? - } - - func makeState() -> State { - return State() - } - - public static var body: Body { - let leftColumnBackground = Child(Rectangle.self) - let lastBackground = Child(Rectangle.self) - let verticalBorder = Child(Rectangle.self) - let titleChildren = ChildMap(environment: Empty.self, keyedBy: AnyHashable.self) - let valueChildren = ChildMap(environment: Empty.self, keyedBy: AnyHashable.self) - let borderChildren = ChildMap(environment: Empty.self, keyedBy: AnyHashable.self) - let outerBorder = Child(Image.self) - - return { context in - let verticalPadding: CGFloat = 11.0 - let horizontalPadding: CGFloat = 12.0 - let borderWidth: CGFloat = 1.0 - - let backgroundColor = context.component.theme.actionSheet.opaqueItemBackgroundColor - let borderColor = backgroundColor.mixedWith(context.component.theme.list.itemBlocksSeparatorColor, alpha: 0.6) - let secondaryBackgroundColor = context.component.theme.overallDarkAppearance ? context.component.theme.list.itemModalBlocksBackgroundColor : context.component.theme.list.itemInputField.backgroundColor - - var leftColumnWidth: CGFloat = 0.0 - - var updatedTitleChildren: [Int: _UpdatedChildComponent] = [:] - var updatedValueChildren: [(_UpdatedChildComponent, UIEdgeInsets)] = [] - var updatedBorderChildren: [_UpdatedChildComponent] = [] - - var i = 0 - for item in context.component.items { - guard let title = item.title else { - i += 1 - continue - } - let titleChild = titleChildren[item.id].update( - component: AnyComponent(MultilineTextComponent( - text: .plain(NSAttributedString(string: title, font: Font.regular(15.0), textColor: context.component.theme.list.itemPrimaryTextColor)) - )), - availableSize: context.availableSize, - transition: context.transition - ) - updatedTitleChildren[i] = titleChild - - if titleChild.size.width > leftColumnWidth { - leftColumnWidth = titleChild.size.width - } - i += 1 - } - - leftColumnWidth = max(100.0, leftColumnWidth + horizontalPadding * 2.0) - let rightColumnWidth = context.availableSize.width - leftColumnWidth - - i = 0 - var rowHeights: [Int: CGFloat] = [:] - var totalHeight: CGFloat = 0.0 - var innerTotalHeight: CGFloat = 0.0 - var hasLastBackground = false - - for item in context.component.items { - let insets: UIEdgeInsets - if let customInsets = item.insets { - insets = customInsets - } else { - insets = UIEdgeInsets(top: 0.0, left: horizontalPadding, bottom: 0.0, right: horizontalPadding) - } - - var titleHeight: CGFloat = 0.0 - if let titleChild = updatedTitleChildren[i] { - titleHeight = titleChild.size.height - } - - let availableValueWidth: CGFloat - if titleHeight > 0.0 { - availableValueWidth = rightColumnWidth - } else { - availableValueWidth = context.availableSize.width - } - - let valueChild = valueChildren[item.id].update( - component: item.component, - availableSize: CGSize(width: availableValueWidth - insets.left - insets.right, height: context.availableSize.height), - transition: context.transition - ) - updatedValueChildren.append((valueChild, insets)) - - let rowHeight = max(40.0, max(titleHeight, valueChild.size.height) + verticalPadding * 2.0) - rowHeights[i] = rowHeight - totalHeight += rowHeight - if titleHeight > 0.0 { - innerTotalHeight += rowHeight - } - - if i < context.component.items.count - 1 { - let borderChild = borderChildren[item.id].update( - component: AnyComponent(Rectangle(color: borderColor)), - availableSize: CGSize(width: context.availableSize.width, height: borderWidth), - transition: context.transition - ) - updatedBorderChildren.append(borderChild) - } - - if item.hasBackground { - hasLastBackground = true - } - - i += 1 - } - - if hasLastBackground { - let lastRowHeight = rowHeights[i - 1] ?? 0 - let lastBackground = lastBackground.update( - component: Rectangle(color: secondaryBackgroundColor), - availableSize: CGSize(width: context.availableSize.width, height: lastRowHeight), - transition: context.transition - ) - context.add( - lastBackground - .position(CGPoint(x: context.availableSize.width / 2.0, y: totalHeight - lastRowHeight / 2.0)) - ) - } - - let leftColumnBackground = leftColumnBackground.update( - component: Rectangle(color: secondaryBackgroundColor), - availableSize: CGSize(width: leftColumnWidth, height: innerTotalHeight), - transition: context.transition - ) - context.add( - leftColumnBackground - .position(CGPoint(x: leftColumnWidth / 2.0, y: innerTotalHeight / 2.0)) - ) - - let borderImage: UIImage - if let (currentImage, theme) = context.state.cachedBorderImage, theme === context.component.theme { - borderImage = currentImage - } else { - let borderRadius: CGFloat = 10.0 - borderImage = generateImage(CGSize(width: 24.0, height: 24.0), rotatedContext: { size, context in - let bounds = CGRect(origin: .zero, size: size) - context.setFillColor(backgroundColor.cgColor) - context.fill(bounds) - - let path = CGPath(roundedRect: bounds.insetBy(dx: borderWidth / 2.0, dy: borderWidth / 2.0), cornerWidth: borderRadius, cornerHeight: borderRadius, transform: nil) - context.setBlendMode(.clear) - context.addPath(path) - context.fillPath() - - context.setBlendMode(.normal) - context.setStrokeColor(borderColor.cgColor) - context.setLineWidth(borderWidth) - context.addPath(path) - context.strokePath() - })!.stretchableImage(withLeftCapWidth: 10, topCapHeight: 10) - context.state.cachedBorderImage = (borderImage, context.component.theme) - } - - let outerBorder = outerBorder.update( - component: Image(image: borderImage), - availableSize: CGSize(width: context.availableSize.width, height: totalHeight), - transition: context.transition - ) - context.add(outerBorder - .position(CGPoint(x: context.availableSize.width / 2.0, y: totalHeight / 2.0)) - ) - - let verticalBorder = verticalBorder.update( - component: Rectangle(color: borderColor), - availableSize: CGSize(width: borderWidth, height: innerTotalHeight), - transition: context.transition - ) - context.add( - verticalBorder - .position(CGPoint(x: leftColumnWidth - borderWidth / 2.0, y: innerTotalHeight / 2.0)) - ) - - i = 0 - var originY: CGFloat = 0.0 - for (valueChild, valueInsets) in updatedValueChildren { - let rowHeight = rowHeights[i] ?? 0.0 - - let valueFrame: CGRect - if let titleChild = updatedTitleChildren[i] { - let titleFrame = CGRect(origin: CGPoint(x: horizontalPadding, y: originY + verticalPadding), size: titleChild.size) - context.add(titleChild - .position(titleFrame.center) - ) - valueFrame = CGRect(origin: CGPoint(x: leftColumnWidth + valueInsets.left, y: originY + verticalPadding), size: valueChild.size) - } else { - if hasLastBackground { - valueFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((context.availableSize.width - valueChild.size.width) / 2.0), y: originY + verticalPadding), size: valueChild.size) - } else { - valueFrame = CGRect(origin: CGPoint(x: horizontalPadding, y: originY + verticalPadding), size: valueChild.size) - } - } - - context.add(valueChild - .position(valueFrame.center) - ) - - if i < updatedBorderChildren.count { - let borderChild = updatedBorderChildren[i] - context.add(borderChild - .position(CGPoint(x: context.availableSize.width / 2.0, y: originY + rowHeight - borderWidth / 2.0)) - ) - } - - originY += rowHeight - i += 1 - } - - return CGSize(width: context.availableSize.width, height: totalHeight) - } - } -} private final class PeerCellComponent: Component { let context: AccountContext diff --git a/submodules/TelegramUI/Components/Gifts/GiftViewScreen/Sources/TableComponent.swift b/submodules/TelegramUI/Components/Gifts/GiftViewScreen/Sources/TableComponent.swift new file mode 100644 index 0000000000..751dd50684 --- /dev/null +++ b/submodules/TelegramUI/Components/Gifts/GiftViewScreen/Sources/TableComponent.swift @@ -0,0 +1,278 @@ +import Foundation +import UIKit +import ComponentFlow +import Display +import TelegramPresentationData +import MultilineTextComponent + +final class TableComponent: CombinedComponent { + class Item: Equatable { + public let id: AnyHashable + public let title: String? + public let hasBackground: Bool + public let component: AnyComponent + public let insets: UIEdgeInsets? + + public init(id: IdType, title: String?, hasBackground: Bool = false, component: AnyComponent, insets: UIEdgeInsets? = nil) { + self.id = AnyHashable(id) + self.title = title + self.hasBackground = hasBackground + self.component = component + self.insets = insets + } + + public static func == (lhs: Item, rhs: Item) -> Bool { + if lhs.id != rhs.id { + return false + } + if lhs.title != rhs.title { + return false + } + if lhs.hasBackground != rhs.hasBackground { + return false + } + if lhs.component != rhs.component { + return false + } + if lhs.insets != rhs.insets { + return false + } + return true + } + } + + private let theme: PresentationTheme + private let items: [Item] + + public init(theme: PresentationTheme, items: [Item]) { + self.theme = theme + self.items = items + } + + public static func ==(lhs: TableComponent, rhs: TableComponent) -> Bool { + if lhs.theme !== rhs.theme { + return false + } + if lhs.items != rhs.items { + return false + } + return true + } + + final class State: ComponentState { + var cachedBorderImage: (UIImage, PresentationTheme)? + } + + func makeState() -> State { + return State() + } + + public static var body: Body { + let leftColumnBackground = Child(Rectangle.self) + let lastBackground = Child(Rectangle.self) + let verticalBorder = Child(Rectangle.self) + let titleChildren = ChildMap(environment: Empty.self, keyedBy: AnyHashable.self) + let valueChildren = ChildMap(environment: Empty.self, keyedBy: AnyHashable.self) + let borderChildren = ChildMap(environment: Empty.self, keyedBy: AnyHashable.self) + let outerBorder = Child(Image.self) + + return { context in + let verticalPadding: CGFloat = 11.0 + let horizontalPadding: CGFloat = 12.0 + let borderWidth: CGFloat = 1.0 + + let backgroundColor = context.component.theme.actionSheet.opaqueItemBackgroundColor + let borderColor = backgroundColor.mixedWith(context.component.theme.list.itemBlocksSeparatorColor, alpha: 0.6) + let secondaryBackgroundColor = context.component.theme.overallDarkAppearance ? context.component.theme.list.itemModalBlocksBackgroundColor : context.component.theme.list.itemInputField.backgroundColor + + var leftColumnWidth: CGFloat = 0.0 + + var updatedTitleChildren: [Int: _UpdatedChildComponent] = [:] + var updatedValueChildren: [(_UpdatedChildComponent, UIEdgeInsets)] = [] + var updatedBorderChildren: [_UpdatedChildComponent] = [] + + var i = 0 + for item in context.component.items { + guard let title = item.title else { + i += 1 + continue + } + let titleChild = titleChildren[item.id].update( + component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString(string: title, font: Font.regular(15.0), textColor: context.component.theme.list.itemPrimaryTextColor)) + )), + availableSize: context.availableSize, + transition: context.transition + ) + updatedTitleChildren[i] = titleChild + + if titleChild.size.width > leftColumnWidth { + leftColumnWidth = titleChild.size.width + } + i += 1 + } + + leftColumnWidth = max(100.0, leftColumnWidth + horizontalPadding * 2.0) + let rightColumnWidth = context.availableSize.width - leftColumnWidth + + i = 0 + var rowHeights: [Int: CGFloat] = [:] + var totalHeight: CGFloat = 0.0 + var innerTotalHeight: CGFloat = 0.0 + var hasLastBackground = false + + for item in context.component.items { + let insets: UIEdgeInsets + if let customInsets = item.insets { + insets = customInsets + } else { + insets = UIEdgeInsets(top: 0.0, left: horizontalPadding, bottom: 0.0, right: horizontalPadding) + } + + var titleHeight: CGFloat = 0.0 + if let titleChild = updatedTitleChildren[i] { + titleHeight = titleChild.size.height + } + + let availableValueWidth: CGFloat + if titleHeight > 0.0 { + availableValueWidth = rightColumnWidth + } else { + availableValueWidth = context.availableSize.width + } + + let valueChild = valueChildren[item.id].update( + component: item.component, + availableSize: CGSize(width: availableValueWidth - insets.left - insets.right, height: context.availableSize.height), + transition: context.transition + ) + updatedValueChildren.append((valueChild, insets)) + + let rowHeight = max(40.0, max(titleHeight, valueChild.size.height) + verticalPadding * 2.0) + rowHeights[i] = rowHeight + totalHeight += rowHeight + if titleHeight > 0.0 { + innerTotalHeight += rowHeight + } + + if i < context.component.items.count - 1 { + let borderChild = borderChildren[item.id].update( + component: AnyComponent(Rectangle(color: borderColor)), + availableSize: CGSize(width: context.availableSize.width, height: borderWidth), + transition: context.transition + ) + updatedBorderChildren.append(borderChild) + } + + if item.hasBackground { + hasLastBackground = true + } + + i += 1 + } + + if hasLastBackground { + let lastRowHeight = rowHeights[i - 1] ?? 0 + let lastBackground = lastBackground.update( + component: Rectangle(color: secondaryBackgroundColor), + availableSize: CGSize(width: context.availableSize.width, height: lastRowHeight), + transition: context.transition + ) + context.add( + lastBackground + .position(CGPoint(x: context.availableSize.width / 2.0, y: totalHeight - lastRowHeight / 2.0)) + ) + } + + let leftColumnBackground = leftColumnBackground.update( + component: Rectangle(color: secondaryBackgroundColor), + availableSize: CGSize(width: leftColumnWidth, height: innerTotalHeight), + transition: context.transition + ) + context.add( + leftColumnBackground + .position(CGPoint(x: leftColumnWidth / 2.0, y: innerTotalHeight / 2.0)) + ) + + let borderImage: UIImage + if let (currentImage, theme) = context.state.cachedBorderImage, theme === context.component.theme { + borderImage = currentImage + } else { + let borderRadius: CGFloat = 10.0 + borderImage = generateImage(CGSize(width: 24.0, height: 24.0), rotatedContext: { size, context in + let bounds = CGRect(origin: .zero, size: size) + context.setFillColor(backgroundColor.cgColor) + context.fill(bounds) + + let path = CGPath(roundedRect: bounds.insetBy(dx: borderWidth / 2.0, dy: borderWidth / 2.0), cornerWidth: borderRadius, cornerHeight: borderRadius, transform: nil) + context.setBlendMode(.clear) + context.addPath(path) + context.fillPath() + + context.setBlendMode(.normal) + context.setStrokeColor(borderColor.cgColor) + context.setLineWidth(borderWidth) + context.addPath(path) + context.strokePath() + })!.stretchableImage(withLeftCapWidth: 10, topCapHeight: 10) + context.state.cachedBorderImage = (borderImage, context.component.theme) + } + + let outerBorder = outerBorder.update( + component: Image(image: borderImage), + availableSize: CGSize(width: context.availableSize.width, height: totalHeight), + transition: context.transition + ) + context.add(outerBorder + .position(CGPoint(x: context.availableSize.width / 2.0, y: totalHeight / 2.0)) + ) + + let verticalBorder = verticalBorder.update( + component: Rectangle(color: borderColor), + availableSize: CGSize(width: borderWidth, height: innerTotalHeight), + transition: context.transition + ) + context.add( + verticalBorder + .position(CGPoint(x: leftColumnWidth - borderWidth / 2.0, y: innerTotalHeight / 2.0)) + ) + + i = 0 + var originY: CGFloat = 0.0 + for (valueChild, valueInsets) in updatedValueChildren { + let rowHeight = rowHeights[i] ?? 0.0 + + let valueFrame: CGRect + if let titleChild = updatedTitleChildren[i] { + let titleFrame = CGRect(origin: CGPoint(x: horizontalPadding, y: originY + verticalPadding), size: titleChild.size) + context.add(titleChild + .position(titleFrame.center) + ) + valueFrame = CGRect(origin: CGPoint(x: leftColumnWidth + valueInsets.left, y: originY + verticalPadding), size: valueChild.size) + } else { + if hasLastBackground { + valueFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((context.availableSize.width - valueChild.size.width) / 2.0), y: originY + verticalPadding), size: valueChild.size) + } else { + valueFrame = CGRect(origin: CGPoint(x: horizontalPadding, y: originY + verticalPadding), size: valueChild.size) + } + } + + context.add(valueChild + .position(valueFrame.center) + ) + + if i < updatedBorderChildren.count { + let borderChild = updatedBorderChildren[i] + context.add(borderChild + .position(CGPoint(x: context.availableSize.width / 2.0, y: originY + rowHeight - borderWidth / 2.0)) + ) + } + + originY += rowHeight + i += 1 + } + + return CGSize(width: context.availableSize.width, height: totalHeight) + } + } +} diff --git a/submodules/TelegramUI/Components/MediaScrubberComponent/Sources/MediaScrubberComponent.swift b/submodules/TelegramUI/Components/MediaScrubberComponent/Sources/MediaScrubberComponent.swift index 7793a3f9ff..457f337bf9 100644 --- a/submodules/TelegramUI/Components/MediaScrubberComponent/Sources/MediaScrubberComponent.swift +++ b/submodules/TelegramUI/Components/MediaScrubberComponent/Sources/MediaScrubberComponent.swift @@ -825,6 +825,14 @@ public final class MediaScrubberComponent: Component { transition: transition ) } + } else { + for (_ , trackView) in self.trackViews { + trackView.updateTrimEdges( + left: leftHandleFrame.minX, + right: rightHandleFrame.maxX, + transition: transition + ) + } } let isDraggingTracks = self.trackViews.values.contains(where: { $0.isDragging }) @@ -863,7 +871,6 @@ public final class MediaScrubberComponent: Component { transition.setFrame(view: self.cursorImageView, frame: CGRect(origin: .zero, size: self.cursorView.frame.size)) - if let (coverPosition, coverImage) = component.cover { let imageSize = CGSize(width: 36.0, height: 36.0) var animateFrame = false @@ -964,6 +971,7 @@ private class TrackView: UIView, UIScrollViewDelegate, UIGestureRecognizerDelega fileprivate let audioIconView: UIImageView fileprivate let audioTitle = ComponentView() + fileprivate let segmentsContainerView = UIView() fileprivate var segmentTitles: [Int32: ComponentView] = [:] fileprivate var segmentLayers: [Int32: SimpleLayer] = [:] @@ -1037,7 +1045,10 @@ private class TrackView: UIView, UIScrollViewDelegate, UIGestureRecognizerDelega self.clippingView.addSubview(self.scrollView) self.scrollView.addSubview(self.containerView) self.backgroundView.addSubview(self.vibrancyView) - + + self.segmentsContainerView.clipsToBounds = true + self.segmentsContainerView.isUserInteractionEnabled = false + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(self.handleTap(_:))) self.addGestureRecognizer(tapGesture) @@ -1133,6 +1144,25 @@ private class TrackView: UIView, UIScrollViewDelegate, UIGestureRecognizerDelega } } + private var leftTrimEdge: CGFloat? + private var rightTrimEdge: CGFloat? + func updateTrimEdges( + left: CGFloat, + right: CGFloat, + transition: ComponentTransition + ) { + self.leftTrimEdge = left + self.rightTrimEdge = right + + if let params = self.params { + self.updateSegmentContainer( + scrubberSize: CGSize(width: params.availableSize.width, height: trackHeight), + availableSize: params.availableSize, + transition: transition + ) + } + } + private func updateThumbnailContainers( scrubberSize: CGSize, availableSize: CGSize, @@ -1146,6 +1176,17 @@ private class TrackView: UIView, UIScrollViewDelegate, UIGestureRecognizerDelega transition.setBounds(view: self.videoOpaqueFramesContainer, bounds: CGRect(origin: CGPoint(x: containerLeftEdge, y: 0.0), size: CGSize(width: containerRightEdge - containerLeftEdge, height: scrubberSize.height))) } + private func updateSegmentContainer( + scrubberSize: CGSize, + availableSize: CGSize, + transition: ComponentTransition + ) { + let containerLeftEdge: CGFloat = self.leftTrimEdge ?? 0.0 + let containerRightEdge: CGFloat = self.rightTrimEdge ?? availableSize.width + + transition.setFrame(view: self.segmentsContainerView, frame: CGRect(origin: CGPoint(x: containerLeftEdge, y: 0.0), size: CGSize(width: containerRightEdge - containerLeftEdge - 2.0, height: scrubberSize.height))) + } + func update( context: AccountContext, style: MediaScrubberComponent.Style, @@ -1281,6 +1322,7 @@ private class TrackView: UIView, UIScrollViewDelegate, UIGestureRecognizerDelega if self.videoTransparentFramesContainer.superview == nil { self.containerView.addSubview(self.videoTransparentFramesContainer) self.containerView.addSubview(self.videoOpaqueFramesContainer) + self.containerView.addSubview(self.segmentsContainerView) } var previousFramesUpdateTimestamp: Double? if let previousParams, case let .video(_, previousFramesUpdateTimestampValue) = previousParams.track.content { @@ -1333,6 +1375,12 @@ private class TrackView: UIView, UIScrollViewDelegate, UIGestureRecognizerDelega transition: transition ) + self.updateSegmentContainer( + scrubberSize: scrubberSize, + availableSize: availableSize, + transition: transition + ) + var frameAspectRatio = 0.66 if let image = frames.first, image.size.height > 0.0 { frameAspectRatio = max(0.66, image.size.width / image.size.height) @@ -1488,9 +1536,8 @@ private class TrackView: UIView, UIScrollViewDelegate, UIGestureRecognizerDelega self.backgroundView.update(size: containerFrame.size, transition: transition.containedViewLayoutTransition) transition.setFrame(view: self.vibrancyView, frame: CGRect(origin: .zero, size: containerFrame.size)) transition.setFrame(view: self.vibrancyContainer, frame: CGRect(origin: .zero, size: containerFrame.size)) - + var segmentCount = 0 - var segmentOrigin: CGFloat = 0.0 var segmentWidth: CGFloat = 0.0 if let segmentDuration { if duration > segmentDuration { @@ -1499,17 +1546,15 @@ private class TrackView: UIView, UIScrollViewDelegate, UIGestureRecognizerDelega segmentWidth = floorToScreenPixels(containerFrame.width * fraction) } if let trimRange = track.trimRange { - if trimRange.lowerBound > 0.0 { - let fraction = trimRange.lowerBound / duration - segmentOrigin = floorToScreenPixels(containerFrame.width * fraction) - } let actualSegmentCount = Int(ceil((trimRange.upperBound - trimRange.lowerBound) / segmentDuration)) - 1 segmentCount = min(actualSegmentCount, segmentCount) } } + let displaySegmentLabels = segmentWidth >= 30.0 + var validIds = Set() - var segmentFrame = CGRect(x: segmentOrigin + segmentWidth, y: 0.0, width: 1.0, height: containerFrame.size.height) + var segmentFrame = CGRect(x: segmentWidth, y: 0.0, width: 1.0, height: containerFrame.size.height) for i in 0 ..< min(segmentCount, 2) { let id = Int32(i) validIds.insert(id) @@ -1530,7 +1575,7 @@ private class TrackView: UIView, UIScrollViewDelegate, UIGestureRecognizerDelega self.segmentLayers[id] = segmentLayer self.segmentTitles[id] = segmentTitle - self.containerView.layer.addSublayer(segmentLayer) + self.segmentsContainerView.layer.addSublayer(segmentLayer) } transition.setFrame(layer: segmentLayer, frame: segmentFrame) @@ -1546,8 +1591,9 @@ private class TrackView: UIView, UIScrollViewDelegate, UIGestureRecognizerDelega containerSize: containerFrame.size ) if let view = segmentTitle.view { + view.alpha = displaySegmentLabels ? 1.0 : 0.0 if view.superview == nil { - self.containerView.addSubview(view) + self.segmentsContainerView.addSubview(view) } segmentTransition.setFrame(view: view, frame: CGRect(origin: CGPoint(x: segmentFrame.maxX + 2.0, y: 2.0), size: segmentTitleSize)) } diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoGiftsPaneNode.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoGiftsPaneNode.swift index 9dcf4c1f25..2a88b1ced1 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoGiftsPaneNode.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoGiftsPaneNode.swift @@ -571,10 +571,15 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr } } } else { + let allSubjects: [GiftViewScreen.Subject] = (self.starsProducts ?? []).map { .profileGift(self.peerId, $0) } + let index = self.starsProducts?.firstIndex(where: { $0 == product }) ?? 0 + var dismissImpl: (() -> Void)? let controller = GiftViewScreen( context: self.context, subject: .profileGift(self.peerId, product), + allSubjects: allSubjects, + index: index, updateSavedToProfile: { [weak self] reference, added in guard let self else { return diff --git a/submodules/TelegramUI/Components/Stars/StarsPurchaseScreen/Sources/StarsPurchaseScreen.swift b/submodules/TelegramUI/Components/Stars/StarsPurchaseScreen/Sources/StarsPurchaseScreen.swift index 96ebd273a3..c0aec3aef2 100644 --- a/submodules/TelegramUI/Components/Stars/StarsPurchaseScreen/Sources/StarsPurchaseScreen.swift +++ b/submodules/TelegramUI/Components/Stars/StarsPurchaseScreen/Sources/StarsPurchaseScreen.swift @@ -249,6 +249,8 @@ private final class StarsPurchaseScreenContentComponent: CombinedComponent { } else { textString = strings.Stars_Purchase_SendGroupMessageInfo(component.peers.first?.value.compactDisplayTitle ?? "").string } + case .buyStarGift: + textString = strings.Stars_Purchase_BuyStarGiftInfo } let markdownAttributes = MarkdownAttributes(body: MarkdownAttributeSet(font: textFont, textColor: textColor), bold: MarkdownAttributeSet(font: boldTextFont, textColor: textColor), link: MarkdownAttributeSet(font: textFont, textColor: accentColor), linkAttribute: { contents in @@ -830,7 +832,7 @@ private final class StarsPurchaseScreenComponent: CombinedComponent { titleText = strings.Stars_Purchase_GetStars case .gift: titleText = strings.Stars_Purchase_GiftStars - case let .topUp(requiredStars, _), let .transfer(_, requiredStars), let .reactions(_, requiredStars), let .subscription(_, requiredStars, _), let .unlockMedia(requiredStars), let .starGift(_, requiredStars), let .upgradeStarGift(requiredStars), let .transferStarGift(requiredStars), let .sendMessage(_, requiredStars): + case let .topUp(requiredStars, _), let .transfer(_, requiredStars), let .reactions(_, requiredStars), let .subscription(_, requiredStars, _), let .unlockMedia(requiredStars), let .starGift(_, requiredStars), let .upgradeStarGift(requiredStars), let .transferStarGift(requiredStars), let .sendMessage(_, requiredStars), let .buyStarGift(requiredStars): titleText = strings.Stars_Purchase_StarsNeeded(Int32(requiredStars)) } @@ -1280,6 +1282,8 @@ private extension StarsPurchasePurpose { return requiredStars case let .sendMessage(_, requiredStars): return requiredStars + case let .buyStarGift(requiredStars): + return requiredStars default: return nil } diff --git a/submodules/TelegramUI/Components/Stars/StarsWithdrawalScreen/Sources/StarsWithdrawalScreen.swift b/submodules/TelegramUI/Components/Stars/StarsWithdrawalScreen/Sources/StarsWithdrawalScreen.swift index 128e1954f2..237d089380 100644 --- a/submodules/TelegramUI/Components/Stars/StarsWithdrawalScreen/Sources/StarsWithdrawalScreen.swift +++ b/submodules/TelegramUI/Components/Stars/StarsWithdrawalScreen/Sources/StarsWithdrawalScreen.swift @@ -55,6 +55,7 @@ private final class SheetContent: CombinedComponent { let closeButton = Child(Button.self) let title = Child(Text.self) let amountSection = Child(ListSectionComponent.self) + let amountAdditionalLabel = Child(MultilineTextComponent.self) let button = Child(ButtonComponent.self) let balanceTitle = Child(MultilineTextComponent.self) let balanceValue = Child(MultilineTextComponent.self) @@ -100,7 +101,8 @@ private final class SheetContent: CombinedComponent { let titleString: String let amountTitle: String let amountPlaceholder: String - let amountLabel: String? + var amountLabel: String? + var amountRightLabel: String? let minAmount: StarsAmount? let maxAmount: StarsAmount? @@ -116,7 +118,6 @@ private final class SheetContent: CombinedComponent { minAmount = withdrawConfiguration.minWithdrawAmount.flatMap { StarsAmount(value: $0, nanos: 0) } maxAmount = status.balances.availableBalance - amountLabel = nil case .accountWithdraw: titleString = environment.strings.Stars_Withdraw_Title amountTitle = environment.strings.Stars_Withdraw_AmountTitle @@ -124,7 +125,6 @@ private final class SheetContent: CombinedComponent { minAmount = withdrawConfiguration.minWithdrawAmount.flatMap { StarsAmount(value: $0, nanos: 0) } maxAmount = state.balance - amountLabel = nil case .paidMedia: titleString = environment.strings.Stars_PaidContent_Title amountTitle = environment.strings.Stars_PaidContent_AmountTitle @@ -136,8 +136,6 @@ private final class SheetContent: CombinedComponent { if let usdWithdrawRate = withdrawConfiguration.usdWithdrawRate, let amount = state.amount, amount > StarsAmount.zero { let usdRate = Double(usdWithdrawRate) / 1000.0 / 100.0 amountLabel = "≈\(formatTonUsdValue(amount.value, divide: false, rate: usdRate, dateTimeFormat: environment.dateTimeFormat))" - } else { - amountLabel = nil } case .reaction: titleString = environment.strings.Stars_SendStars_Title @@ -146,7 +144,6 @@ private final class SheetContent: CombinedComponent { minAmount = StarsAmount(value: 1, nanos: 0) maxAmount = withdrawConfiguration.maxPaidMediaAmount.flatMap { StarsAmount(value: $0, nanos: 0) } - amountLabel = nil case let .starGiftResell(update): titleString = update ? environment.strings.Stars_SellGift_EditTitle : environment.strings.Stars_SellGift_Title amountTitle = environment.strings.Stars_SellGift_AmountTitle @@ -154,7 +151,6 @@ private final class SheetContent: CombinedComponent { minAmount = StarsAmount(value: resaleConfiguration.starGiftResaleMinAmount, nanos: 0) maxAmount = StarsAmount(value: resaleConfiguration.starGiftResaleMaxAmount, nanos: 0) - amountLabel = nil case let .paidMessages(_, minAmountValue, _, kind): //TODO:localize switch kind { @@ -168,7 +164,6 @@ private final class SheetContent: CombinedComponent { minAmount = StarsAmount(value: minAmountValue, nanos: 0) maxAmount = StarsAmount(value: resaleConfiguration.paidMessageMaxAmount, nanos: 0) - amountLabel = nil } let title = title.update( @@ -287,6 +282,11 @@ private final class SheetContent: CombinedComponent { let starsValue = Int32(floor(Float(value) * Float(resaleConfiguration.paidMessageCommissionPermille) / 1000.0)) let starsString = environment.strings.Stars_SellGift_AmountInfo_Stars(starsValue) amountInfoString = NSAttributedString(attributedString: parseMarkdownIntoAttributedString(environment.strings.Stars_SellGift_AmountInfo(starsString).string, attributes: amountMarkdownAttributes, textAlignment: .natural)) + + if let usdWithdrawRate = withdrawConfiguration.usdWithdrawRate { + let usdRate = Double(usdWithdrawRate) / 1000.0 / 100.0 + amountRightLabel = "≈\(formatTonUsdValue(Int64(starsValue), divide: false, rate: usdRate, dateTimeFormat: environment.dateTimeFormat))" + } } else { amountInfoString = NSAttributedString(attributedString: parseMarkdownIntoAttributedString(environment.strings.Stars_SellGift_AmountInfo("\(resaleConfiguration.paidMessageCommissionPermille / 10)%").string, attributes: amountMarkdownAttributes, textAlignment: .natural)) } @@ -355,8 +355,17 @@ private final class SheetContent: CombinedComponent { .cornerRadius(10.0) ) contentSize.height += amountSection.size.height + if let amountRightLabel { + let amountAdditionalLabel = amountAdditionalLabel.update( + component: MultilineTextComponent(text: .plain(NSAttributedString(string: amountRightLabel, font: amountFont, textColor: amountTextColor))), + availableSize: CGSize(width: context.availableSize.width - sideInset * 2.0, height: .greatestFiniteMagnitude), + transition: context.transition + ) + context.add(amountAdditionalLabel + .position(CGPoint(x: context.availableSize.width - amountAdditionalLabel.size.width / 2.0 - sideInset - 16.0, y: contentSize.height - amountAdditionalLabel.size.height / 2.0))) + } contentSize.height += 32.0 - + let buttonString: String if case .paidMedia = component.mode { buttonString = environment.strings.Stars_PaidContent_Create diff --git a/submodules/TelegramUI/Sources/OpenResolvedUrl.swift b/submodules/TelegramUI/Sources/OpenResolvedUrl.swift index f33043a036..25c781c4d2 100644 --- a/submodules/TelegramUI/Sources/OpenResolvedUrl.swift +++ b/submodules/TelegramUI/Sources/OpenResolvedUrl.swift @@ -802,7 +802,6 @@ func openResolvedUrlImpl( } if let currentState = starsContext.currentState, currentState.balance >= StarsAmount(value: amount, nanos: 0) { let presentationData = context.sharedContext.currentPresentationData.with { $0 } - //TODO:localize let controller = UndoOverlayController( presentationData: presentationData, content: .universal( @@ -810,8 +809,8 @@ func openResolvedUrlImpl( scale: 0.066, colors: [:], title: nil, - text: "You have enough stars at the moment.", - customUndoText: "Buy Anyway", + text: presentationData.strings.Stars_Purchase_EnoughStars, + customUndoText: presentationData.strings.Stars_Purchase_BuyAnyway, timeout: nil ), elevatedLayout: true, @@ -826,6 +825,12 @@ func openResolvedUrlImpl( proceed() } } + case .stars: + dismissInput() + let controller = context.sharedContext.makeStarsIntroScreen(context: context) + if let navigationController = navigationController { + navigationController.pushViewController(controller, animated: true) + } case let .joinVoiceChat(peerId, invite): let _ = (context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: peerId)) |> deliverOnMainQueue).start(next: { peer in diff --git a/submodules/TelegramUI/Sources/OpenUrl.swift b/submodules/TelegramUI/Sources/OpenUrl.swift index d631476b71..c440cb496f 100644 --- a/submodules/TelegramUI/Sources/OpenUrl.swift +++ b/submodules/TelegramUI/Sources/OpenUrl.swift @@ -1016,7 +1016,9 @@ func openExternalUrlImpl(context: AccountContext, urlContext: OpenURLContext, ur } } } else { - if parsedUrl.host == "importStickers" { + if parsedUrl.host == "stars" { + handleResolvedUrl(.stars) + } else if parsedUrl.host == "importStickers" { handleResolvedUrl(.importStickers) } else if parsedUrl.host == "settings" { if let path = parsedUrl.pathComponents.last { From c9c4d78e30df9a330995cda5c51bd13c936fbae4 Mon Sep 17 00:00:00 2001 From: Ilya Laktyushin Date: Fri, 2 May 2025 00:45:42 +0400 Subject: [PATCH 08/29] Various fixes --- .../Sources/Utils/PeerUtils.swift | 23 ++++++- .../Sources/QuickShareScreen.swift | 44 ++++++++++---- .../Sources/GiftViewScreen.swift | 60 +++++++++++-------- .../TelegramUI/Sources/OpenResolvedUrl.swift | 9 ++- 4 files changed, 96 insertions(+), 40 deletions(-) diff --git a/submodules/TelegramCore/Sources/Utils/PeerUtils.swift b/submodules/TelegramCore/Sources/Utils/PeerUtils.swift index e967e561ae..a2a3cc0197 100644 --- a/submodules/TelegramCore/Sources/Utils/PeerUtils.swift +++ b/submodules/TelegramCore/Sources/Utils/PeerUtils.swift @@ -142,7 +142,7 @@ public extension Peer { var largeProfileImage: TelegramMediaImageRepresentation? { return largestImageRepresentation(self.profileImageRepresentations) } - + var isDeleted: Bool { switch self { case let user as TelegramUser: @@ -152,6 +152,27 @@ public extension Peer { } } + var isGenericUser: Bool { + switch self { + case let user as TelegramUser: + if user.isDeleted { + return false + } + if user.botInfo != nil { + return false + } + if user.id.isRepliesOrVerificationCodes { + return false + } + if user.id.isTelegramNotifications { + return false + } + return true + default: + return false + } + } + var isScam: Bool { switch self { case let user as TelegramUser: diff --git a/submodules/TelegramUI/Components/Chat/QuickShareScreen/Sources/QuickShareScreen.swift b/submodules/TelegramUI/Components/Chat/QuickShareScreen/Sources/QuickShareScreen.swift index 02024baeb3..d9043f109f 100644 --- a/submodules/TelegramUI/Components/Chat/QuickShareScreen/Sources/QuickShareScreen.swift +++ b/submodules/TelegramUI/Components/Chat/QuickShareScreen/Sources/QuickShareScreen.swift @@ -4,6 +4,7 @@ import AsyncDisplayKit import Display import ComponentFlow import SwiftSignalKit +import Postbox import TelegramCore import TextFormat import TelegramPresentationData @@ -211,27 +212,46 @@ private final class QuickShareScreenComponent: Component { self.state = state if self.component == nil { + let peers = component.context.engine.peers.recentPeers() + |> take(1) + |> mapToSignal { recentPeers -> Signal<[EnginePeer], NoError> in + if case let .peers(peers) = recentPeers, !peers.isEmpty { + return .single(peers.map(EnginePeer.init)) + } else { + return component.context.account.stateManager.postbox.tailChatListView( + groupId: .root, + count: 20, + summaryComponents: ChatListEntrySummaryComponents() + ) + |> take(1) + |> map { view -> [EnginePeer] in + var peers: [EnginePeer] = [] + for entry in view.0.entries.reversed() { + if case let .MessageEntry(entryData) = entry { + if let user = entryData.renderedPeer.chatMainPeer as? TelegramUser, user.isGenericUser && user.id != component.context.account.peerId && !user.id.isSecretChat { + peers.append(EnginePeer(user)) + } + } + } + return peers + } + } + } + self.disposable = combineLatest(queue: Queue.mainQueue(), - component.context.engine.peers.recentPeers() |> take(1), + peers, component.context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: component.context.account.peerId)) - ).start(next: { [weak self] recentPeers, accountPeer in + ).start(next: { [weak self] peers, accountPeer in guard let self else { return } - var result: [EnginePeer] = [] - switch recentPeers { - case let .peers(peers): - result = peers.map(EnginePeer.init) - case .disabled: - break - } - if !result.isEmpty, let accountPeer { - self.peers = Array([accountPeer] + result.prefix(4)) + if !peers.isEmpty, let accountPeer { + self.peers = Array([accountPeer] + peers.prefix(4)) self.state?.updated() + component.ready.set(.single(true)) } else { self.environment?.controller()?.dismiss() } - component.ready.set(.single(true)) }) component.gesture.externalUpdated = { [weak self] view, point in diff --git a/submodules/TelegramUI/Components/Gifts/GiftViewScreen/Sources/GiftViewScreen.swift b/submodules/TelegramUI/Components/Gifts/GiftViewScreen/Sources/GiftViewScreen.swift index 924c0aa70a..b1ed83e615 100644 --- a/submodules/TelegramUI/Components/Gifts/GiftViewScreen/Sources/GiftViewScreen.swift +++ b/submodules/TelegramUI/Components/Gifts/GiftViewScreen/Sources/GiftViewScreen.swift @@ -2462,13 +2462,23 @@ private final class GiftViewSheetContent: CombinedComponent { if let uniqueGift { if isMyUniqueGift, case let .peerId(peerId) = uniqueGift.owner { var canTransfer = true - if let peer = state.peerMap[peerId], case let .channel(channel) = peer, !channel.flags.contains(.isCreator) { - canTransfer = false + var canResell = true + if let peer = state.peerMap[peerId], case let .channel(channel) = peer { + if !channel.flags.contains(.isCreator) { + canTransfer = false + } + canResell = false } else if subject.arguments?.transferStars == nil { canTransfer = false } - let buttonsCount = canTransfer ? 3 : 2 + var buttonsCount = 1 + if canTransfer { + buttonsCount += 1 + } + if canResell { + buttonsCount += 1 + } let buttonSpacing: CGFloat = 10.0 let buttonWidth = floor(context.availableSize.width - sideInset * 2.0 - buttonSpacing * CGFloat(buttonsCount - 1)) / CGFloat(buttonsCount) @@ -2560,28 +2570,30 @@ private final class GiftViewSheetContent: CombinedComponent { ) buttonOriginX += buttonWidth + buttonSpacing - let resellButton = resellButton.update( - component: PlainButtonComponent( - content: AnyComponent( - HeaderButtonComponent( - title: uniqueGift.resellStars == nil ? strings.Gift_View_Sell : strings.Gift_View_Unlist, - iconName: uniqueGift.resellStars == nil ? "Premium/Collectible/Sell" : "Premium/Collectible/Unlist" - ) + if canResell { + let resellButton = resellButton.update( + component: PlainButtonComponent( + content: AnyComponent( + HeaderButtonComponent( + title: uniqueGift.resellStars == nil ? strings.Gift_View_Sell : strings.Gift_View_Unlist, + iconName: uniqueGift.resellStars == nil ? "Premium/Collectible/Sell" : "Premium/Collectible/Unlist" + ) + ), + effectAlignment: .center, + action: { [weak state] in + state?.resellGift() + } ), - effectAlignment: .center, - action: { [weak state] in - state?.resellGift() - } - ), - environment: {}, - availableSize: CGSize(width: buttonWidth, height: buttonHeight), - transition: context.transition - ) - context.add(resellButton - .position(CGPoint(x: buttonOriginX + buttonWidth / 2.0, y: headerHeight - buttonHeight / 2.0 - 16.0)) - .appear(.default(scale: true, alpha: true)) - .disappear(.default(scale: true, alpha: true)) - ) + environment: {}, + availableSize: CGSize(width: buttonWidth, height: buttonHeight), + transition: context.transition + ) + context.add(resellButton + .position(CGPoint(x: buttonOriginX + buttonWidth / 2.0, y: headerHeight - buttonHeight / 2.0 - 16.0)) + .appear(.default(scale: true, alpha: true)) + .disappear(.default(scale: true, alpha: true)) + ) + } } let order: [StarGift.UniqueGift.Attribute.AttributeType] = [ diff --git a/submodules/TelegramUI/Sources/OpenResolvedUrl.swift b/submodules/TelegramUI/Sources/OpenResolvedUrl.swift index 25c781c4d2..c2a4eaa898 100644 --- a/submodules/TelegramUI/Sources/OpenResolvedUrl.swift +++ b/submodules/TelegramUI/Sources/OpenResolvedUrl.swift @@ -827,9 +827,12 @@ func openResolvedUrlImpl( } case .stars: dismissInput() - let controller = context.sharedContext.makeStarsIntroScreen(context: context) - if let navigationController = navigationController { - navigationController.pushViewController(controller, animated: true) + if let starsContext = context.starsContext { + let controller = context.sharedContext.makeStarsTransactionsScreen(context: context, starsContext: starsContext) + controller.navigationPresentation = .modal + if let navigationController { + navigationController.pushViewController(controller, animated: true) + } } case let .joinVoiceChat(peerId, invite): let _ = (context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: peerId)) From 751e20043fb089e67c435fe2153bc34d5a6c17a2 Mon Sep 17 00:00:00 2001 From: Ilya Laktyushin Date: Fri, 2 May 2025 00:51:14 +0400 Subject: [PATCH 09/29] Various fixes --- .../Sources/StarsTransactionsScreen.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/submodules/TelegramUI/Components/Stars/StarsTransactionsScreen/Sources/StarsTransactionsScreen.swift b/submodules/TelegramUI/Components/Stars/StarsTransactionsScreen/Sources/StarsTransactionsScreen.swift index d3656812ce..c5f800167d 100644 --- a/submodules/TelegramUI/Components/Stars/StarsTransactionsScreen/Sources/StarsTransactionsScreen.swift +++ b/submodules/TelegramUI/Components/Stars/StarsTransactionsScreen/Sources/StarsTransactionsScreen.swift @@ -447,7 +447,11 @@ final class StarsTransactionsScreenComponent: Component { let sideInsets: CGFloat = environment.safeInsets.left + environment.safeInsets.right + 16.0 * 2.0 let bottomInset: CGFloat = environment.safeInsets.bottom - contentHeight += environment.statusBarHeight + if environment.statusBarHeight > 0.0 { + contentHeight += environment.statusBarHeight + } else { + contentHeight += 12.0 + } let starTransition: ComponentTransition = .immediate From da164c2332eecf2fd270745b50447e726901eee6 Mon Sep 17 00:00:00 2001 From: Ilya Laktyushin Date: Fri, 2 May 2025 17:03:23 +0400 Subject: [PATCH 10/29] Various fixes --- .../GalleryUI/Sources/GalleryController.swift | 2 +- .../ChatMessageGiftBubbleContentNode.swift | 4 +-- .../ChatMessagePaymentAlertController.swift | 11 +++++++ ...hatMessageWallpaperBubbleContentNode.swift | 6 ++-- .../Sources/PeerInfoScreen.swift | 8 ++--- .../Chat/ChatControllerLoadDisplayNode.swift | 33 ++++++++++--------- .../Sources/ChatHistoryListNode.swift | 2 +- .../ChatPremiumRequiredInputPanelNode.swift | 4 +-- .../TranslateUI/Sources/ChatTranslation.swift | 25 +++++++++----- 9 files changed, 58 insertions(+), 37 deletions(-) diff --git a/submodules/GalleryUI/Sources/GalleryController.swift b/submodules/GalleryUI/Sources/GalleryController.swift index 5072eab702..aec776a3b8 100644 --- a/submodules/GalleryUI/Sources/GalleryController.swift +++ b/submodules/GalleryUI/Sources/GalleryController.swift @@ -713,7 +713,7 @@ public class GalleryController: ViewController, StandalonePresentableController, return .single(message.flatMap { ($0, false) }) } } - translateToLanguage = chatTranslationState(context: context, peerId: messageId.peerId) + translateToLanguage = chatTranslationState(context: context, peerId: messageId.peerId, threadId: threadIdValue) |> map { translationState in if let translationState, translationState.isEnabled { let translateToLanguage = translationState.toLang ?? baseLanguageCode diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageGiftBubbleContentNode/Sources/ChatMessageGiftBubbleContentNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageGiftBubbleContentNode/Sources/ChatMessageGiftBubbleContentNode.swift index 75c0a68835..3c0945ed07 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageGiftBubbleContentNode/Sources/ChatMessageGiftBubbleContentNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageGiftBubbleContentNode/Sources/ChatMessageGiftBubbleContentNode.swift @@ -780,7 +780,7 @@ public class ChatMessageGiftBubbleContentNode: ChatMessageBubbleContentNode { let overlayColor = item.presentationData.theme.theme.overallDarkAppearance && uniquePatternFile == nil ? UIColor(rgb: 0xffffff, alpha: 0.12) : UIColor(rgb: 0x000000, alpha: 0.12) - let imageFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((backgroundSize.width - giftSize.width) / 2.0), y: hasServiceMessage ? labelLayout.size.height + 12.0 : 0.0), size: giftSize) + let imageFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((boundingWidth - giftSize.width) / 2.0), y: hasServiceMessage ? labelLayout.size.height + 12.0 : 0.0), size: giftSize) let mediaBackgroundFrame = imageFrame.insetBy(dx: -2.0, dy: -2.0) var iconSize = CGSize(width: 160.0, height: 160.0) @@ -852,7 +852,7 @@ public class ChatMessageGiftBubbleContentNode: ChatMessageBubbleContentNode { let _ = ribbonTextApply() let _ = moreApply() - let labelFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((backgroundSize.width - labelLayout.size.width) / 2.0), y: 2.0), size: labelLayout.size) + let labelFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((boundingWidth - labelLayout.size.width) / 2.0), y: 2.0), size: labelLayout.size) strongSelf.labelNode.frame = labelFrame let titleFrame = CGRect(origin: CGPoint(x: mediaBackgroundFrame.minX + floorToScreenPixels((mediaBackgroundFrame.width - titleLayout.size.width) / 2.0) , y: mediaBackgroundFrame.minY + 151.0), size: titleLayout.size) diff --git a/submodules/TelegramUI/Components/Chat/ChatMessagePaymentAlertController/Sources/ChatMessagePaymentAlertController.swift b/submodules/TelegramUI/Components/Chat/ChatMessagePaymentAlertController/Sources/ChatMessagePaymentAlertController.swift index 75270b57dc..d78bb08410 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessagePaymentAlertController/Sources/ChatMessagePaymentAlertController.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessagePaymentAlertController/Sources/ChatMessagePaymentAlertController.swift @@ -325,6 +325,8 @@ public class ChatMessagePaymentAlertController: AlertController { private let balance = ComponentView() + private var didAppear = false + public init(context: AccountContext?, presentationData: PresentationData, contentNode: AlertContentNode, navigationController: NavigationController?, showBalance: Bool = true) { self.context = context self.presentationData = presentationData @@ -361,6 +363,15 @@ public class ChatMessagePaymentAlertController: AlertController { public override func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { super.containerLayoutUpdated(layout, transition: transition) + if !self.didAppear { + self.didAppear = true + if !layout.metrics.isTablet && layout.size.width > layout.size.height { + Queue.mainQueue().after(0.1) { + self.view.window?.endEditing(true) + } + } + } + if let context = self.context, let _ = self.parentNavigationController, self.showBalance { let insets = layout.insets(options: .statusBar) let balanceSize = self.balance.update( diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageWallpaperBubbleContentNode/Sources/ChatMessageWallpaperBubbleContentNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageWallpaperBubbleContentNode/Sources/ChatMessageWallpaperBubbleContentNode.swift index d3280e0622..290a9c7f39 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageWallpaperBubbleContentNode/Sources/ChatMessageWallpaperBubbleContentNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageWallpaperBubbleContentNode/Sources/ChatMessageWallpaperBubbleContentNode.swift @@ -334,8 +334,8 @@ public class ChatMessageWallpaperBubbleContentNode: ChatMessageBubbleContentNode strongSelf.buttonNode.isHidden = fromYou || isGroupOrChannel strongSelf.buttonTitleNode.isHidden = fromYou || isGroupOrChannel - let imageFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((backgroundSize.width - imageSize.width) / 2.0), y: 13.0), size: imageSize) - if let media, mediaUpdated { + let imageFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((boundingWidth - imageSize.width) / 2.0), y: 13.0), size: imageSize) + if let media, mediaUpdated { let boundingSize = imageSize var imageSize = boundingSize let updateImageSignal: Signal<(TransformImageArguments) -> DrawingContext?, NoError> @@ -440,7 +440,7 @@ public class ChatMessageWallpaperBubbleContentNode: ChatMessageBubbleContentNode } } - let mediaBackgroundFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((backgroundSize.width - width) / 2.0), y: 0.0), size: backgroundSize) + let mediaBackgroundFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((boundingWidth - width) / 2.0), y: 0.0), size: backgroundSize) 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) diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift index 15b2105611..3ef862c66c 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift @@ -4978,7 +4978,7 @@ final class PeerInfoScreenNode: ViewControllerTracingNode, PeerInfoScreenNodePro self.refreshMessageTagStatsDisposable = context.engine.messages.refreshMessageTagStats(peerId: peerId, threadId: chatLocation.threadId, tags: [.video, .photo, .gif, .music, .voiceOrInstantVideo, .webPage, .file]).startStrict() if peerId.namespace == Namespaces.Peer.CloudChannel { - self.translationStateDisposable = (chatTranslationState(context: context, peerId: peerId) + self.translationStateDisposable = (chatTranslationState(context: context, peerId: peerId, threadId: nil) |> deliverOnMainQueue).startStrict(next: { [weak self] translationState in self?.translationState = translationState }) @@ -6507,7 +6507,7 @@ final class PeerInfoScreenNode: ViewControllerTracingNode, PeerInfoScreenNodePro f(.dismissWithoutContent) if let strongSelf = self { - let _ = updateChatTranslationStateInteractively(engine: strongSelf.context.engine, peerId: strongSelf.peerId, { current in + let _ = updateChatTranslationStateInteractively(engine: strongSelf.context.engine, peerId: strongSelf.peerId, threadId: nil, { current in return current?.withIsEnabled(true) }).startStandalone() @@ -6698,7 +6698,7 @@ final class PeerInfoScreenNode: ViewControllerTracingNode, PeerInfoScreenNodePro f(.dismissWithoutContent) if let strongSelf = self { - let _ = updateChatTranslationStateInteractively(engine: strongSelf.context.engine, peerId: strongSelf.peerId, { current in + let _ = updateChatTranslationStateInteractively(engine: strongSelf.context.engine, peerId: strongSelf.peerId, threadId: nil, { current in return current?.withIsEnabled(true) }).startStandalone() @@ -6957,7 +6957,7 @@ final class PeerInfoScreenNode: ViewControllerTracingNode, PeerInfoScreenNodePro f(.dismissWithoutContent) if let strongSelf = self { - let _ = updateChatTranslationStateInteractively(engine: strongSelf.context.engine, peerId: strongSelf.peerId, { current in + let _ = updateChatTranslationStateInteractively(engine: strongSelf.context.engine, peerId: strongSelf.peerId, threadId: nil, { current in return current?.withIsEnabled(true) }).startStandalone() diff --git a/submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift b/submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift index e4795c62ec..3ca76e4c6f 100644 --- a/submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift +++ b/submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift @@ -681,6 +681,7 @@ extension ChatControllerImpl { let hasAutoTranslate = self.context.engine.data.subscribe(TelegramEngine.EngineData.Item.Peer.AutoTranslateEnabled(id: peerId)) |> distinctUntilChanged + let chatLocation = self.chatLocation self.translationStateDisposable = (combineLatest( queue: .concurrentDefaultQueue(), isPremium, @@ -693,7 +694,7 @@ extension ChatControllerImpl { maybeSuggestPremium = true } if (isPremium || maybeSuggestPremium || hasAutoTranslate) && !isHidden { - return chatTranslationState(context: context, peerId: peerId) + return chatTranslationState(context: context, peerId: peerId, threadId: chatLocation.threadId) |> map { translationState -> ChatPresentationTranslationState? in if let translationState, !translationState.fromLang.isEmpty && (translationState.fromLang != baseLanguageCode || translationState.isEnabled) { return ChatPresentationTranslationState(isEnabled: translationState.isEnabled, fromLang: translationState.fromLang, toLang: translationState.toLang ?? baseLanguageCode) @@ -4357,32 +4358,32 @@ extension ChatControllerImpl { } let _ = strongSelf.context.engine.peers.setForumChannelTopicClosed(id: peerId, threadId: threadId, isClosed: false).startStandalone() }, toggleTranslation: { [weak self] type in - guard let strongSelf = self, let peerId = strongSelf.chatLocation.peerId else { + guard let self, let peerId = self.chatLocation.peerId else { return } - let _ = (updateChatTranslationStateInteractively(engine: strongSelf.context.engine, peerId: peerId, { current in + let _ = (updateChatTranslationStateInteractively(engine: self.context.engine, peerId: peerId, threadId: self.chatLocation.threadId, { current in return current?.withIsEnabled(type == .translated) }) |> deliverOnMainQueue).startStandalone(completed: { [weak self] in - if let strongSelf = self, type == .translated { + if let self, type == .translated { Queue.mainQueue().after(0.15) { - strongSelf.chatDisplayNode.historyNode.refreshPollActionsForVisibleMessages() + self.chatDisplayNode.historyNode.refreshPollActionsForVisibleMessages() } } }) }, changeTranslationLanguage: { [weak self] langCode in - guard let strongSelf = self, let peerId = strongSelf.chatLocation.peerId else { + guard let self, let peerId = self.chatLocation.peerId else { return } let langCode = normalizeTranslationLanguage(langCode) - let _ = updateChatTranslationStateInteractively(engine: strongSelf.context.engine, peerId: peerId, { current in + let _ = updateChatTranslationStateInteractively(engine: self.context.engine, peerId: peerId, threadId: self.chatLocation.threadId, { current in return current?.withToLang(langCode).withIsEnabled(true) }).startStandalone() }, addDoNotTranslateLanguage: { [weak self] langCode in - guard let strongSelf = self, let peerId = strongSelf.chatLocation.peerId else { + guard let self, let peerId = self.chatLocation.peerId else { return } - let _ = updateTranslationSettingsInteractively(accountManager: strongSelf.context.sharedContext.accountManager, { current in + let _ = updateTranslationSettingsInteractively(accountManager: self.context.sharedContext.accountManager, { current in var updated = current if var ignoredLanguages = updated.ignoredLanguages { if !ignoredLanguages.contains(langCode) { @@ -4391,7 +4392,7 @@ extension ChatControllerImpl { updated.ignoredLanguages = ignoredLanguages } else { var ignoredLanguages = Set() - ignoredLanguages.insert(strongSelf.presentationData.strings.baseLanguageCode) + ignoredLanguages.insert(self.presentationData.strings.baseLanguageCode) for language in systemLanguageCodes() { ignoredLanguages.insert(language) } @@ -4400,11 +4401,11 @@ extension ChatControllerImpl { } return updated }).startStandalone() - let _ = updateChatTranslationStateInteractively(engine: strongSelf.context.engine, peerId: peerId, { current in + let _ = updateChatTranslationStateInteractively(engine: self.context.engine, peerId: peerId, threadId: self.chatLocation.threadId, { current in return nil }).startStandalone() - let presentationData = strongSelf.context.sharedContext.currentPresentationData.with { $0 } + let presentationData = self.context.sharedContext.currentPresentationData.with { $0 } var languageCode = presentationData.strings.baseLanguageCode let rawSuffix = "-raw" if languageCode.hasSuffix(rawSuffix) { @@ -4413,11 +4414,11 @@ extension ChatControllerImpl { let locale = Locale(identifier: languageCode) let fromLanguage: String = locale.localizedString(forLanguageCode: langCode) ?? "" - strongSelf.present(UndoOverlayController(presentationData: presentationData, content: .image(image: generateTintedImage(image: UIImage(bundleImageName: "Chat/Title Panels/Translate"), color: .white)!, title: nil, text: presentationData.strings.Conversation_Translation_AddedToDoNotTranslateText(fromLanguage).string, round: false, undoText: presentationData.strings.Conversation_Translation_Settings), elevatedLayout: false, animateInAsReplacement: false, action: { [weak self] action in - if case .undo = action, let strongSelf = self { - let controller = translationSettingsController(context: strongSelf.context) + self.present(UndoOverlayController(presentationData: presentationData, content: .image(image: generateTintedImage(image: UIImage(bundleImageName: "Chat/Title Panels/Translate"), color: .white)!, title: nil, text: presentationData.strings.Conversation_Translation_AddedToDoNotTranslateText(fromLanguage).string, round: false, undoText: presentationData.strings.Conversation_Translation_Settings), elevatedLayout: false, animateInAsReplacement: false, action: { [weak self] action in + if case .undo = action, let self { + let controller = translationSettingsController(context: self.context) controller.navigationPresentation = .modal - strongSelf.push(controller) + self.push(controller) } return true }), in: .current) diff --git a/submodules/TelegramUI/Sources/ChatHistoryListNode.swift b/submodules/TelegramUI/Sources/ChatHistoryListNode.swift index 723f3a53c3..57522caf86 100644 --- a/submodules/TelegramUI/Sources/ChatHistoryListNode.swift +++ b/submodules/TelegramUI/Sources/ChatHistoryListNode.swift @@ -1655,7 +1655,7 @@ public final class ChatHistoryListNodeImpl: ListView, ChatHistoryNode, ChatHisto let translationState: Signal if let peerId = chatLocation.peerId, peerId.namespace != Namespaces.Peer.SecretChat && peerId != context.account.peerId && subject != .scheduledMessages { - translationState = chatTranslationState(context: context, peerId: peerId) + translationState = chatTranslationState(context: context, peerId: peerId, threadId: self.chatLocation.threadId) } else { translationState = .single(nil) } diff --git a/submodules/TelegramUI/Sources/ChatPremiumRequiredInputPanelNode.swift b/submodules/TelegramUI/Sources/ChatPremiumRequiredInputPanelNode.swift index 0add3d1da0..51571afe63 100644 --- a/submodules/TelegramUI/Sources/ChatPremiumRequiredInputPanelNode.swift +++ b/submodules/TelegramUI/Sources/ChatPremiumRequiredInputPanelNode.swift @@ -115,7 +115,7 @@ final class ChatPremiumRequiredInputPanelNode: ChatInputPanelNode { } } - let size = CGSize(width: params.width - params.additionalSideInsets.left * 2.0 - params.leftInset * 2.0, height: height) + let size = CGSize(width: params.width - params.additionalSideInsets.left * 2.0 - params.leftInset * 2.0 - 32.0, height: height) let buttonSize = self.button.update( transition: .immediate, component: AnyComponent(PlainButtonComponent( @@ -136,7 +136,7 @@ final class ChatPremiumRequiredInputPanelNode: ChatInputPanelNode { if buttonView.superview == nil { self.view.addSubview(buttonView) } - transition.setFrame(view: buttonView, frame: CGRect(origin: CGPoint(), size: buttonSize)) + transition.setFrame(view: buttonView, frame: CGRect(origin: CGPoint(x: floorToScreenPixels((params.width - buttonSize.width) / 2.0), y: 0.0), size: buttonSize)) } return height diff --git a/submodules/TranslateUI/Sources/ChatTranslation.swift b/submodules/TranslateUI/Sources/ChatTranslation.swift index 74ea83d24c..8c03916535 100644 --- a/submodules/TranslateUI/Sources/ChatTranslation.swift +++ b/submodules/TranslateUI/Sources/ChatTranslation.swift @@ -75,9 +75,12 @@ public struct ChatTranslationState: Codable { } } -private func cachedChatTranslationState(engine: TelegramEngine, peerId: EnginePeer.Id) -> Signal { +private func cachedChatTranslationState(engine: TelegramEngine, peerId: EnginePeer.Id, threadId: Int64?) -> Signal { let key = EngineDataBuffer(length: 8) key.setInt64(0, value: peerId.id._internalGetInt64Value()) + if let threadId { + key.setInt64(8, value: threadId) + } return engine.data.subscribe(TelegramEngine.EngineData.Item.ItemCache.Item(collectionId: ApplicationSpecificItemCacheCollectionId.translationState, id: key)) |> map { entry -> ChatTranslationState? in @@ -85,9 +88,12 @@ private func cachedChatTranslationState(engine: TelegramEngine, peerId: EnginePe } } -private func updateChatTranslationState(engine: TelegramEngine, peerId: EnginePeer.Id, state: ChatTranslationState?) -> Signal { +private func updateChatTranslationState(engine: TelegramEngine, peerId: EnginePeer.Id, threadId: Int64?, state: ChatTranslationState?) -> Signal { let key = EngineDataBuffer(length: 8) key.setInt64(0, value: peerId.id._internalGetInt64Value()) + if let threadId { + key.setInt64(8, value: threadId) + } if let state { return engine.itemCache.put(collectionId: ApplicationSpecificItemCacheCollectionId.translationState, id: key, item: state) @@ -96,9 +102,12 @@ private func updateChatTranslationState(engine: TelegramEngine, peerId: EnginePe } } -public func updateChatTranslationStateInteractively(engine: TelegramEngine, peerId: EnginePeer.Id, _ f: @escaping (ChatTranslationState?) -> ChatTranslationState?) -> Signal { +public func updateChatTranslationStateInteractively(engine: TelegramEngine, peerId: EnginePeer.Id, threadId: Int64?, _ f: @escaping (ChatTranslationState?) -> ChatTranslationState?) -> Signal { let key = EngineDataBuffer(length: 8) key.setInt64(0, value: peerId.id._internalGetInt64Value()) + if let threadId { + key.setInt64(8, value: threadId) + } return engine.data.get(TelegramEngine.EngineData.Item.ItemCache.Item(collectionId: ApplicationSpecificItemCacheCollectionId.translationState, id: key)) |> map { entry -> ChatTranslationState? in @@ -106,7 +115,7 @@ public func updateChatTranslationStateInteractively(engine: TelegramEngine, peer } |> mapToSignal { current -> Signal in if let current { - return updateChatTranslationState(engine: engine, peerId: peerId, state: f(current)) + return updateChatTranslationState(engine: engine, peerId: peerId, threadId: threadId, state: f(current)) } else { return .never() } @@ -166,7 +175,7 @@ public func translateMessageIds(context: AccountContext, messageIds: [EngineMess } |> switchToLatest } -public func chatTranslationState(context: AccountContext, peerId: EnginePeer.Id) -> Signal { +public func chatTranslationState(context: AccountContext, peerId: EnginePeer.Id, threadId: Int64?) -> Signal { if peerId.id == EnginePeer.Id.Id._internalFromInt64Value(777000) { return .single(nil) } @@ -202,7 +211,7 @@ public func chatTranslationState(context: AccountContext, peerId: EnginePeer.Id) } } - return cachedChatTranslationState(engine: context.engine, peerId: peerId) + return cachedChatTranslationState(engine: context.engine, peerId: peerId, threadId: threadId) |> mapToSignal { cached in let currentTime = Int32(CFAbsoluteTimeGetCurrent() + kCFAbsoluteTimeIntervalSince1970) if let cached, let timestamp = cached.timestamp, cached.baseLang == baseLang && currentTime - timestamp < 60 * 60 { @@ -214,7 +223,7 @@ public func chatTranslationState(context: AccountContext, peerId: EnginePeer.Id) } else { return .single(nil) |> then( - context.account.viewTracker.aroundMessageHistoryViewForLocation(.peer(peerId: peerId, threadId: nil), index: .upperBound, anchorIndex: .upperBound, count: 32, fixedCombinedReadStates: nil) + context.account.viewTracker.aroundMessageHistoryViewForLocation(.peer(peerId: peerId, threadId: threadId), index: .upperBound, anchorIndex: .upperBound, count: 32, fixedCombinedReadStates: nil) |> filter { messageHistoryView -> Bool in return messageHistoryView.0.entries.count > 1 } @@ -308,7 +317,7 @@ public func chatTranslationState(context: AccountContext, peerId: EnginePeer.Id) toLang: cached?.toLang, isEnabled: isEnabled ) - let _ = updateChatTranslationState(engine: context.engine, peerId: peerId, state: state).start() + let _ = updateChatTranslationState(engine: context.engine, peerId: peerId, threadId: threadId, state: state).start() if !dontTranslateLanguages.contains(fromLang) { return state } else { From 6334e867219d2ae90bcd0607dea2e44012c46df8 Mon Sep 17 00:00:00 2001 From: Ilya Laktyushin Date: Fri, 2 May 2025 17:18:11 +0400 Subject: [PATCH 11/29] Bump version --- versions.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/versions.json b/versions.json index 180c7987ae..040d815c36 100644 --- a/versions.json +++ b/versions.json @@ -1,5 +1,5 @@ { - "app": "11.10", + "app": "11.11", "xcode": "16.2", "bazel": "7.3.1:981f82a470bad1349322b6f51c9c6ffa0aa291dab1014fac411543c12e661dff", "macos": "15" From fe2cc49a55c7b44e0d618f0e570296f15834ee86 Mon Sep 17 00:00:00 2001 From: Ilya Laktyushin Date: Fri, 2 May 2025 18:31:42 +0400 Subject: [PATCH 12/29] Various fixes --- .../Telegram-iOS/en.lproj/Localizable.strings | 1 + .../GiftAttributeListContextItem.swift | 11 ++++++- .../Sources/GiftStoreScreen.swift | 29 ++++++++++++++----- .../Sources/StarsPurchaseScreen.swift | 4 ++- 4 files changed, 36 insertions(+), 9 deletions(-) diff --git a/Telegram/Telegram-iOS/en.lproj/Localizable.strings b/Telegram/Telegram-iOS/en.lproj/Localizable.strings index d578975f89..c7ef0e4901 100644 --- a/Telegram/Telegram-iOS/en.lproj/Localizable.strings +++ b/Telegram/Telegram-iOS/en.lproj/Localizable.strings @@ -14262,6 +14262,7 @@ Sorry for the inconvenience."; "PeerInfo.Gifts.Sale" = "sale"; +"Gift.Store.ForResaleNoResults" = "no results"; "Gift.Store.ForResale_1" = "%@ for resale"; "Gift.Store.ForResale_any" = "%@ for resale"; "Gift.Store.Sort.Price" = "Price"; diff --git a/submodules/TelegramUI/Components/Gifts/GiftStoreScreen/Sources/GiftAttributeListContextItem.swift b/submodules/TelegramUI/Components/Gifts/GiftStoreScreen/Sources/GiftAttributeListContextItem.swift index bbb4658b85..58f23df6bf 100644 --- a/submodules/TelegramUI/Components/Gifts/GiftStoreScreen/Sources/GiftAttributeListContextItem.swift +++ b/submodules/TelegramUI/Components/Gifts/GiftStoreScreen/Sources/GiftAttributeListContextItem.swift @@ -71,7 +71,15 @@ private func actionForAttribute(attribute: StarGift.UniqueGift.Attribute, presen var title = "# \(name)" var count = "" - if let counter = item.attributeCount[.model(file.fileId.id)] { + var attributeId: ResaleGiftsContext.Attribute? + switch attribute { + case .model: + attributeId = .model(file.fileId.id) + case .pattern: + attributeId = .pattenr(file.fileId.id) + } + + if let attributeId, let counter = item.attributeCount[attributeId] { count = " \(presentationStringsFormattedNumber(counter, presentationData.dateTimeFormat.groupingSeparator))" entities.append( MessageTextEntity( @@ -81,6 +89,7 @@ private func actionForAttribute(attribute: StarGift.UniqueGift.Attribute, presen ) title += count } + let words = title.components(separatedBy: .whitespacesAndNewlines).filter { !$0.isEmpty } var wordStartIndices: [String.Index] = [] diff --git a/submodules/TelegramUI/Components/Gifts/GiftStoreScreen/Sources/GiftStoreScreen.swift b/submodules/TelegramUI/Components/Gifts/GiftStoreScreen/Sources/GiftStoreScreen.swift index 835092e294..99f6f05b84 100644 --- a/submodules/TelegramUI/Components/Gifts/GiftStoreScreen/Sources/GiftStoreScreen.swift +++ b/submodules/TelegramUI/Components/Gifts/GiftStoreScreen/Sources/GiftStoreScreen.swift @@ -454,22 +454,31 @@ final class GiftStoreScreenComponent: Component { return generateTintedImage(image: UIImage(bundleImageName: "Peer Info/SortValue"), color: theme.contextMenu.primaryColor) }, action: { [weak self] _, f in f(.default) - - self?.state?.starGiftsContext.updateSorting(.value) + guard let self else { + return + } + self.state?.starGiftsContext.updateSorting(.value) + self.scrollToTop() }))) items.append(.action(ContextMenuActionItem(text: presentationData.strings.Gift_Store_SortByDate, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Peer Info/SortDate"), color: theme.contextMenu.primaryColor) }, action: { [weak self] _, f in f(.default) - - self?.state?.starGiftsContext.updateSorting(.date) + guard let self else { + return + } + self.state?.starGiftsContext.updateSorting(.date) + self.scrollToTop() }))) items.append(.action(ContextMenuActionItem(text: presentationData.strings.Gift_Store_SortByNumber, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Peer Info/SortNumber"), color: theme.contextMenu.primaryColor) }, action: { [weak self] _, f in f(.default) - - self?.state?.starGiftsContext.updateSorting(.number) + guard let self else { + return + } + self.state?.starGiftsContext.updateSorting(.number) + self.scrollToTop() }))) let contextController = ContextController(presentationData: presentationData, source: .reference(GiftStoreReferenceContentSource(controller: controller, sourceView: sourceView)), items: .single(ContextController.Items(content: .list(items))), gesture: nil) @@ -542,6 +551,7 @@ final class GiftStoreScreenComponent: Component { } } self.state?.starGiftsContext.updateFilterAttributes(updatedFilterAttributes) + self.scrollToTop() }, selectAll: { [weak self] in guard let self else { @@ -554,6 +564,7 @@ final class GiftStoreScreenComponent: Component { return true } self.state?.starGiftsContext.updateFilterAttributes(updatedFilterAttributes) + self.scrollToTop() } ), false)) @@ -633,6 +644,7 @@ final class GiftStoreScreenComponent: Component { } } self.state?.starGiftsContext.updateFilterAttributes(updatedFilterAttributes) + self.scrollToTop() }, selectAll: { [weak self] in guard let self else { @@ -645,6 +657,7 @@ final class GiftStoreScreenComponent: Component { return true } self.state?.starGiftsContext.updateFilterAttributes(updatedFilterAttributes) + self.scrollToTop() } ), false)) @@ -724,6 +737,7 @@ final class GiftStoreScreenComponent: Component { } } self.state?.starGiftsContext.updateFilterAttributes(updatedFilterAttributes) + self.scrollToTop() }, selectAll: { [weak self] in guard let self else { @@ -736,6 +750,7 @@ final class GiftStoreScreenComponent: Component { return true } self.state?.starGiftsContext.updateFilterAttributes(updatedFilterAttributes) + self.scrollToTop() } ), false)) @@ -900,7 +915,7 @@ final class GiftStoreScreenComponent: Component { let subtitleSize = self.subtitle.update( transition: transition, component: AnyComponent(BalancedTextComponent( - text: .plain(NSAttributedString(string: environment.strings.Gift_Store_ForResale(effectiveCount), font: Font.regular(13.0), textColor: theme.rootController.navigationBar.secondaryTextColor)), + text: .plain(NSAttributedString(string: effectiveCount == 0 ? environment.strings.Gift_Store_ForResaleNoResults : environment.strings.Gift_Store_ForResale(effectiveCount), font: Font.regular(13.0), textColor: theme.rootController.navigationBar.secondaryTextColor)), horizontalAlignment: .center, maximumNumberOfLines: 1 )), diff --git a/submodules/TelegramUI/Components/Stars/StarsPurchaseScreen/Sources/StarsPurchaseScreen.swift b/submodules/TelegramUI/Components/Stars/StarsPurchaseScreen/Sources/StarsPurchaseScreen.swift index c0aec3aef2..37da22d283 100644 --- a/submodules/TelegramUI/Components/Stars/StarsPurchaseScreen/Sources/StarsPurchaseScreen.swift +++ b/submodules/TelegramUI/Components/Stars/StarsPurchaseScreen/Sources/StarsPurchaseScreen.swift @@ -308,6 +308,7 @@ private final class StarsPurchaseScreenContentComponent: CombinedComponent { var i = 0 var items: [AnyComponentWithIdentity] = [] + var collapsedItems = 0 if let products = state.products, let balance = context.component.balance { var minimumCount: StarsAmount? if let requiredStars = context.component.purpose.requiredStars { @@ -326,6 +327,7 @@ private final class StarsPurchaseScreenContentComponent: CombinedComponent { if let _ = minimumCount, items.isEmpty { } else if !context.component.expanded && product.isExtended { + collapsedItems += 1 continue } @@ -390,7 +392,7 @@ private final class StarsPurchaseScreenContentComponent: CombinedComponent { } } - if !context.component.expanded && items.count > 1 { + if !context.component.expanded && collapsedItems > 0 { let titleComponent = AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString( string: strings.Stars_Purchase_ShowMore, From 60aef02fa5658ccb4411fdf08159d809f7a8c7a0 Mon Sep 17 00:00:00 2001 From: Ilya Laktyushin Date: Fri, 2 May 2025 18:50:50 +0400 Subject: [PATCH 13/29] Fix build --- .../Sources/GiftAttributeListContextItem.swift | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/submodules/TelegramUI/Components/Gifts/GiftStoreScreen/Sources/GiftAttributeListContextItem.swift b/submodules/TelegramUI/Components/Gifts/GiftStoreScreen/Sources/GiftAttributeListContextItem.swift index 58f23df6bf..28a5e9206a 100644 --- a/submodules/TelegramUI/Components/Gifts/GiftStoreScreen/Sources/GiftAttributeListContextItem.swift +++ b/submodules/TelegramUI/Components/Gifts/GiftStoreScreen/Sources/GiftAttributeListContextItem.swift @@ -71,15 +71,8 @@ private func actionForAttribute(attribute: StarGift.UniqueGift.Attribute, presen var title = "# \(name)" var count = "" - var attributeId: ResaleGiftsContext.Attribute? - switch attribute { - case .model: - attributeId = .model(file.fileId.id) - case .pattern: - attributeId = .pattenr(file.fileId.id) - } - if let attributeId, let counter = item.attributeCount[attributeId] { + if let counter = item.attributeCount[attributeId] { count = " \(presentationStringsFormattedNumber(counter, presentationData.dateTimeFormat.groupingSeparator))" entities.append( MessageTextEntity( From c04e8f3c113ba76b9a17718f72736f895c638601 Mon Sep 17 00:00:00 2001 From: Ilya Laktyushin Date: Sat, 3 May 2025 05:54:09 +0400 Subject: [PATCH 14/29] Various fixes --- .../TelegramEngine/Payments/StarGifts.swift | 38 ++++--- .../Sources/GiftOptionsScreen.swift | 10 +- .../Sources/GiftSetupScreen.swift | 105 +++++++++--------- .../Sources/GiftStoreScreen.swift | 26 +++-- .../Sources/GiftViewScreen.swift | 4 +- 5 files changed, 100 insertions(+), 83 deletions(-) diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Payments/StarGifts.swift b/submodules/TelegramCore/Sources/TelegramEngine/Payments/StarGifts.swift index 2eb5ec91de..6b44951fc8 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Payments/StarGifts.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Payments/StarGifts.swift @@ -2438,6 +2438,7 @@ private final class ResaleGiftsContextImpl { private var gifts: [StarGift] = [] private var attributes: [StarGift.UniqueGift.Attribute] = [] private var attributeCount: [ResaleGiftsContext.Attribute: Int32] = [:] + private var attributesHash: Int64? private var count: Int32? private var dataState: ResaleGiftsContext.State.DataState = .ready(canLoadMore: true, nextOffset: nil) @@ -2477,6 +2478,7 @@ private final class ResaleGiftsContextImpl { let postbox = self.account.postbox let sorting = self.sorting let filterAttributes = self.filterAttributes + let currentAttributesHash = self.attributesHash let dataState = self.dataState @@ -2511,46 +2513,45 @@ private final class ResaleGiftsContextImpl { } } - var attributesHash: Int64? - if "".isEmpty { - flags |= (1 << 0) - attributesHash = 0 - } + let attributesHash = currentAttributesHash ?? 0 + flags |= (1 << 0) let signal = network.request(Api.functions.payments.getResaleStarGifts(flags: flags, attributesHash: attributesHash, giftId: giftId, attributes: apiAttributes, offset: initialNextOffset ?? "", limit: 36)) |> map(Optional.init) |> `catch` { _ -> Signal in return .single(nil) } - |> mapToSignal { result -> Signal<([StarGift], [StarGift.UniqueGift.Attribute], [ResaleGiftsContext.Attribute: Int32], Int32, String?), NoError> in + |> mapToSignal { result -> Signal<([StarGift], [StarGift.UniqueGift.Attribute]?, [ResaleGiftsContext.Attribute: Int32]?, Int64?, Int32, String?), NoError> in guard let result else { - return .single(([], [], [:], 0, nil)) + return .single(([], nil, nil, nil, 0, nil)) } - return postbox.transaction { transaction -> ([StarGift], [StarGift.UniqueGift.Attribute], [ResaleGiftsContext.Attribute: Int32], Int32, String?) in + return postbox.transaction { transaction -> ([StarGift], [StarGift.UniqueGift.Attribute]?, [ResaleGiftsContext.Attribute: Int32]?, Int64?, Int32, String?) in switch result { case let .resaleStarGifts(_, count, gifts, nextOffset, attributes, attributesHash, chats, counters, users): let _ = attributesHash - var resultAttributes: [StarGift.UniqueGift.Attribute] = [] + var resultAttributes: [StarGift.UniqueGift.Attribute]? if let attributes { resultAttributes = attributes.compactMap { StarGift.UniqueGift.Attribute(apiAttribute: $0) } } - var attributeCount: [ResaleGiftsContext.Attribute: Int32] = [:] + var attributeCount: [ResaleGiftsContext.Attribute: Int32]? if let counters { + var attributeCountValue: [ResaleGiftsContext.Attribute: Int32] = [:] for counter in counters { switch counter { case let .starGiftAttributeCounter(attribute, count): switch attribute { case let .starGiftAttributeIdModel(documentId): - attributeCount[.model(documentId)] = count + attributeCountValue[.model(documentId)] = count case let .starGiftAttributeIdPattern(documentId): - attributeCount[.pattern(documentId)] = count + attributeCountValue[.pattern(documentId)] = count case let .starGiftAttributeIdBackdrop(backdropId): - attributeCount[.backdrop(backdropId)] = count + attributeCountValue[.backdrop(backdropId)] = count } } } + attributeCount = attributeCountValue } let parsedPeers = AccumulatedPeers(transaction: transaction, chats: chats, users: users) @@ -2563,13 +2564,13 @@ private final class ResaleGiftsContextImpl { } } - return (mappedGifts, resultAttributes, attributeCount, count, nextOffset) + return (mappedGifts, resultAttributes, attributeCount, attributesHash, count, nextOffset) } } } self.disposable.set((signal - |> deliverOn(self.queue)).start(next: { [weak self] (gifts, attributes, attributeCount, count, nextOffset) in + |> deliverOn(self.queue)).start(next: { [weak self] (gifts, attributes, attributeCount, attributesHash, count, nextOffset) in guard let self else { return } @@ -2581,10 +2582,13 @@ private final class ResaleGiftsContextImpl { let updatedCount = max(Int32(self.gifts.count), count) self.count = updatedCount - self.attributes = attributes - if !attributeCount.isEmpty { + + if let attributes, let attributeCount, let attributesHash { + self.attributes = attributes self.attributeCount = attributeCount + self.attributesHash = attributesHash } + self.dataState = .ready(canLoadMore: count != 0 && updatedCount > self.gifts.count && nextOffset != nil, nextOffset: nextOffset) self.pushState() diff --git a/submodules/TelegramUI/Components/Gifts/GiftOptionsScreen/Sources/GiftOptionsScreen.swift b/submodules/TelegramUI/Components/Gifts/GiftOptionsScreen/Sources/GiftOptionsScreen.swift index 52bd22d3ee..e28b5d6219 100644 --- a/submodules/TelegramUI/Components/Gifts/GiftOptionsScreen/Sources/GiftOptionsScreen.swift +++ b/submodules/TelegramUI/Components/Gifts/GiftOptionsScreen/Sources/GiftOptionsScreen.swift @@ -458,9 +458,13 @@ final class GiftOptionsScreenComponent: Component { mainController.push(giftController) } } else { - var forceUnique = false - if let disallowedGifts = self.state?.disallowedGifts, disallowedGifts.contains(.limited) && !disallowedGifts.contains(.unique) { - forceUnique = true + var forceUnique: Bool? + if let disallowedGifts = self.state?.disallowedGifts { + if disallowedGifts.contains(.limited) && !disallowedGifts.contains(.unique) { + forceUnique = true + } else if !disallowedGifts.contains(.limited) && disallowedGifts.contains(.unique) { + forceUnique = false + } } let giftController = GiftSetupScreen( diff --git a/submodules/TelegramUI/Components/Gifts/GiftSetupScreen/Sources/GiftSetupScreen.swift b/submodules/TelegramUI/Components/Gifts/GiftSetupScreen/Sources/GiftSetupScreen.swift index 3c325c54bb..2c574d0a09 100644 --- a/submodules/TelegramUI/Components/Gifts/GiftSetupScreen/Sources/GiftSetupScreen.swift +++ b/submodules/TelegramUI/Components/Gifts/GiftSetupScreen/Sources/GiftSetupScreen.swift @@ -788,57 +788,60 @@ final class GiftSetupScreenComponent: Component { contentHeight += sectionSpacing } - if case let .starGift(starGift, _) = component.subject, let availability = starGift.availability, availability.resale > 0 { - let resaleSectionSize = self.resaleSection.update( - transition: transition, - component: AnyComponent(ListSectionComponent( - theme: environment.theme, - header: nil, - footer: nil, - items: [ - AnyComponentWithIdentity(id: 0, component: AnyComponent(ListActionItemComponent( - theme: environment.theme, - title: AnyComponent(VStack([ - AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent( - MultilineTextComponent( - text: .plain(NSAttributedString(string: environment.strings.Gift_Send_AvailableForResale, font: Font.regular(presentationData.listsFontSize.baseDisplaySize), textColor: environment.theme.list.itemPrimaryTextColor)) + if case let .starGift(starGift, forceUnique) = component.subject, let availability = starGift.availability, availability.resale > 0 { + if let forceUnique, !forceUnique { + } else { + let resaleSectionSize = self.resaleSection.update( + transition: transition, + component: AnyComponent(ListSectionComponent( + theme: environment.theme, + header: nil, + footer: nil, + items: [ + AnyComponentWithIdentity(id: 0, component: AnyComponent(ListActionItemComponent( + theme: environment.theme, + title: AnyComponent(VStack([ + AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent( + MultilineTextComponent( + text: .plain(NSAttributedString(string: environment.strings.Gift_Send_AvailableForResale, font: Font.regular(presentationData.listsFontSize.baseDisplaySize), textColor: environment.theme.list.itemPrimaryTextColor)) + ) + )), + ], alignment: .left, spacing: 2.0)), + accessory: .custom(ListActionItemComponent.CustomAccessory(component: AnyComponentWithIdentity(id: 0, component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString( + string: presentationStringsFormattedNumber(Int32(availability.resale), environment.dateTimeFormat.groupingSeparator), + font: Font.regular(presentationData.listsFontSize.baseDisplaySize), + textColor: environment.theme.list.itemSecondaryTextColor + )), + maximumNumberOfLines: 0 + ))), insets: UIEdgeInsets(top: 0.0, left: 0.0, bottom: 0.0, right: 16.0))), + action: { [weak self] _ in + guard let self, let component = self.component, let controller = environment.controller() else { + return + } + let storeController = component.context.sharedContext.makeGiftStoreController( + context: component.context, + peerId: component.peerId, + gift: starGift ) - )), - ], alignment: .left, spacing: 2.0)), - accessory: .custom(ListActionItemComponent.CustomAccessory(component: AnyComponentWithIdentity(id: 0, component: AnyComponent(MultilineTextComponent( - text: .plain(NSAttributedString( - string: presentationStringsFormattedNumber(Int32(availability.resale), environment.dateTimeFormat.groupingSeparator), - font: Font.regular(presentationData.listsFontSize.baseDisplaySize), - textColor: environment.theme.list.itemSecondaryTextColor - )), - maximumNumberOfLines: 0 - ))), insets: UIEdgeInsets(top: 0.0, left: 0.0, bottom: 0.0, right: 16.0))), - action: { [weak self] _ in - guard let self, let component = self.component, let controller = environment.controller() else { - return + controller.push(storeController) } - let storeController = component.context.sharedContext.makeGiftStoreController( - context: component.context, - peerId: component.peerId, - gift: starGift - ) - controller.push(storeController) - } - ))) - ] - )), - environment: {}, - containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 10000.0) - ) - let resaleSectionFrame = CGRect(origin: CGPoint(x: sideInset, y: contentHeight), size: resaleSectionSize) - if let resaleSectionView = self.resaleSection.view { - if resaleSectionView.superview == nil { - self.scrollView.addSubview(resaleSectionView) + ))) + ] + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 10000.0) + ) + let resaleSectionFrame = CGRect(origin: CGPoint(x: sideInset, y: contentHeight), size: resaleSectionSize) + if let resaleSectionView = self.resaleSection.view { + if resaleSectionView.superview == nil { + self.scrollView.addSubview(resaleSectionView) + } + transition.setFrame(view: resaleSectionView, frame: resaleSectionFrame) } - transition.setFrame(view: resaleSectionView, frame: resaleSectionFrame) + contentHeight += resaleSectionSize.height + contentHeight += sectionSpacing } - contentHeight += resaleSectionSize.height - contentHeight += sectionSpacing } let giftConfiguration = GiftConfiguration.with(appConfiguration: component.context.currentAppConfiguration.with { $0 }) @@ -1128,7 +1131,7 @@ final class GiftSetupScreenComponent: Component { if isChannelGift { upgradeFooterRawString = environment.strings.Gift_SendChannel_Upgrade_Info(peerName).string } else { - if forceUnique { + if forceUnique == true { upgradeFooterRawString = environment.strings.Gift_Send_Upgrade_ForcedInfo(peerName).string } else { upgradeFooterRawString = environment.strings.Gift_Send_Upgrade_Info(peerName).string @@ -1201,8 +1204,8 @@ final class GiftSetupScreenComponent: Component { ) )), ], alignment: .left, spacing: 2.0)), - accessory: .toggle(ListActionItemComponent.Toggle(style: .regular, isOn: self.includeUpgrade, isEnabled: !forceUnique, action: { [weak self] _ in - guard let self, !forceUnique else { + accessory: .toggle(ListActionItemComponent.Toggle(style: .regular, isOn: self.includeUpgrade, isEnabled: forceUnique != true, action: { [weak self] _ in + guard let self, forceUnique != true else { return } self.includeUpgrade = !self.includeUpgrade @@ -1748,7 +1751,7 @@ final class GiftSetupScreenComponent: Component { public final class GiftSetupScreen: ViewControllerComponentContainer { public enum Subject: Equatable { case premium(PremiumGiftProduct) - case starGift(StarGift.Gift, Bool) + case starGift(StarGift.Gift, Bool?) } private let context: AccountContext diff --git a/submodules/TelegramUI/Components/Gifts/GiftStoreScreen/Sources/GiftStoreScreen.swift b/submodules/TelegramUI/Components/Gifts/GiftStoreScreen/Sources/GiftStoreScreen.swift index 99f6f05b84..1ca1c6317f 100644 --- a/submodules/TelegramUI/Components/Gifts/GiftStoreScreen/Sources/GiftStoreScreen.swift +++ b/submodules/TelegramUI/Components/Gifts/GiftStoreScreen/Sources/GiftStoreScreen.swift @@ -789,6 +789,11 @@ final class GiftStoreScreenComponent: Component { } self.component = component + var isLoading = false + if self.state?.starGiftsState?.gifts == nil || self.state?.starGiftsState?.dataState == .loading { + isLoading = true + } + let theme = environment.theme let strings = environment.strings @@ -887,6 +892,10 @@ final class GiftStoreScreenComponent: Component { balanceIconView.bounds = CGRect(origin: .zero, size: balanceIconSize) } + var topInset: CGFloat = 0.0 + if environment.statusBarHeight > 0.0 { + topInset = environment.statusBarHeight - 6.0 + } let titleSize = self.title.update( transition: transition, component: AnyComponent(MultilineTextComponent( @@ -900,7 +909,7 @@ final class GiftStoreScreenComponent: Component { if titleView.superview == nil { self.addSubview(titleView) } - transition.setFrame(view: titleView, frame: CGRect(origin: CGPoint(x: floor((availableSize.width - titleSize.width) / 2.0), y: 10.0), size: titleSize)) + transition.setFrame(view: titleView, frame: CGRect(origin: CGPoint(x: floor((availableSize.width - titleSize.width) / 2.0), y: topInset + 10.0), size: titleSize)) } let effectiveCount: Int32 @@ -922,7 +931,7 @@ final class GiftStoreScreenComponent: Component { environment: {}, containerSize: CGSize(width: availableSize.width - headerSideInset * 2.0, height: 100.0) ) - let subtitleFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - subtitleSize.width) / 2.0), y: 31.0), size: subtitleSize) + let subtitleFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - subtitleSize.width) / 2.0), y: topInset + 31.0), size: subtitleSize) if let subtitleView = self.subtitle.view { if subtitleView.superview == nil { self.addSubview(subtitleView) @@ -984,10 +993,10 @@ final class GiftStoreScreenComponent: Component { modelTitle = environment.strings.Gift_Store_Filter_Selected_Model(modelCount) } if backdropCount > 0 { - backdropTitle = environment.strings.Gift_Store_Filter_Selected_Backdrop(modelCount) + backdropTitle = environment.strings.Gift_Store_Filter_Selected_Backdrop(backdropCount) } if symbolCount > 0 { - symbolTitle = environment.strings.Gift_Store_Filter_Selected_Symbol(modelCount) + symbolTitle = environment.strings.Gift_Store_Filter_Selected_Symbol(symbolCount) } } @@ -1036,7 +1045,7 @@ final class GiftStoreScreenComponent: Component { if filterSelectorView.superview == nil { self.addSubview(filterSelectorView) } - transition.setFrame(view: filterSelectorView, frame: CGRect(origin: CGPoint(x: floor((availableSize.width - filterSize.width) / 2.0), y: 56.0), size: filterSize)) + transition.setFrame(view: filterSelectorView, frame: CGRect(origin: CGPoint(x: floor((availableSize.width - filterSize.width) / 2.0), y: topInset + 56.0), size: filterSize)) } if let starGifts = self.state?.starGiftsState?.gifts { @@ -1078,12 +1087,7 @@ final class GiftStoreScreenComponent: Component { self.topOverscrollLayer.frame = CGRect(origin: CGPoint(x: 0.0, y: -3000.0), size: CGSize(width: availableSize.width, height: 3000.0)) self.updateScrolling(transition: transition) - - var isLoading = false - if self.state?.starGiftsState?.gifts == nil || self.state?.starGiftsState?.dataState == .loading { - isLoading = true - } - + let loadingTransition: ComponentTransition = .easeInOut(duration: 0.25) if isLoading { self.loadingNode.update(size: availableSize, theme: environment.theme, transition: .immediate) diff --git a/submodules/TelegramUI/Components/Gifts/GiftViewScreen/Sources/GiftViewScreen.swift b/submodules/TelegramUI/Components/Gifts/GiftViewScreen/Sources/GiftViewScreen.swift index b1ed83e615..db684ec86e 100644 --- a/submodules/TelegramUI/Components/Gifts/GiftViewScreen/Sources/GiftViewScreen.swift +++ b/submodules/TelegramUI/Components/Gifts/GiftViewScreen/Sources/GiftViewScreen.swift @@ -805,6 +805,7 @@ private final class GiftViewSheetContent: CombinedComponent { default: break } + self.updated(transition: .easeInOut(duration: 0.2)) let text = presentationData.strings.Gift_View_Resale_Unlist_Success(giftTitle).string let tooltipController = UndoOverlayController( @@ -880,7 +881,8 @@ private final class GiftViewSheetContent: CombinedComponent { default: break } - + self.updated(transition: .easeInOut(duration: 0.2)) + var text = presentationData.strings.Gift_View_Resale_List_Success(giftTitle).string if update { let starsString = presentationData.strings.Gift_View_Resale_Relist_Success_Stars(Int32(price)) From fa463380103f6cb3254242a8fce4cfbc0ec4cca4 Mon Sep 17 00:00:00 2001 From: Ilya Laktyushin Date: Sat, 3 May 2025 17:07:17 +0400 Subject: [PATCH 15/29] Various fixes --- .../TelegramEngine/Payments/StarGifts.swift | 23 +------ .../Sources/GiftViewScreen.swift | 64 ++++++++++--------- 2 files changed, 38 insertions(+), 49 deletions(-) diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Payments/StarGifts.swift b/submodules/TelegramCore/Sources/TelegramEngine/Payments/StarGifts.swift index 6b44951fc8..7b5a1c63b5 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Payments/StarGifts.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Payments/StarGifts.swift @@ -1542,24 +1542,7 @@ private final class ProfileGiftsContextImpl { return EmptyDisposable } - var saveToProfile = false - if let gift = self.gifts.first(where: { $0.reference == reference }) { - if !gift.savedToProfile { - saveToProfile = true - } - } else if let gift = self.filteredGifts.first(where: { $0.reference == reference }) { - if !gift.savedToProfile { - saveToProfile = true - } - } - - var signal = _internal_updateStarGiftResalePrice(account: self.account, reference: reference, price: price) - if saveToProfile { - signal = _internal_updateStarGiftAddedToProfile(account: self.account, reference: reference, added: true) - |> castError(UpdateStarGiftPriceError.self) - |> then(signal) - } - + let signal = _internal_updateStarGiftResalePrice(account: self.account, reference: reference, price: price) let disposable = MetaDisposable() disposable.set( (signal @@ -1584,7 +1567,7 @@ private final class ProfileGiftsContextImpl { }) { if case let .unique(uniqueGift) = self.gifts[index].gift { let updatedUniqueGift = uniqueGift.withResellStars(price) - let updatedGift = self.gifts[index].withGift(.unique(updatedUniqueGift)).withSavedToProfile(true) + let updatedGift = self.gifts[index].withGift(.unique(updatedUniqueGift)) self.gifts[index] = updatedGift } } @@ -1607,7 +1590,7 @@ private final class ProfileGiftsContextImpl { }) { if case let .unique(uniqueGift) = self.filteredGifts[index].gift { let updatedUniqueGift = uniqueGift.withResellStars(price) - let updatedGift = self.filteredGifts[index].withGift(.unique(updatedUniqueGift)).withSavedToProfile(true) + let updatedGift = self.filteredGifts[index].withGift(.unique(updatedUniqueGift)) self.filteredGifts[index] = updatedGift } } diff --git a/submodules/TelegramUI/Components/Gifts/GiftViewScreen/Sources/GiftViewScreen.swift b/submodules/TelegramUI/Components/Gifts/GiftViewScreen/Sources/GiftViewScreen.swift index db684ec86e..ff8d1fb812 100644 --- a/submodules/TelegramUI/Components/Gifts/GiftViewScreen/Sources/GiftViewScreen.swift +++ b/submodules/TelegramUI/Components/Gifts/GiftViewScreen/Sources/GiftViewScreen.swift @@ -306,40 +306,46 @@ private final class GiftViewSheetContent: CombinedComponent { controller.dismissAllTooltips() - if gifts { - if let profileController = self.context.sharedContext.makePeerInfoController( - context: self.context, - updatedPresentationData: nil, - peer: peer._asPeer(), - mode: peer.id == self.context.account.peerId ? .myProfileGifts : .gifts, - avatarInitiallyExpanded: false, - fromChat: false, - requestsContext: nil - ) { - controller.push(profileController) + let context = self.context + let action = { + if gifts { + if let profileController = context.sharedContext.makePeerInfoController( + context: context, + updatedPresentationData: nil, + peer: peer._asPeer(), + mode: peer.id == context.account.peerId ? .myProfileGifts : .gifts, + avatarInitiallyExpanded: false, + fromChat: false, + requestsContext: nil + ) { + navigationController.pushViewController(profileController) + } + } else { + context.sharedContext.navigateToChatController(NavigateToChatControllerParams( + navigationController: navigationController, + chatController: nil, + context: context, + chatLocation: .peer(peer), + subject: nil, + botStart: nil, + updateTextInputState: nil, + keepStack: .always, + useExisting: true, + purposefulAction: nil, + scrollToEndIfExists: false, + activateMessageSearch: nil, + animated: true + )) } - } else { - self.context.sharedContext.navigateToChatController(NavigateToChatControllerParams( - navigationController: navigationController, - chatController: nil, - context: self.context, - chatLocation: .peer(peer), - subject: nil, - botStart: nil, - updateTextInputState: nil, - keepStack: .always, - useExisting: true, - purposefulAction: nil, - scrollToEndIfExists: false, - activateMessageSearch: nil, - animated: true - )) } if dismiss { - Queue.mainQueue().after(0.6, { - self.dismiss(animated: false) + self.dismiss(animated: true) + Queue.mainQueue().after(0.4, { + action() }) + } else { + action() } } From ee38ee55d4969320b0b7ceafe249a635eb07bddc Mon Sep 17 00:00:00 2001 From: Ilya Laktyushin Date: Mon, 5 May 2025 18:42:51 +0400 Subject: [PATCH 16/29] Various fixes --- .../Telegram-iOS/en.lproj/Localizable.strings | 5 + .../Sources/CallListController.swift | 17 +- .../Sources/ChatListControllerNode.swift | 2 +- .../Sources/Node/ChatListNode.swift | 4 +- .../Sources/ReactionButtonListComponent.swift | 2 +- .../ContextUI/Sources/ContextController.swift | 7 +- .../DrawingUI/Sources/ColorPickerScreen.swift | 2 +- .../TelegramNotices/Sources/Notices.swift | 32 ++ .../CameraScreen/Sources/CameraScreen.swift | 21 +- .../Sources/GiftOptionsScreen.swift | 13 +- .../Sources/FilterSelectorComponent.swift | 9 + .../Sources/GiftStoreScreen.swift | 68 ++-- .../Sources/LoadingShimmerComponent.swift | 13 +- .../Sources/GiftViewScreen.swift | 4 +- .../Components/MediaEditorScreen/BUILD | 1 + .../Sources/MediaEditorScreen.swift | 34 +- .../Sources/PeerInfoScreenAvatarSetup.swift | 2 +- .../WebUI/Sources/WebAppController.swift | 320 +++++++++++------- 18 files changed, 389 insertions(+), 167 deletions(-) diff --git a/Telegram/Telegram-iOS/en.lproj/Localizable.strings b/Telegram/Telegram-iOS/en.lproj/Localizable.strings index c7ef0e4901..e4cd94bb7e 100644 --- a/Telegram/Telegram-iOS/en.lproj/Localizable.strings +++ b/Telegram/Telegram-iOS/en.lproj/Localizable.strings @@ -14315,3 +14315,8 @@ Sorry for the inconvenience."; "Gift.Buy.Confirm.Text.Stars_any" = "**%@** Stars"; "Gift.Buy.Confirm.BuyFor_1" = "Buy for %@ Star"; "Gift.Buy.Confirm.BuyFor_any" = "Buy for %@ Stars"; + +"Calls.HideCallsTab" = "Hide Calls Tab"; + +"Story.Editor.TooltipSelection_1" = "Tap here to view your %@ story"; +"Story.Editor.TooltipSelection_any" = "Tap here to view your %@ stories"; diff --git a/submodules/CallListUI/Sources/CallListController.swift b/submodules/CallListUI/Sources/CallListController.swift index 9475b141c7..407693bec4 100644 --- a/submodules/CallListUI/Sources/CallListController.swift +++ b/submodules/CallListUI/Sources/CallListController.swift @@ -16,6 +16,7 @@ import TelegramBaseController import InviteLinksUI import UndoUI import TelegramCallsUI +import TelegramUIPreferences public enum CallListControllerMode { case tab @@ -734,10 +735,22 @@ public final class CallListController: TelegramBaseController { return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/AddUser"), color: theme.contextMenu.primaryColor) }, action: { [weak self] c, f in c?.dismiss(completion: { [weak self] in - guard let strongSelf = self else { + guard let self else { return } - strongSelf.callPressed() + self.callPressed() + }) + }))) + items.append(.action(ContextMenuActionItem(text: self.presentationData.strings.Calls_HideCallsTab, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Peer Info/HideIcon"), color: theme.contextMenu.primaryColor) + }, action: { [weak self] c, f in + c?.dismiss(completion: { [weak self] in + guard let self else { + return + } + let _ = updateCallListSettingsInteractively(accountManager: self.context.sharedContext.accountManager, { + $0.withUpdatedShowTab(false) + }).start() }) }))) diff --git a/submodules/ChatListUI/Sources/ChatListControllerNode.swift b/submodules/ChatListUI/Sources/ChatListControllerNode.swift index 4d8534e741..357cf6e848 100644 --- a/submodules/ChatListUI/Sources/ChatListControllerNode.swift +++ b/submodules/ChatListUI/Sources/ChatListControllerNode.swift @@ -1015,7 +1015,7 @@ public final class ChatListContainerNode: ASDisplayNode, ASGestureRecognizerDele } } - itemNode.listNode.isMainTab.set(self.availableFilters.firstIndex(where: { $0.id == id }) == 0 ? true : false) + itemNode.listNode.isMainTab.set(self.availableFilters.firstIndex(where: { $0.id == id }) == 0) itemNode.updateLayout(size: layout.size, insets: insets, visualNavigationHeight: visualNavigationHeight, originalNavigationHeight: originalNavigationHeight, inlineNavigationLocation: inlineNavigationLocation, inlineNavigationTransitionFraction: itemInlineNavigationTransitionFraction, storiesInset: storiesInset, transition: nodeTransition) if let scrollingOffset = self.scrollingOffset { itemNode.updateScrollingOffset(navigationHeight: scrollingOffset.navigationHeight, offset: scrollingOffset.offset, transition: nodeTransition) diff --git a/submodules/ChatListUI/Sources/Node/ChatListNode.swift b/submodules/ChatListUI/Sources/Node/ChatListNode.swift index 302bb3a8cf..5f679acdc0 100644 --- a/submodules/ChatListUI/Sources/Node/ChatListNode.swift +++ b/submodules/ChatListUI/Sources/Node/ChatListNode.swift @@ -2092,8 +2092,6 @@ public final class ChatListNode: ListView { return .single(.setupPhoto(accountPeer)) } else if suggestions.contains(.gracePremium) { return .single(.premiumGrace) - } else if suggestions.contains(.setupBirthday) && birthday == nil { - return .single(.setupBirthday) } else if suggestions.contains(.xmasPremiumGift) { return .single(.xmasPremiumGift) } else if suggestions.contains(.annualPremium) || suggestions.contains(.upgradePremium) || suggestions.contains(.restorePremium), let inAppPurchaseManager = context.inAppPurchaseManager { @@ -2149,6 +2147,8 @@ public final class ChatListNode: ListView { } return .birthdayPremiumGift(peers: todayBirthdayPeers, birthdays: birthdays) } + } else if suggestions.contains(.setupBirthday) && birthday == nil { + return .single(.setupBirthday) } else if case let .link(id, url, title, subtitle) = suggestions.first(where: { if case .link = $0 { return true } else { return false} }) { return .single(.link(id: id, url: url, title: title, subtitle: subtitle)) } else { diff --git a/submodules/Components/ReactionButtonListComponent/Sources/ReactionButtonListComponent.swift b/submodules/Components/ReactionButtonListComponent/Sources/ReactionButtonListComponent.swift index ce867a96d1..a74dda7990 100644 --- a/submodules/Components/ReactionButtonListComponent/Sources/ReactionButtonListComponent.swift +++ b/submodules/Components/ReactionButtonListComponent/Sources/ReactionButtonListComponent.swift @@ -501,7 +501,7 @@ public final class ReactionButtonAsyncNode: ContextControllerSourceView { animationFraction = max(0.0, min(1.0, (CACurrentMediaTime() - animationState.startTime) / animationState.duration)) animationFraction = animationState.curve.solve(at: animationFraction) if animationState.fromExtracted != isExtracted { - fixedTransitionDirection = isExtracted ? true : false + fixedTransitionDirection = isExtracted } } else { animationFraction = 1.0 diff --git a/submodules/ContextUI/Sources/ContextController.swift b/submodules/ContextUI/Sources/ContextController.swift index 6842c65076..771e502072 100644 --- a/submodules/ContextUI/Sources/ContextController.swift +++ b/submodules/ContextUI/Sources/ContextController.swift @@ -527,7 +527,12 @@ final class ContextControllerNode: ViewControllerTracingNode, ASScrollViewDelega guard let strongSelf = self, let _ = gesture else { return } - let localPoint = strongSelf.view.convert(point, from: view) + let localPoint: CGPoint + if let layout = strongSelf.validLayout, layout.metrics.isTablet, layout.size.width > layout.size.height, let view { + localPoint = view.convert(point, to: nil) + } else { + localPoint = strongSelf.view.convert(point, from: view) + } let initialPoint: CGPoint if let current = strongSelf.initialContinueGesturePoint { initialPoint = current diff --git a/submodules/DrawingUI/Sources/ColorPickerScreen.swift b/submodules/DrawingUI/Sources/ColorPickerScreen.swift index d7eb09b353..8b0d1fc502 100644 --- a/submodules/DrawingUI/Sources/ColorPickerScreen.swift +++ b/submodules/DrawingUI/Sources/ColorPickerScreen.swift @@ -697,7 +697,7 @@ final class ColorGridComponent: Component { bottomRightRadius = largeCornerRadius } - let isLight = (selectedColor?.toUIColor().lightness ?? 1.0) < 0.5 ? true : false + let isLight = (selectedColor?.toUIColor().lightness ?? 1.0) < 0.5 var selectionKnobImage = ColorSelectionImage(size: CGSize(width: squareSize, height: squareSize), topLeftRadius: topLeftRadius, topRightRadius: topRightRadius, bottomLeftRadius: bottomLeftRadius, bottomRightRadius: bottomRightRadius, isLight: isLight) if selectionKnobImage != self.selectionKnobImage { diff --git a/submodules/TelegramNotices/Sources/Notices.swift b/submodules/TelegramNotices/Sources/Notices.swift index aebfa04f96..29a3e4862b 100644 --- a/submodules/TelegramNotices/Sources/Notices.swift +++ b/submodules/TelegramNotices/Sources/Notices.swift @@ -203,6 +203,7 @@ private enum ApplicationSpecificGlobalNotice: Int32 { case channelSendGiftTooltip = 76 case starGiftWearTips = 77 case channelSuggestTooltip = 78 + case multipleStoriesTooltip = 79 var key: ValueBoxKey { let v = ValueBoxKey(length: 4) @@ -564,6 +565,10 @@ private struct ApplicationSpecificNoticeKeys { static func channelSuggestTooltip() -> NoticeEntryKey { return NoticeEntryKey(namespace: noticeNamespace(namespace: globalNamespace), key: ApplicationSpecificGlobalNotice.channelSuggestTooltip.key) } + + static func multipleStoriesTooltip() -> NoticeEntryKey { + return NoticeEntryKey(namespace: noticeNamespace(namespace: globalNamespace), key: ApplicationSpecificGlobalNotice.multipleStoriesTooltip.key) + } } public struct ApplicationSpecificNotice { @@ -2426,4 +2431,31 @@ public struct ApplicationSpecificNotice { return Int(previousValue) } } + + public static func getMultipleStoriesTooltip(accountManager: AccountManager) -> Signal { + return accountManager.transaction { transaction -> Int32 in + if let value = transaction.getNotice(ApplicationSpecificNoticeKeys.multipleStoriesTooltip())?.get(ApplicationSpecificCounterNotice.self) { + return value.value + } else { + return 0 + } + } + } + + public static func incrementMultipleStoriesTooltip(accountManager: AccountManager, count: Int = 1) -> Signal { + return accountManager.transaction { transaction -> Int in + var currentValue: Int32 = 0 + if let value = transaction.getNotice(ApplicationSpecificNoticeKeys.multipleStoriesTooltip())?.get(ApplicationSpecificCounterNotice.self) { + currentValue = value.value + } + let previousValue = currentValue + currentValue += Int32(count) + + if let entry = CodableEntry(ApplicationSpecificCounterNotice(value: currentValue)) { + transaction.setNotice(ApplicationSpecificNoticeKeys.multipleStoriesTooltip(), entry) + } + + return Int(previousValue) + } + } } diff --git a/submodules/TelegramUI/Components/CameraScreen/Sources/CameraScreen.swift b/submodules/TelegramUI/Components/CameraScreen/Sources/CameraScreen.swift index bda8dd68ee..5b57038105 100644 --- a/submodules/TelegramUI/Components/CameraScreen/Sources/CameraScreen.swift +++ b/submodules/TelegramUI/Components/CameraScreen/Sources/CameraScreen.swift @@ -3630,7 +3630,13 @@ public class CameraScreenImpl: ViewController, CameraScreen { self.node.resumeCameraCapture(fromGallery: true) } - var dismissControllerImpl: (() -> Void)? + class DismissArgs { + var resumeOnDismiss = true + } + + var dismissControllerImpl: ((Bool) -> Void)? + let dismissArgs = DismissArgs() + let controller: ViewController if let current = self.galleryController { controller = current @@ -3686,7 +3692,7 @@ public class CameraScreenImpl: ViewController, CameraScreen { } } - dismissControllerImpl?() + dismissControllerImpl?(true) } else { stopCameraCapture() @@ -3759,17 +3765,19 @@ public class CameraScreenImpl: ViewController, CameraScreen { self.node.collage?.addResults(signals: results) } } else { + self.node.animateOutToEditor() if let assets = results as? [PHAsset] { self.completion(.single(.assets(assets)), nil, self.remainingStoryCount, { - }) } } self.galleryController = nil - dismissControllerImpl?() + dismissControllerImpl?(false) }, dismissed: { [weak self] in - resumeCameraCapture() + if dismissArgs.resumeOnDismiss { + resumeCameraCapture() + } if let self { self.node.hasGallery = false self.node.requestUpdateLayout(transition: .immediate) @@ -3780,7 +3788,8 @@ public class CameraScreenImpl: ViewController, CameraScreen { ) self.galleryController = controller - dismissControllerImpl = { [weak controller] in + dismissControllerImpl = { [weak controller] resume in + dismissArgs.resumeOnDismiss = resume controller?.dismiss(animated: true) } } diff --git a/submodules/TelegramUI/Components/Gifts/GiftOptionsScreen/Sources/GiftOptionsScreen.swift b/submodules/TelegramUI/Components/Gifts/GiftOptionsScreen/Sources/GiftOptionsScreen.swift index e28b5d6219..5b79de4903 100644 --- a/submodules/TelegramUI/Components/Gifts/GiftOptionsScreen/Sources/GiftOptionsScreen.swift +++ b/submodules/TelegramUI/Components/Gifts/GiftOptionsScreen/Sources/GiftOptionsScreen.swift @@ -235,6 +235,8 @@ final class GiftOptionsScreenComponent: Component { private var chevronImage: (UIImage, PresentationTheme)? + private var resaleConfiguration: StarsSubscriptionConfiguration? + override init(frame: CGRect) { self.scrollView = ScrollView() self.scrollView.showsVerticalScrollIndicator = true @@ -408,9 +410,14 @@ final class GiftOptionsScreenComponent: Component { switch gift { case let .generic(gift): if let availability = gift.availability, availability.remains == 0, let minResaleStars = availability.minResaleStars { - subject = .starGift(gift: gift, price: "⭐️ \(minResaleStars)+") + let priceString = presentationStringsFormattedNumber(Int32(minResaleStars), environment.dateTimeFormat.groupingSeparator) + if let resaleConfiguration = self.resaleConfiguration, minResaleStars == resaleConfiguration.starGiftResaleMaxAmount || availability.resale == 1 { + subject = .starGift(gift: gift, price: "⭐️ \(priceString)") + } else { + subject = .starGift(gift: gift, price: "⭐️ \(priceString)+") + } } else { - subject = .starGift(gift: gift, price: "⭐️ \(gift.price)") + subject = .starGift(gift: gift, price: "⭐️ \(presentationStringsFormattedNumber(Int32(gift.price), environment.dateTimeFormat.groupingSeparator))") } case let .unique(gift): subject = .uniqueGift(gift: gift, price: nil) @@ -773,6 +780,8 @@ final class GiftOptionsScreenComponent: Component { self.optionsPromise.set(component.context.engine.payments.starsTopUpOptions() |> map(Optional.init)) } + + self.resaleConfiguration = StarsSubscriptionConfiguration.with(appConfiguration: component.context.currentAppConfiguration.with { $0 }) } self.component = component diff --git a/submodules/TelegramUI/Components/Gifts/GiftStoreScreen/Sources/FilterSelectorComponent.swift b/submodules/TelegramUI/Components/Gifts/GiftStoreScreen/Sources/FilterSelectorComponent.swift index 5b31427f89..8d4bc8fff3 100644 --- a/submodules/TelegramUI/Components/Gifts/GiftStoreScreen/Sources/FilterSelectorComponent.swift +++ b/submodules/TelegramUI/Components/Gifts/GiftStoreScreen/Sources/FilterSelectorComponent.swift @@ -109,6 +109,15 @@ public final class FilterSelectorComponent: Component { return true } + func animateIn() { + for (_, item) in self.visibleItems { + if let itemView = item.title.view { + itemView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) + itemView.layer.animateScale(from: 0.1, to: 1.0, duration: 0.2) + } + } + } + func update(component: FilterSelectorComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { self.component = component self.state = state diff --git a/submodules/TelegramUI/Components/Gifts/GiftStoreScreen/Sources/GiftStoreScreen.swift b/submodules/TelegramUI/Components/Gifts/GiftStoreScreen/Sources/GiftStoreScreen.swift index 1ca1c6317f..ae889947fc 100644 --- a/submodules/TelegramUI/Components/Gifts/GiftStoreScreen/Sources/GiftStoreScreen.swift +++ b/submodules/TelegramUI/Components/Gifts/GiftStoreScreen/Sources/GiftStoreScreen.swift @@ -27,6 +27,8 @@ import UndoUI import ContextUI import LottieComponent +private let minimumCountToDisplayFilters = 18 + final class GiftStoreScreenComponent: Component { typealias EnvironmentType = ViewControllerComponentContainer.Environment @@ -93,7 +95,8 @@ final class GiftStoreScreenComponent: Component { private var starsStateDisposable: Disposable? private var starsState: StarsContext.State? - + private var initialCount: Int? + private var component: GiftStoreScreenComponent? private(set) weak var state: State? private var environment: EnvironmentType? @@ -148,6 +151,13 @@ final class GiftStoreScreenComponent: Component { } } + private var effectiveIsLoading: Bool { + if self.state?.starGiftsState?.gifts == nil || self.state?.starGiftsState?.dataState == .loading { + return true + } + return false + } + private func updateScrolling(interactive: Bool = false, transition: ComponentTransition) { guard let environment = self.environment, let component = self.component, self.state?.starGiftsState?.dataState != .loading else { return @@ -163,6 +173,11 @@ final class GiftStoreScreenComponent: Component { transition.setAlpha(view: topSeparator, alpha: topPanelAlpha) } + var topInset = environment.navigationHeight + 39.0 + if let initialCount = self.initialCount, initialCount < minimumCountToDisplayFilters { + topInset = environment.navigationHeight + } + let visibleBounds = self.scrollView.bounds.insetBy(dx: 0.0, dy: -10.0) if let starGifts = self.effectiveGifts { let sideInset: CGFloat = 16.0 + environment.safeInsets.left @@ -172,7 +187,7 @@ final class GiftStoreScreenComponent: Component { let starsOptionSize = CGSize(width: optionWidth, height: 154.0) var validIds: [AnyHashable] = [] - var itemFrame = CGRect(origin: CGPoint(x: sideInset, y: environment.navigationHeight + 39.0 + 9.0), size: starsOptionSize) + var itemFrame = CGRect(origin: CGPoint(x: sideInset, y: topInset + 9.0), size: starsOptionSize) let controller = environment.controller @@ -337,7 +352,6 @@ final class GiftStoreScreenComponent: Component { showClearFilters = true } - let topInset: CGFloat = environment.navigationHeight + 39.0 let bottomInset: CGFloat = environment.safeInsets.bottom var emptyResultsActionFrame = CGRect( @@ -443,7 +457,7 @@ final class GiftStoreScreenComponent: Component { } func openSortContextMenu(sourceView: UIView) { - guard let component = self.component, let controller = self.environment?.controller() else { + guard let component = self.component, let controller = self.environment?.controller(), !self.effectiveIsLoading else { return } @@ -486,10 +500,10 @@ final class GiftStoreScreenComponent: Component { } func openModelContextMenu(sourceView: UIView) { - guard let component = self.component, let controller = self.environment?.controller() else { + guard let component = self.component, let controller = self.environment?.controller(), !self.effectiveIsLoading else { return } - + let presentationData = component.context.sharedContext.currentPresentationData.with { $0 } let searchQueryPromise = ValuePromise("") @@ -579,7 +593,7 @@ final class GiftStoreScreenComponent: Component { } func openBackdropContextMenu(sourceView: UIView) { - guard let component = self.component, let controller = self.environment?.controller() else { + guard let component = self.component, let controller = self.environment?.controller(), !self.effectiveIsLoading else { return } @@ -672,7 +686,7 @@ final class GiftStoreScreenComponent: Component { } func openSymbolContextMenu(sourceView: UIView) { - guard let component = self.component, let controller = self.environment?.controller() else { + guard let component = self.component, let controller = self.environment?.controller(), !self.effectiveIsLoading else { return } @@ -789,10 +803,7 @@ final class GiftStoreScreenComponent: Component { } self.component = component - var isLoading = false - if self.state?.starGiftsState?.gifts == nil || self.state?.starGiftsState?.dataState == .loading { - isLoading = true - } + let isLoading = self.effectiveIsLoading let theme = environment.theme let strings = environment.strings @@ -808,7 +819,10 @@ final class GiftStoreScreenComponent: Component { var contentHeight: CGFloat = 0.0 contentHeight += environment.navigationHeight - let topPanelHeight = environment.navigationHeight + 39.0 + var topPanelHeight = environment.navigationHeight + 39.0 + if let initialCount = self.initialCount, initialCount < minimumCountToDisplayFilters { + topPanelHeight = environment.navigationHeight + } let topPanelSize = self.topPanel.update( transition: transition, @@ -913,7 +927,10 @@ final class GiftStoreScreenComponent: Component { } let effectiveCount: Int32 - if let count = self.effectiveGifts?.count { + if let count = self.effectiveGifts?.count, count > 0 || self.initialCount != nil { + if self.initialCount == nil { + self.initialCount = count + } effectiveCount = Int32(count) } else if let resale = component.gift.availability?.resale { effectiveCount = Int32(resale) @@ -1028,13 +1045,15 @@ final class GiftStoreScreenComponent: Component { } )) + let loadingTransition: ComponentTransition = .easeInOut(duration: 0.25) + let filterSize = self.filterSelector.update( transition: transition, component: AnyComponent(FilterSelectorComponent( context: component.context, colors: FilterSelectorComponent.Colors( foreground: theme.list.itemPrimaryTextColor.withMultipliedAlpha(0.65), - background: theme.list.itemSecondaryTextColor.withMultipliedAlpha(0.15) + background: theme.list.itemSecondaryTextColor.mixedWith(theme.list.blocksBackgroundColor, alpha: 0.85) ), items: filterItems )), @@ -1043,9 +1062,14 @@ final class GiftStoreScreenComponent: Component { ) if let filterSelectorView = self.filterSelector.view { if filterSelectorView.superview == nil { + filterSelectorView.alpha = 0.0 self.addSubview(filterSelectorView) } transition.setFrame(view: filterSelectorView, frame: CGRect(origin: CGPoint(x: floor((availableSize.width - filterSize.width) / 2.0), y: topInset + 56.0), size: filterSize)) + + if let initialCount = self.initialCount, initialCount >= minimumCountToDisplayFilters { + loadingTransition.setAlpha(view: filterSelectorView, alpha: 1.0) + } } if let starGifts = self.state?.starGiftsState?.gifts { @@ -1088,14 +1112,13 @@ final class GiftStoreScreenComponent: Component { self.updateScrolling(transition: transition) - let loadingTransition: ComponentTransition = .easeInOut(duration: 0.25) if isLoading { self.loadingNode.update(size: availableSize, theme: environment.theme, transition: .immediate) loadingTransition.setAlpha(view: self.loadingNode.view, alpha: 1.0) } else { loadingTransition.setAlpha(view: self.loadingNode.view, alpha: 0.0) } - transition.setFrame(view: self.loadingNode.view, frame: CGRect(origin: CGPoint(x: 0.0, y: environment.navigationHeight + 39.0 + 7.0), size: availableSize)) + transition.setFrame(view: self.loadingNode.view, frame: CGRect(origin: CGPoint(x: 0.0, y: environment.navigationHeight), size: availableSize)) return availableSize } @@ -1108,19 +1131,22 @@ final class GiftStoreScreenComponent: Component { final class State: ComponentState { private let context: AccountContext var peerId: EnginePeer.Id + private let gift: StarGift.Gift + private var disposable: Disposable? fileprivate let starGiftsContext: ResaleGiftsContext fileprivate var starGiftsState: ResaleGiftsContext.State? - + init( context: AccountContext, peerId: EnginePeer.Id, - giftId: Int64 + gift: StarGift.Gift ) { self.context = context self.peerId = peerId - self.starGiftsContext = ResaleGiftsContext(account: context.account, giftId: giftId) + self.gift = gift + self.starGiftsContext = ResaleGiftsContext(account: context.account, giftId: gift.id) super.init() @@ -1140,7 +1166,7 @@ final class GiftStoreScreenComponent: Component { } func makeState() -> State { - return State(context: self.context, peerId: self.peerId, giftId: self.gift.id) + return State(context: self.context, peerId: self.peerId, gift: self.gift) } func update(view: View, availableSize: CGSize, state: State, environment: Environment, transition: ComponentTransition) -> CGSize { diff --git a/submodules/TelegramUI/Components/Gifts/GiftStoreScreen/Sources/LoadingShimmerComponent.swift b/submodules/TelegramUI/Components/Gifts/GiftStoreScreen/Sources/LoadingShimmerComponent.swift index b9f6c04b2f..73e314820f 100644 --- a/submodules/TelegramUI/Components/Gifts/GiftStoreScreen/Sources/LoadingShimmerComponent.swift +++ b/submodules/TelegramUI/Components/Gifts/GiftStoreScreen/Sources/LoadingShimmerComponent.swift @@ -154,10 +154,17 @@ final class LoadingShimmerNode: ASDisplayNode { context.setFillColor(theme.list.blocksBackgroundColor.cgColor) context.fill(CGRect(origin: CGPoint(), size: size)) - var currentY: CGFloat = 0.0 + let sideInset: CGFloat = 16.0 + + let filterSpacing: CGFloat = 6.0 + let filterWidth = (size.width - sideInset * 2.0 - filterSpacing * 3.0) / 4.0 + for i in 0 ..< 4 { + context.addPath(CGPath(roundedRect: CGRect(origin: CGPoint(x: sideInset + (filterWidth + filterSpacing) * CGFloat(i), y: 0.0), size: CGSize(width: filterWidth, height: 28.0)), cornerWidth: 14.0, cornerHeight: 14.0, transform: nil)) + } + + var currentY: CGFloat = 39.0 + 7.0 var rowIndex: Int = 0 - let sideInset: CGFloat = 16.0// + environment.safeInsets.left let optionSpacing: CGFloat = 10.0 let optionWidth = (size.width - sideInset * 2.0 - optionSpacing * 2.0) / 3.0 let itemSize = CGSize(width: optionWidth, height: 154.0) @@ -167,7 +174,7 @@ final class LoadingShimmerNode: ASDisplayNode { while currentY < size.height { for i in 0 ..< 3 { - let itemOrigin = CGPoint(x: sideInset + CGFloat(i) * (itemSize.width + optionSpacing), y: 2.0 + CGFloat(rowIndex) * (itemSize.height + optionSpacing)) + let itemOrigin = CGPoint(x: sideInset + CGFloat(i) * (itemSize.width + optionSpacing), y: 39.0 + 9.0 + CGFloat(rowIndex) * (itemSize.height + optionSpacing)) context.addPath(CGPath(roundedRect: CGRect(origin: itemOrigin, size: itemSize), cornerWidth: 10.0, cornerHeight: 10.0, transform: nil)) } currentY += itemSize.height diff --git a/submodules/TelegramUI/Components/Gifts/GiftViewScreen/Sources/GiftViewScreen.swift b/submodules/TelegramUI/Components/Gifts/GiftViewScreen/Sources/GiftViewScreen.swift index ff8d1fb812..3d8134dfbc 100644 --- a/submodules/TelegramUI/Components/Gifts/GiftViewScreen/Sources/GiftViewScreen.swift +++ b/submodules/TelegramUI/Components/Gifts/GiftViewScreen/Sources/GiftViewScreen.swift @@ -496,7 +496,7 @@ private final class GiftViewSheetContent: CombinedComponent { if currentTime > starsConvertMaxDate { let days: Int32 = Int32(ceil(Float(configuration.convertToStarsPeriod) / 86400.0)) - let controller = textAlertController( + let alertController = textAlertController( context: self.context, title: presentationData.strings.Gift_Convert_Title, text: presentationData.strings.Gift_Convert_Period_Unavailable_Text(presentationData.strings.Gift_Convert_Period_Unavailable_Days(days)).string, @@ -505,7 +505,7 @@ private final class GiftViewSheetContent: CombinedComponent { ], parseMarkdown: true ) - controller.present(controller, in: .window(.root)) + controller.present(alertController, in: .window(.root)) } else { let delta = starsConvertMaxDate - currentTime let days: Int32 = Int32(ceil(Float(delta) / 86400.0)) diff --git a/submodules/TelegramUI/Components/MediaEditorScreen/BUILD b/submodules/TelegramUI/Components/MediaEditorScreen/BUILD index b07b9b4bfb..814fcb4edd 100644 --- a/submodules/TelegramUI/Components/MediaEditorScreen/BUILD +++ b/submodules/TelegramUI/Components/MediaEditorScreen/BUILD @@ -67,6 +67,7 @@ swift_library( "//submodules/TelegramUI/Components/SaveProgressScreen", "//submodules/TelegramUI/Components/MediaAssetsContext", "//submodules/CheckNode", + "//submodules/TelegramNotices", ], visibility = [ "//visibility:public", diff --git a/submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreen.swift b/submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreen.swift index 3cee6aa8cd..ecacd3939b 100644 --- a/submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreen.swift +++ b/submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreen.swift @@ -49,6 +49,7 @@ import StickerPickerScreen import UIKitRuntimeUtils import ImageObjectSeparation import SaveProgressScreen +import TelegramNotices private let playbackButtonTag = GenericComponentViewTag() private let muteButtonTag = GenericComponentViewTag() @@ -58,6 +59,7 @@ private let drawButtonTag = GenericComponentViewTag() private let textButtonTag = GenericComponentViewTag() private let stickerButtonTag = GenericComponentViewTag() private let dayNightButtonTag = GenericComponentViewTag() +private let selectionButtonTag = GenericComponentViewTag() final class MediaEditorScreenComponent: Component { typealias EnvironmentType = ViewControllerComponentContainer.Environment @@ -2320,7 +2322,8 @@ final class MediaEditorScreenComponent: Component { controller.hapticFeedback.impact(.light) } }, - animateAlpha: false + animateAlpha: false, + tag: selectionButtonTag )), environment: {}, containerSize: CGSize(width: 33.0, height: 33.0) @@ -4744,6 +4747,33 @@ public final class MediaEditorScreenImpl: ViewController, MediaEditorScreen, UID self.controller?.present(tooltipController, in: .current) } + private var displayedSelectionTooltip = false + func presentSelectionTooltip() { + guard let sourceView = self.componentHost.findTaggedView(tag: selectionButtonTag), !self.displayedSelectionTooltip, self.items.count > 1 else { + return + } + + self.displayedSelectionTooltip = true + + let _ = (ApplicationSpecificNotice.getMultipleStoriesTooltip(accountManager: self.context.sharedContext.accountManager) + |> deliverOnMainQueue).start(next: { [weak self] count in + guard let self, count < 3 else { + return + } + let parentFrame = self.view.convert(self.bounds, to: nil) + let absoluteFrame = sourceView.convert(sourceView.bounds, to: nil).offsetBy(dx: -parentFrame.minX, dy: 0.0) + let location = CGRect(origin: CGPoint(x: absoluteFrame.midX, y: absoluteFrame.minY - 3.0), size: CGSize()) + + let text = self.presentationData.strings.Story_Editor_TooltipSelection(Int32(self.items.count)) + let tooltipController = TooltipScreen(account: self.context.account, sharedContext: self.context.sharedContext, text: .plain(text: text), location: .point(location, .bottom), displayDuration: .default, inset: 8.0, shouldDismissOnTouch: { _, _ in + return .dismiss(consume: false) + }) + self.controller?.present(tooltipController, in: .current) + + let _ = ApplicationSpecificNotice.incrementMultipleStoriesTooltip(accountManager: self.context.sharedContext.accountManager).start() + }) + } + fileprivate weak var saveTooltip: SaveProgressScreen? func presentSaveTooltip() { guard let controller = self.controller else { @@ -5725,6 +5755,8 @@ public final class MediaEditorScreenImpl: ViewController, MediaEditorScreen, UID if hasAppeared && !self.hasAppeared { self.hasAppeared = hasAppeared + + self.presentSelectionTooltip() } let componentSize = self.componentHost.update( diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreenAvatarSetup.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreenAvatarSetup.swift index 00f309ace7..9bdfe72622 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreenAvatarSetup.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreenAvatarSetup.swift @@ -347,7 +347,7 @@ extension PeerInfoScreenImpl { let resource = LocalFileMediaResource(fileId: Int64.random(in: Int64.min ... Int64.max)) self.context.account.postbox.mediaBox.storeResourceData(resource.id, data: data) - let representation = TelegramMediaImageRepresentation(dimensions: PixelDimensions(width: 640, height: 640), resource: resource, progressiveSizes: [], immediateThumbnailData: nil, hasVideo: false, isPersonal: mode == .custom ? true : false) + let representation = TelegramMediaImageRepresentation(dimensions: PixelDimensions(width: 640, height: 640), resource: resource, progressiveSizes: [], immediateThumbnailData: nil, hasVideo: false, isPersonal: mode == .custom) if [.suggest, .fallback].contains(mode) { } else { diff --git a/submodules/WebUI/Sources/WebAppController.swift b/submodules/WebUI/Sources/WebAppController.swift index 922679abce..4324257087 100644 --- a/submodules/WebUI/Sources/WebAppController.swift +++ b/submodules/WebUI/Sources/WebAppController.swift @@ -895,8 +895,13 @@ public final class WebAppController: ViewController, AttachmentContainable { if let controller = self.controller { webView.updateMetrics(height: viewportFrame.height, isExpanded: controller.isContainerExpanded(), isStable: !controller.isContainerPanning(), transition: transition) - let contentInsetsData = "{top:\(contentTopInset), bottom:0.0, left:0.0, right:0.0}" - webView.sendEvent(name: "content_safe_area_changed", data: contentInsetsData) + let data: JSON = [ + "top": Double(contentTopInset), + "bottom": 0.0, + "left": 0.0, + "right": 0.0 + ] + webView.sendEvent(name: "content_safe_area_changed", data: data.string) if self.updateWebViewWhenStable && !controller.isContainerPanning() { self.updateWebViewWhenStable = false @@ -1333,7 +1338,7 @@ public final class WebAppController: ViewController, AttachmentContainable { controller.completion = { [weak self] result in if let strongSelf = self { if let result = result { - strongSelf.sendQrCodeScannedEvent(data: result) + strongSelf.sendQrCodeScannedEvent(dataString: result) } else { strongSelf.sendQrCodeScannerClosedEvent() } @@ -1923,8 +1928,11 @@ public final class WebAppController: ViewController, AttachmentContainable { } private func sendInvoiceClosedEvent(slug: String, result: InvoiceCloseResult) { - let paramsString = "{slug: \"\(slug)\", status: \"\(result.string)\"}" - self.webView?.sendEvent(name: "invoice_closed", data: paramsString) + let data: JSON = [ + "slug": slug, + "status": result.string + ] + self.webView?.sendEvent(name: "invoice_closed", data: data.string) } fileprivate func sendBackButtonEvent() { @@ -1936,24 +1944,23 @@ public final class WebAppController: ViewController, AttachmentContainable { } fileprivate func sendAlertButtonEvent(id: String?) { - var paramsString: String? - if let id = id { - paramsString = "{button_id: \"\(id)\"}" + var data: [String: Any] = [:] + if let id { + data["button_id"] = id } - self.webView?.sendEvent(name: "popup_closed", data: paramsString ?? "{}") - } - - fileprivate func sendPhoneRequestedEvent(phone: String?) { - var paramsString: String? - if let phone = phone { - paramsString = "{phone_number: \"\(phone)\"}" + if let serializedData = JSON(dictionary: data)?.string { + self.webView?.sendEvent(name: "popup_closed", data: serializedData) } - self.webView?.sendEvent(name: "phone_requested", data: paramsString) } - - fileprivate func sendQrCodeScannedEvent(data: String?) { - let paramsString = data.flatMap { "{data: \"\($0)\"}" } ?? "{}" - self.webView?.sendEvent(name: "qr_text_received", data: paramsString) + + fileprivate func sendQrCodeScannedEvent(dataString: String?) { + var data: [String: Any] = [:] + if let dataString { + data["data"] = dataString + } + if let serializedData = JSON(dictionary: data)?.string { + self.webView?.sendEvent(name: "qr_text_received", data: serializedData) + } } fileprivate func sendQrCodeScannerClosedEvent() { @@ -1961,14 +1968,15 @@ public final class WebAppController: ViewController, AttachmentContainable { } fileprivate func sendClipboardTextEvent(requestId: String, fillData: Bool) { - var paramsString: String + var data: [String: Any] = [:] + data["req_id"] = requestId if fillData { - let data = UIPasteboard.general.string ?? "" - paramsString = "{req_id: \"\(requestId)\", data: \"\(data)\"}" - } else { - paramsString = "{req_id: \"\(requestId)\"}" + let pasteboardData = UIPasteboard.general.string ?? "" + data["data"] = pasteboardData + } + if let serializedData = JSON(dictionary: data)?.string { + self.webView?.sendEvent(name: "clipboard_text_received", data: serializedData) } - self.webView?.sendEvent(name: "clipboard_text_received", data: paramsString) } fileprivate func requestWriteAccess() { @@ -1977,13 +1985,10 @@ public final class WebAppController: ViewController, AttachmentContainable { } let sendEvent: (Bool) -> Void = { success in - var paramsString: String - if success { - paramsString = "{status: \"allowed\"}" - } else { - paramsString = "{status: \"cancelled\"}" - } - self.webView?.sendEvent(name: "write_access_requested", data: paramsString) + let data: JSON = [ + "status": success ? "allowed" : "cancelled" + ] + self.webView?.sendEvent(name: "write_access_requested", data: data.string) } let _ = (self.context.engine.messages.canBotSendMessages(botId: controller.botId) @@ -2021,13 +2026,10 @@ public final class WebAppController: ViewController, AttachmentContainable { return } let sendEvent: (Bool) -> Void = { success in - var paramsString: String - if success { - paramsString = "{status: \"sent\"}" - } else { - paramsString = "{status: \"cancelled\"}" - } - self.webView?.sendEvent(name: "phone_requested", data: paramsString) + let data: JSON = [ + "status": success ? "sent" : "cancelled" + ] + self.webView?.sendEvent(name: "phone_requested", data: data.string) } let _ = (self.context.engine.data.get( @@ -2348,28 +2350,15 @@ public final class WebAppController: ViewController, AttachmentContainable { state.opaqueToken = encryptedData return state }) - - var data: [String: Any] = [:] - data["status"] = "updated" - - guard let jsonData = try? JSONSerialization.data(withJSONObject: data) else { - return - } - guard let jsonDataString = String(data: jsonData, encoding: .utf8) else { - return - } - self.webView?.sendEvent(name: "biometry_token_updated", data: jsonDataString) + let data: JSON = [ + "status": "updated" + ] + self.webView?.sendEvent(name: "biometry_token_updated", data: data.string) } else { - var data: [String: Any] = [:] - data["status"] = "failed" - - guard let jsonData = try? JSONSerialization.data(withJSONObject: data) else { - return - } - guard let jsonDataString = String(data: jsonData, encoding: .utf8) else { - return - } - self.webView?.sendEvent(name: "biometry_token_updated", data: jsonDataString) + let data: JSON = [ + "status": "failed" + ] + self.webView?.sendEvent(name: "biometry_token_updated", data: data.string) } } }.start() @@ -2379,17 +2368,10 @@ public final class WebAppController: ViewController, AttachmentContainable { state.opaqueToken = nil return state }) - - var data: [String: Any] = [:] - data["status"] = "removed" - - guard let jsonData = try? JSONSerialization.data(withJSONObject: data) else { - return - } - guard let jsonDataString = String(data: jsonData, encoding: .utf8) else { - return - } - self.webView?.sendEvent(name: "biometry_token_updated", data: jsonDataString) + let data: JSON = [ + "status": "removed" + ] + self.webView?.sendEvent(name: "biometry_token_updated", data: data.string) } } @@ -2410,13 +2392,18 @@ public final class WebAppController: ViewController, AttachmentContainable { return } guard controller.isFullscreen != isFullscreen else { - self.webView?.sendEvent(name: "fullscreen_failed", data: "{error: \"ALREADY_FULLSCREEN\"}") + let data: JSON = [ + "error": "ALREADY_FULLSCREEN" + ] + self.webView?.sendEvent(name: "fullscreen_failed", data: data.string) return } - let paramsString = "{is_fullscreen: \( isFullscreen ? "true" : "false" )}" - self.webView?.sendEvent(name: "fullscreen_changed", data: paramsString) - + let data: JSON = [ + "is_fullscreen": isFullscreen + ] + self.webView?.sendEvent(name: "fullscreen_changed", data: data.string) + controller.isFullscreen = isFullscreen if isFullscreen { controller.requestAttachmentMenuExpansion() @@ -2436,7 +2423,10 @@ public final class WebAppController: ViewController, AttachmentContainable { private var isAccelerometerActive = false fileprivate func setIsAccelerometerActive(_ isActive: Bool, refreshRate: Double? = nil) { guard self.motionManager.isAccelerometerAvailable else { - self.webView?.sendEvent(name: "accelerometer_failed", data: "{error: \"UNSUPPORTED\"}") + let data: JSON = [ + "error": "UNSUPPORTED" + ] + self.webView?.sendEvent(name: "accelerometer_failed", data: data.string) return } guard self.isAccelerometerActive != isActive else { @@ -2451,15 +2441,17 @@ public final class WebAppController: ViewController, AttachmentContainable { } else { self.motionManager.accelerometerUpdateInterval = 1.0 } - self.motionManager.startAccelerometerUpdates(to: OperationQueue.main) { [weak self] data, error in - guard let self, let data else { + self.motionManager.startAccelerometerUpdates(to: OperationQueue.main) { [weak self] accelerometerData, error in + guard let self, let accelerometerData else { return } - let gravityConstant = 9.81 - self.webView?.sendEvent( - name: "accelerometer_changed", - data: "{x: \(data.acceleration.x * gravityConstant), y: \(data.acceleration.y * gravityConstant), z: \(data.acceleration.z * gravityConstant)}" - ) + let gravityConstant: Double = 9.81 + let data: JSON = [ + "x": Double(accelerometerData.acceleration.x * gravityConstant), + "y": Double(accelerometerData.acceleration.y * gravityConstant), + "z": Double(accelerometerData.acceleration.z * gravityConstant) + ] + self.webView?.sendEvent(name: "accelerometer_changed", data: data.string) } } else { if self.motionManager.isAccelerometerActive { @@ -2472,7 +2464,10 @@ public final class WebAppController: ViewController, AttachmentContainable { private var isDeviceOrientationActive = false fileprivate func setIsDeviceOrientationActive(_ isActive: Bool, refreshRate: Double? = nil, absolute: Bool = false) { guard self.motionManager.isDeviceMotionAvailable else { - self.webView?.sendEvent(name: "device_orientation_failed", data: "{error: \"UNSUPPORTED\"}") + let data: JSON = [ + "error": "UNSUPPORTED" + ] + self.webView?.sendEvent(name: "device_orientation_failed", data: data.string) return } guard self.isDeviceOrientationActive != isActive else { @@ -2505,25 +2500,29 @@ public final class WebAppController: ViewController, AttachmentContainable { } effectiveIsAbsolute = false } - self.motionManager.startDeviceMotionUpdates(using: referenceFrame, to: OperationQueue.main) { [weak self] data, error in - guard let self, let data else { + self.motionManager.startDeviceMotionUpdates(using: referenceFrame, to: OperationQueue.main) { [weak self] motionData, error in + guard let self, let motionData else { return } var alpha: Double if effectiveIsAbsolute { - alpha = data.heading * .pi / 180.0 + alpha = motionData.heading * .pi / 180.0 if alpha > .pi { alpha -= 2.0 * .pi } else if alpha < -.pi { alpha += 2.0 * .pi } } else { - alpha = data.attitude.yaw + alpha = motionData.attitude.yaw } - self.webView?.sendEvent( - name: "device_orientation_changed", - data: "{absolute: \(effectiveIsAbsolute ? "true" : "false"), alpha: \(alpha), beta: \(data.attitude.pitch), gamma: \(data.attitude.roll)}" - ) + + let data: JSON = [ + "absolute": effectiveIsAbsolute, + "alpha": Double(alpha), + "beta": Double(motionData.attitude.pitch), + "gamma": Double(motionData.attitude.roll) + ] + self.webView?.sendEvent(name: "device_orientation_changed", data: data.string) } } else { if self.motionManager.isDeviceMotionActive { @@ -2536,7 +2535,10 @@ public final class WebAppController: ViewController, AttachmentContainable { private var isGyroscopeActive = false fileprivate func setIsGyroscopeActive(_ isActive: Bool, refreshRate: Double? = nil) { guard self.motionManager.isGyroAvailable else { - self.webView?.sendEvent(name: "gyroscope_failed", data: "{error: \"UNSUPPORTED\"}") + let data: JSON = [ + "error": "UNSUPPORTED" + ] + self.webView?.sendEvent(name: "gyroscope_failed", data: data.string) return } guard self.isGyroscopeActive != isActive else { @@ -2551,14 +2553,16 @@ public final class WebAppController: ViewController, AttachmentContainable { } else { self.motionManager.gyroUpdateInterval = 1.0 } - self.motionManager.startGyroUpdates(to: OperationQueue.main) { [weak self] data, error in - guard let self, let data else { + self.motionManager.startGyroUpdates(to: OperationQueue.main) { [weak self] gyroData, error in + guard let self, let gyroData else { return } - self.webView?.sendEvent( - name: "gyroscope_changed", - data: "{x: \(data.rotationRate.x), y: \(data.rotationRate.y), z: \(data.rotationRate.z)}" - ) + let data: JSON = [ + "x": Double(gyroData.rotationRate.x), + "y": Double(gyroData.rotationRate.y), + "z": Double(gyroData.rotationRate.z) + ] + self.webView?.sendEvent(name: "gyroscope_changed", data: data.string) } } else { if self.motionManager.isGyroActive { @@ -2575,7 +2579,10 @@ public final class WebAppController: ViewController, AttachmentContainable { let _ = (self.context.engine.messages.getPreparedInlineMessage(botId: controller.botId, id: id) |> deliverOnMainQueue).start(next: { [weak self, weak controller] preparedMessage in guard let self, let controller, let preparedMessage else { - self?.webView?.sendEvent(name: "prepared_message_failed", data: "{error: \"MESSAGE_EXPIRED\"}") + let data: JSON = [ + "error": "MESSAGE_EXPIRED" + ] + self?.webView?.sendEvent(name: "prepared_message_failed", data: data.string) return } let previewController = WebAppMessagePreviewScreen(context: controller.context, botName: controller.botName, botAddress: controller.botAddress, preparedMessage: preparedMessage, completion: { [weak self] result in @@ -2585,7 +2592,10 @@ public final class WebAppController: ViewController, AttachmentContainable { if result { self.webView?.sendEvent(name: "prepared_message_sent", data: nil) } else { - self.webView?.sendEvent(name: "prepared_message_failed", data: "{error: \"USER_DECLINED\"}") + let data: JSON = [ + "error": "USER_DECLINED" + ] + self.webView?.sendEvent(name: "prepared_message_failed", data: data.string) } }) previewController.navigationPresentation = .flatModal @@ -2599,7 +2609,10 @@ public final class WebAppController: ViewController, AttachmentContainable { } guard !fileName.contains("/") && fileName.lengthOfBytes(using: .utf8) < 256 && url.lengthOfBytes(using: .utf8) < 32768 else { - self.webView?.sendEvent(name: "file_download_requested", data: "{status: \"cancelled\"}") + let data: JSON = [ + "status": "cancelled" + ] + self.webView?.sendEvent(name: "file_download_requested", data: data.string) return } @@ -2635,7 +2648,10 @@ public final class WebAppController: ViewController, AttachmentContainable { return } guard canDownload else { - self.webView?.sendEvent(name: "file_download_requested", data: "{status: \"cancelled\"}") + let data: JSON = [ + "status": "cancelled" + ] + self.webView?.sendEvent(name: "file_download_requested", data: data.string) return } var fileSizeString = "" @@ -2646,14 +2662,20 @@ public final class WebAppController: ViewController, AttachmentContainable { let text: String = self.presentationData.strings.WebApp_Download_Text(controller.botName, fileName, fileSizeString).string let alertController = standardTextAlertController(theme: AlertControllerTheme(presentationData: self.presentationData), title: title, text: text, actions: [ TextAlertAction(type: .genericAction, title: self.presentationData.strings.Common_Cancel, action: { [weak self] in - self?.webView?.sendEvent(name: "file_download_requested", data: "{status: \"cancelled\"}") + let data: JSON = [ + "status": "cancelled" + ] + self?.webView?.sendEvent(name: "file_download_requested", data: data.string) }), TextAlertAction(type: .defaultAction, title: self.presentationData.strings.WebApp_Download_Download, action: { [weak self] in self?.startDownload(url: url, fileName: fileName, fileSize: fileSize, isMedia: isMedia) }) ], parseMarkdown: true) alertController.dismissed = { [weak self] byOutsideTap in - self?.webView?.sendEvent(name: "file_download_requested", data: "{status: \"cancelled\"}") + let data: JSON = [ + "status": "cancelled" + ] + self?.webView?.sendEvent(name: "file_download_requested", data: data.string) } controller.present(alertController, in: .window(.root)) }) @@ -2664,7 +2686,10 @@ public final class WebAppController: ViewController, AttachmentContainable { guard let controller = self.controller else { return } - self.webView?.sendEvent(name: "file_download_requested", data: "{status: \"downloading\"}") + let data: JSON = [ + "status": "downloading" + ] + self.webView?.sendEvent(name: "file_download_requested", data: data.string) var removeImpl: (() -> Void)? let fileDownload = FileDownload( @@ -2840,13 +2865,20 @@ public final class WebAppController: ViewController, AttachmentContainable { demoController?.replace(with: c) } controller.parentController()?.push(demoController) - self.webView?.sendEvent(name: "emoji_status_access_requested", data: "{status: \"cancelled\"}") + + let data: JSON = [ + "status": "cancelled" + ] + self.webView?.sendEvent(name: "emoji_status_access_requested", data: data.string) return } let _ = (context.engine.peers.toggleBotEmojiStatusAccess(peerId: botId, enabled: true) |> deliverOnMainQueue).startStandalone(completed: { [weak self] in - self?.webView?.sendEvent(name: "emoji_status_access_requested", data: "{status: \"allowed\"}") + let data: JSON = [ + "status": "allowed" + ] + self?.webView?.sendEvent(name: "emoji_status_access_requested", data: data.string) }) if let botPeer { @@ -2865,7 +2897,10 @@ public final class WebAppController: ViewController, AttachmentContainable { controller.present(resultController, in: .window(.root)) } } else { - self.webView?.sendEvent(name: "emoji_status_access_requested", data: "{status: \"cancelled\"}") + let data: JSON = [ + "status": "cancelled" + ] + self.webView?.sendEvent(name: "emoji_status_access_requested", data: data.string) } let _ = updateWebAppPermissionsStateInteractively(context: context, peerId: botId) { current in @@ -2874,7 +2909,10 @@ public final class WebAppController: ViewController, AttachmentContainable { } ) alertController.dismissed = { [weak self] byOutsideTap in - self?.webView?.sendEvent(name: "emoji_status_access_requested", data: "{status: \"cancelled\"}") + let data: JSON = [ + "status": "cancelled" + ] + self?.webView?.sendEvent(name: "emoji_status_access_requested", data: data.string) } controller.present(alertController, in: .window(.root)) }) @@ -2894,7 +2932,10 @@ public final class WebAppController: ViewController, AttachmentContainable { return } guard let file = files[fileId] else { - self.webView?.sendEvent(name: "emoji_status_failed", data: "{error: \"SUGGESTED_EMOJI_INVALID\"}") + let data: JSON = [ + "error": "SUGGESTED_EMOJI_INVALID" + ] + self.webView?.sendEvent(name: "emoji_status_failed", data: data.string) return } let confirmController = WebAppSetEmojiStatusScreen( @@ -2919,7 +2960,11 @@ public final class WebAppController: ViewController, AttachmentContainable { demoController?.replace(with: c) } controller.parentController()?.push(demoController) - self.webView?.sendEvent(name: "emoji_status_failed", data: "{error: \"USER_DECLINED\"}") + + let data: JSON = [ + "error": "USER_DECLINED" + ] + self.webView?.sendEvent(name: "emoji_status_failed", data: data.string) return } @@ -2951,7 +2996,10 @@ public final class WebAppController: ViewController, AttachmentContainable { ) controller.present(resultController, in: .window(.root)) } else { - self.webView?.sendEvent(name: "emoji_status_failed", data: "{error: \"USER_DECLINED\"}") + let data: JSON = [ + "error": "USER_DECLINED" + ] + self.webView?.sendEvent(name: "emoji_status_failed", data: data.string) } } ) @@ -3302,6 +3350,24 @@ public final class WebAppController: ViewController, AttachmentContainable { } } }) + + self.longTapWithTabBar = { [weak self] in + guard let self else { + return + } + + let _ = (context.engine.messages.attachMenuBots() + |> take(1) + |> deliverOnMainQueue).start(next: { [weak self] attachMenuBots in + guard let self else { + return + } + let attachMenuBot = attachMenuBots.first(where: { $0.peer.id == self.botId && !$0.flags.contains(.notActivated) }) + if let _ = attachMenuBot, [.attachMenu, .settings, .generic].contains(self.source) { + self.removeAttachBot() + } + }) + } } required public init(coder aDecoder: NSCoder) { @@ -3561,14 +3627,8 @@ public final class WebAppController: ViewController, AttachmentContainable { }, action: { [weak self] c, _ in c?.dismiss(completion: nil) - if let strongSelf = self { - let presentationData = context.sharedContext.currentPresentationData.with { $0 } - strongSelf.present(textAlertController(context: context, title: presentationData.strings.WebApp_RemoveConfirmationTitle, text: presentationData.strings.WebApp_RemoveAllConfirmationText(strongSelf.botName).string, actions: [TextAlertAction(type: .genericAction, title: presentationData.strings.Common_Cancel, action: {}), TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_OK, action: { [weak self] in - if let strongSelf = self { - let _ = context.engine.messages.removeBotFromAttachMenu(botId: strongSelf.botId).start() - strongSelf.dismiss() - } - })], parseMarkdown: true), in: .window(.root)) + if let self { + self.removeAttachBot() } }))) } @@ -3580,6 +3640,17 @@ public final class WebAppController: ViewController, AttachmentContainable { self.presentInGlobalOverlay(contextController) } + private func removeAttachBot() { + let presentationData = self.context.sharedContext.currentPresentationData.with { $0 } + self.present(textAlertController(context: context, title: presentationData.strings.WebApp_RemoveConfirmationTitle, text: presentationData.strings.WebApp_RemoveAllConfirmationText(self.botName).string, actions: [TextAlertAction(type: .genericAction, title: presentationData.strings.Common_Cancel, action: {}), TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_OK, action: { [weak self] in + guard let self else { + return + } + let _ = self.context.engine.messages.removeBotFromAttachMenu(botId: self.botId).start() + self.dismiss() + })], parseMarkdown: true), in: .window(.root)) + } + override public func loadDisplayNode() { self.displayNode = Node(context: self.context, controller: self) @@ -3660,7 +3731,10 @@ public final class WebAppController: ViewController, AttachmentContainable { self.controllerNode.webView?.setNeedsLayout() } - self.controllerNode.webView?.sendEvent(name: "visibility_changed", data: "{is_visible: \(self.isMinimized ? "false" : "true")}") + let data: JSON = [ + "is_visible": !self.isMinimized, + ] + self.controllerNode.webView?.sendEvent(name: "visibility_changed", data: data.string) } } } From ef20f4229c39b4a77a1f4d40c3ad0bf08f1facd4 Mon Sep 17 00:00:00 2001 From: Ilya Laktyushin Date: Mon, 5 May 2025 19:48:42 +0400 Subject: [PATCH 17/29] Fix multiple stories upload progress --- .../Sources/TelegramEngine/Messages/PendingStoryManager.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Messages/PendingStoryManager.swift b/submodules/TelegramCore/Sources/TelegramEngine/Messages/PendingStoryManager.swift index bec0b9076e..0fa08c6ef1 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Messages/PendingStoryManager.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Messages/PendingStoryManager.swift @@ -469,6 +469,10 @@ final class PendingStoryManager { } }) } else { + if let uploadInfo = pendingItemContext.item.uploadInfo { + let partTotalProgress = 1.0 / Float(uploadInfo.total) + pendingItemContext.progress = Float(uploadInfo.index) * partTotalProgress + } pendingItemContext.disposable = (_internal_uploadStoryImpl(postbox: self.postbox, network: self.network, accountPeerId: self.accountPeerId, stateManager: self.stateManager, messageMediaPreuploadManager: self.messageMediaPreuploadManager, revalidationContext: self.revalidationContext, auxiliaryMethods: self.auxiliaryMethods, toPeerId: toPeerId, stableId: stableId, media: firstItem.media, mediaAreas: firstItem.mediaAreas, text: firstItem.text, entities: firstItem.entities, embeddedStickers: firstItem.embeddedStickers, pin: firstItem.pin, privacy: firstItem.privacy, isForwardingDisabled: firstItem.isForwardingDisabled, period: Int(firstItem.period), randomId: firstItem.randomId, forwardInfo: firstItem.forwardInfo) |> deliverOn(self.queue)).start(next: { [weak self] event in guard let `self` = self else { From 65a0b41071177fed70d72fb018c5f283c4f8ccde Mon Sep 17 00:00:00 2001 From: Isaac <> Date: Mon, 5 May 2025 17:58:19 +0200 Subject: [PATCH 18/29] Build system --- .cursorignore | 1 + .gitignore | 1 + .vscode/settings.json | 3 + build-system/Make/Make.py | 116 ++++++ build-system/bazel-rules/rules_xcodeproj | 2 +- build-system/bazel-utils/spm.bzl | 447 +++++++++++++++++++++++ build-system/generate_spm.py | 193 ++++++++++ 7 files changed, 762 insertions(+), 1 deletion(-) create mode 100644 build-system/bazel-utils/spm.bzl create mode 100644 build-system/generate_spm.py diff --git a/.cursorignore b/.cursorignore index 6f9f00ff49..db9dd609e9 100644 --- a/.cursorignore +++ b/.cursorignore @@ -1 +1,2 @@ # Add directories or file patterns to ignore during indexing (e.g. foo/ or *.csv) +spm-files \ No newline at end of file diff --git a/.gitignore b/.gitignore index a1a1e19e20..4a8af5793f 100644 --- a/.gitignore +++ b/.gitignore @@ -73,3 +73,4 @@ buildServer.json .build/** Telegram.LSP.json **/.build/** +spm-files diff --git a/.vscode/settings.json b/.vscode/settings.json index 3c0e2d31f2..2bb86d2b21 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -11,5 +11,8 @@ }, "search.exclude": { ".git/**": true + }, + "files.associations": { + "memory": "cpp" } } diff --git a/build-system/Make/Make.py b/build-system/Make/Make.py index d1078efef7..67cbb89c3e 100644 --- a/build-system/Make/Make.py +++ b/build-system/Make/Make.py @@ -393,6 +393,58 @@ class BazelCommandLine: print(subprocess.list2cmdline(combined_arguments)) call_executable(combined_arguments) + def get_spm_aspect_invocation(self): + combined_arguments = [ + self.build_environment.bazel_path + ] + combined_arguments += self.get_startup_bazel_arguments() + combined_arguments += ['build'] + + if self.custom_target is not None: + combined_arguments += [self.custom_target] + else: + combined_arguments += ['Telegram/Telegram'] + + if self.continue_on_error: + combined_arguments += ['--keep_going'] + if self.show_actions: + combined_arguments += ['--subcommands'] + + if self.enable_sandbox: + combined_arguments += ['--spawn_strategy=sandboxed'] + + if self.disable_provisioning_profiles: + combined_arguments += ['--//Telegram:disableProvisioningProfiles'] + + if self.configuration_path is None: + raise Exception('configuration_path is not defined') + + combined_arguments += [ + '--override_repository=build_configuration={}'.format(self.configuration_path) + ] + + combined_arguments += self.common_args + combined_arguments += self.common_build_args + combined_arguments += self.get_define_arguments() + combined_arguments += self.get_additional_build_arguments() + + if self.remote_cache is not None: + combined_arguments += [ + '--remote_cache={}'.format(self.remote_cache), + '--experimental_remote_downloader={}'.format(self.remote_cache) + ] + elif self.cache_dir is not None: + combined_arguments += [ + '--disk_cache={path}'.format(path=self.cache_dir) + ] + + combined_arguments += self.configuration_args + + combined_arguments += ['--aspects', '//build-system/bazel-utils:spm.bzl%spm_text_aspect'] + + print(subprocess.list2cmdline(combined_arguments)) + call_executable(combined_arguments) + def clean(bazel, arguments): bazel_command_line = BazelCommandLine( @@ -696,6 +748,36 @@ def query(bazel, arguments): bazel_command_line.invoke_query(query_args) +def get_spm_aspect_invocation(bazel, arguments): + bazel_command_line = BazelCommandLine( + bazel=bazel, + override_bazel_version=arguments.overrideBazelVersion, + override_xcode_version=arguments.overrideXcodeVersion, + bazel_user_root=arguments.bazelUserRoot + ) + + if arguments.cacheDir is not None: + bazel_command_line.add_cache_dir(arguments.cacheDir) + elif arguments.cacheHost is not None: + bazel_command_line.add_remote_cache(arguments.cacheHost) + + resolve_configuration( + base_path=os.getcwd(), + bazel_command_line=bazel_command_line, + arguments=arguments, + additional_codesigning_output_path=None + ) + + bazel_command_line.set_configuration(arguments.configuration) + bazel_command_line.set_build_number(arguments.buildNumber) + bazel_command_line.set_custom_target(arguments.target) + bazel_command_line.set_continue_on_error(False) + bazel_command_line.set_show_actions(False) + bazel_command_line.set_enable_sandbox(False) + bazel_command_line.set_split_swiftmodules(False) + + bazel_command_line.get_spm_aspect_invocation() + def add_codesigning_common_arguments(current_parser: argparse.ArgumentParser): configuration_group = current_parser.add_mutually_exclusive_group(required=True) configuration_group.add_argument( @@ -1121,6 +1203,38 @@ if __name__ == '__main__': metavar='query_string' ) + spm_parser = subparsers.add_parser('spm', help='Generate SPM package') + spm_parser.add_argument( + '--target', + type=str, + help='A custom bazel target name to build.', + metavar='target_name' + ) + spm_parser.add_argument( + '--buildNumber', + required=False, + type=int, + default=10000, + help='Build number.', + metavar='number' + ) + spm_parser.add_argument( + '--configuration', + choices=[ + 'debug_universal', + 'debug_arm64', + 'debug_armv7', + 'debug_sim_arm64', + 'release_sim_arm64', + 'release_arm64', + 'release_armv7', + 'release_universal' + ], + required=True, + help='Build configuration' + ) + add_codesigning_common_arguments(spm_parser) + if len(sys.argv) < 2: parser.print_help() sys.exit(1) @@ -1229,6 +1343,8 @@ if __name__ == '__main__': test(bazel=bazel_path, arguments=args) elif args.commandName == 'query': query(bazel=bazel_path, arguments=args) + elif args.commandName == 'spm': + get_spm_aspect_invocation(bazel=bazel_path, arguments=args) else: raise Exception('Unknown command') except KeyboardInterrupt: diff --git a/build-system/bazel-rules/rules_xcodeproj b/build-system/bazel-rules/rules_xcodeproj index 44b6f046d9..41929acc4c 160000 --- a/build-system/bazel-rules/rules_xcodeproj +++ b/build-system/bazel-rules/rules_xcodeproj @@ -1 +1 @@ -Subproject commit 44b6f046d95b84933c1149fbf7f9d81fd4e32020 +Subproject commit 41929acc4c7c1da973c77871d0375207b9d0806f diff --git a/build-system/bazel-utils/spm.bzl b/build-system/bazel-utils/spm.bzl new file mode 100644 index 0000000000..793d804c2a --- /dev/null +++ b/build-system/bazel-utils/spm.bzl @@ -0,0 +1,447 @@ +load("@build_bazel_rules_swift//swift:swift.bzl", "SwiftInfo") +load("@bazel_skylib//lib:paths.bzl", "paths") +load("@bazel_skylib//lib:dicts.bzl", "dicts") + +# Define provider to propagate data +SPMModulesInfo = provider( + fields = { + "modules": "Dictionary of module information", + "transitive_sources": "Depset of all transitive source files", + } +) + +_IGNORE_CC_LIBRARY_ATTRS = [ + "data", + "applicable_licenses", + "alwayslink", + "aspect_hints", + "compatible_with", + "deprecation", + "exec_compatible_with", + "exec_properties", + "expect_failure", + "features", + "generator_function", + "generator_location", + "generator_name", + "generator_platform", + "generator_script", + "generator_tool", + "generator_toolchain", + "generator_toolchain_type", + "licenses", + "linkstamp", + "linkstatic", + "name", + "restricted_to", + "tags", + "target_compatible_with", + "testonly", + "to_json", + "to_proto", + "toolchains", + "transitive_configs", + "visibility", + "win_def_file", + "linkopts", +] + +_IGNORE_CC_LIBRARY_EMPTY_ATTRS = [ + "additional_compiler_inputs", + "additional_linker_inputs", + "hdrs_check", + "implementation_deps", + "include_prefix", + "strip_include_prefix", + "local_defines", +] + +_CC_LIBRARY_ATTRS = { + "copts": [], + "defines": [], + "deps": [], + "hdrs": [], + "includes": [], + "srcs": [], + "textual_hdrs": [], +} + +_CC_LIBRARY_REQUIRED_ATTRS = { +} + +_IGNORE_OBJC_LIBRARY_ATTRS = [ + "data", + "alwayslink", + "applicable_licenses", + "aspect_hints", + "compatible_with", + "enable_modules", + "exec_compatible_with", + "exec_properties", + "expect_failure", + "features", + "generator_function", + "generator_location", + "generator_name", + "deprecation", + "module_name", + "name", + "stamp", + "tags", + "target_compatible_with", + "testonly", + "to_json", + "to_proto", + "toolchains", + "transitive_configs", + "visibility", +] + +_IGNORE_OBJC_LIBRARY_EMPTY_ATTRS = [ + "implementation_deps", + "linkopts", + "module_map", + "non_arc_srcs", + "pch", + "restricted_to", + "textual_hdrs", + "sdk_includes", +] + +_OBJC_LIBRARY_ATTRS = { + "copts": [], + "defines": [], + "deps": [], + "hdrs": [], + "srcs": [], + "sdk_dylibs": [], + "sdk_frameworks": [], + "weak_sdk_frameworks": [], + "includes": [], +} + +_OBJC_LIBRARY_REQUIRED_ATTRS = [ + "module_name", +] + +_IGNORE_SWIFT_LIBRARY_ATTRS = [ + "data", + "always_include_developer_search_paths", + "alwayslink", + "applicable_licenses", + "aspect_hints", + "compatible_with", + "deprecation", + "exec_compatible_with", + "exec_properties", + "expect_failure", + "features", + "generated_header_name", + "generates_header", + "generator_function", + "generator_location", + "generator_name", + "linkstatic", + "module_name", + "name", + "package_name", + "restricted_to", + "tags", + "target_compatible_with", + "testonly", + "to_json", + "to_proto", + "toolchains", + "transitive_configs", + "visibility", +] + +_IGNORE_SWIFT_LIBRARY_EMPTY_ATTRS = [ + "plugins", + "private_deps", + "swiftc_inputs", +] + +_SWIFT_LIBRARY_ATTRS = { + "copts": [], + "defines": [], + "deps": [], + "linkopts": [], + "srcs": [], +} + +_SWIFT_LIBRARY_REQUIRED_ATTRS = [ + "module_name", +] + +_LIBRARY_CONFIGS = { + "cc_library": { + "ignore_attrs": _IGNORE_CC_LIBRARY_ATTRS, + "ignore_empty_attrs": _IGNORE_CC_LIBRARY_EMPTY_ATTRS, + "handled_attrs": _CC_LIBRARY_ATTRS, + "required_attrs": _CC_LIBRARY_REQUIRED_ATTRS, + }, + "objc_library": { + "ignore_attrs": _IGNORE_OBJC_LIBRARY_ATTRS, + "ignore_empty_attrs": _IGNORE_OBJC_LIBRARY_EMPTY_ATTRS, + "handled_attrs": _OBJC_LIBRARY_ATTRS, + "required_attrs": _OBJC_LIBRARY_REQUIRED_ATTRS, + }, + "swift_library": { + "ignore_attrs": _IGNORE_SWIFT_LIBRARY_ATTRS, + "ignore_empty_attrs": _IGNORE_SWIFT_LIBRARY_EMPTY_ATTRS, + "handled_attrs": _SWIFT_LIBRARY_ATTRS, + "required_attrs": _SWIFT_LIBRARY_REQUIRED_ATTRS, + }, +} + +def get_rule_atts(rule): + if rule.kind in _LIBRARY_CONFIGS: + config = _LIBRARY_CONFIGS[rule.kind] + ignore_attrs = config["ignore_attrs"] + ignore_empty_attrs = config["ignore_empty_attrs"] + handled_attrs = config["handled_attrs"] + required_attrs = config["required_attrs"] + + for attr_name in dir(rule.attr): + if attr_name.startswith("_"): + continue + if attr_name in ignore_attrs: + continue + if attr_name in ignore_empty_attrs: + attr_value = getattr(rule.attr, attr_name) + if attr_value == [] or attr_value == None or attr_value == "": + continue + else: + fail("Attribute {} is not empty: {}".format(attr_name, attr_value)) + if attr_name in handled_attrs: + continue + fail("Unknown attribute: {}".format(attr_name)) + + result = dict() + result["type"] = rule.kind + for attr_name in handled_attrs: + if hasattr(rule.attr, attr_name): + result[attr_name] = getattr(rule.attr, attr_name) + else: + result[attr_name] = handled_attrs[attr_name] # Use default value + for attr_name in required_attrs: + if not hasattr(rule.attr, attr_name): + if rule.kind == "objc_library" and attr_name == "module_name": + result[attr_name] = getattr(rule.attr, "name") + else: + fail("Required attribute {} is missing".format(attr_name)) + else: + result[attr_name] = getattr(rule.attr, attr_name) + result["name"] = getattr(rule.attr, "name") + return result + elif rule.kind == "ios_application": + result = dict() + result["type"] = "ios_application" + return result + elif rule.kind == "generate_spm": + result = dict() + result["type"] = "root" + return result + elif rule.kind == "apple_static_xcframework_import": + result = dict() + result["type"] = "apple_static_xcframework_import" + return result + else: + fail("Unknown rule kind: {}".format(rule.kind)) + +def _collect_spm_modules_impl(target, ctx): + # Skip targets without DefaultInfo + if not DefaultInfo in target: + return [] + + # Get module name + module_name = ctx.label.name + if hasattr(ctx.rule.attr, "module_name"): + module_name = ctx.rule.attr.module_name or ctx.label.name + + # Collect all modules and transitive sources from dependencies first + all_modules = {} + dep_transitive_sources_list = [] + + if hasattr(ctx.rule.attr, "deps"): + for dep in ctx.rule.attr.deps: + if SPMModulesInfo in dep: + # Merge the modules dictionaries + for label, info in dep[SPMModulesInfo].modules.items(): + all_modules[label] = info + # Add transitive sources depset from dependency to the list + dep_transitive_sources_list.append(dep[SPMModulesInfo].transitive_sources) + + # Merge all transitive sources from dependencies + transitive_sources_from_deps = depset(transitive = dep_transitive_sources_list) + + # Keep this for debugging later + # if result_attrs["type"] == "swift_library": + # print("Processing rule {}".format(ctx.label.name)) + # print("ctx.rule.kind = {}".format(ctx.rule.kind)) + # for attr_name in dir(ctx.rule.attr): + # print(" attr1: {}".format(attr_name)) + + result_attrs = get_rule_atts(ctx.rule) + + sources = [] + current_target_src_files = [] + if "srcs" in result_attrs: + for src_target in result_attrs["srcs"]: + src_files = src_target.files.to_list() + for f in src_files: + if f.extension in ["swift", "cc", "cpp", "h", "m", "mm", "s", "S"]: + current_target_src_files.append(f) + for src_file in src_files: + sources.append(src_file.path) + current_target_sources = depset(current_target_src_files) + + headers = [] + current_target_hdr_files = [] + if "hdrs" in result_attrs: + for hdr_target in result_attrs["hdrs"]: + hdr_files = hdr_target.files.to_list() + for f in hdr_files: + current_target_hdr_files.append(f) + for hdr_file in hdr_files: + headers.append(hdr_file.path) + current_target_headers = depset(current_target_hdr_files) + + module_type = result_attrs["type"] + + if module_type == "root": + pass + elif module_type == "apple_static_xcframework_import": + pass + elif module_type == "objc_library" or module_type == "swift_library" or module_type == "cc_library": + # Collect dependency labels + dep_names = [] + if "deps" in result_attrs: + for dep in result_attrs["deps"]: + if hasattr(dep, "label"): + dep_label = str(dep.label) + dep_name = dep_label.split(":")[-1] + dep_names.append(dep_name) + else: + fail("Missing dependency label") + + if module_type == "objc_library" or module_type == "swift_library": + if result_attrs["module_name"] != result_attrs["name"]: + fail("Module name mismatch: {} != {}".format(result_attrs["module_name"], result_attrs["name"])) + + # Extract the path from the label + # Example: @//path/ModuleName:ModuleSubname -> path/ModuleName + if not str(ctx.label).startswith("@//"): + fail("Invalid label: {}".format(ctx.label)) + module_path = str(ctx.label).split(":")[0].split("@//")[1] + + if module_type == "objc_library": + module_info = { + "name": result_attrs["name"], + "type": module_type, + "path": module_path, + "defines": result_attrs["defines"], + "deps": dep_names, + "sources": sorted(sources + headers), + "module_name": module_name, + "copts": result_attrs["copts"], + "sdk_frameworks": result_attrs["sdk_frameworks"], + "sdk_dylibs": result_attrs["sdk_dylibs"], + "weak_sdk_frameworks": result_attrs["weak_sdk_frameworks"], + "includes": result_attrs["includes"], + } + elif module_type == "cc_library": + module_info = { + "name": result_attrs["name"], + "type": module_type, + "path": module_path, + "defines": result_attrs["defines"], + "deps": dep_names, + "sources": sorted(sources + headers), + "module_name": module_name, + "copts": result_attrs["copts"], + "includes": result_attrs["includes"], + } + elif module_type == "swift_library": + module_info = { + "name": result_attrs["name"], + "type": module_type, + "path": module_path, + "deps": dep_names, + "sources": sorted(sources), + "module_name": module_name, + "copts": result_attrs["copts"], + } + else: + fail("Unknown module type: {}".format(module_type)) + + if result_attrs["name"] in all_modules: + fail("Duplicate module name: {}".format(result_attrs["name"])) + all_modules[result_attrs["name"]] = module_info + elif result_attrs["type"] == "ios_application": + pass + else: + fail("Unknown rule type: {}".format(ctx.rule.kind)) + + # Add current target's sources and headers to the transitive set + final_transitive_sources = depset(transitive = [ + transitive_sources_from_deps, + current_target_sources, + current_target_headers, + ]) + + # Return both the SPM output files and the provider with modules data and sources + return [ + SPMModulesInfo( + modules = all_modules, + transitive_sources = final_transitive_sources, + ), + ] + +spm_modules_aspect = aspect( + implementation = _collect_spm_modules_impl, + attr_aspects = ["deps"], +) + +def _generate_spm_impl(ctx): + outputs = [] + dep_transitive_sources_list = [] + + if len(ctx.attr.deps) != 1: + fail("generate_spm must have exactly one dependency") + if SPMModulesInfo not in ctx.attr.deps[0]: + fail("generate_spm must have a dependency with SPMModulesInfo provider") + + spm_info = ctx.attr.deps[0][SPMModulesInfo] + modules = spm_info.modules + + # Declare and write the modules JSON file + modules_json_out = ctx.actions.declare_file("%s_modules.json" % ctx.label.name) + ctx.actions.write( + output = modules_json_out, + content = json.encode_indent(modules, indent = " "), # Use encode_indent for readability + ) + outputs.append(modules_json_out) + + for dep in ctx.attr.deps: + if SPMModulesInfo in dep: + # Add transitive sources depset from dependency + dep_transitive_sources_list.append(dep[SPMModulesInfo].transitive_sources) + + # Merge all transitive sources from dependencies + transitive_sources_from_deps = depset(transitive = dep_transitive_sources_list) + + # Return DefaultInfo containing only the output files in the 'files' field, + # but include the transitive sources in 'runfiles' to enforce the dependency. + return [DefaultInfo( + files = depset(outputs), + runfiles = ctx.runfiles(transitive_files = transitive_sources_from_deps), + )] + +generate_spm = rule( + implementation = _generate_spm_impl, + attrs = { + 'deps' : attr.label_list(aspects = [spm_modules_aspect]), + }, +) diff --git a/build-system/generate_spm.py b/build-system/generate_spm.py new file mode 100644 index 0000000000..fea5dbf31c --- /dev/null +++ b/build-system/generate_spm.py @@ -0,0 +1,193 @@ +#! /usr/bin/env python3 + +import sys +import os +import sys +import json +import shutil + +# Read the modules JSON file +modules_json_path = "bazel-bin/Telegram/spm_build_root_modules.json" + +with open(modules_json_path, 'r') as f: + modules = json.load(f) + +# Clean spm-files +spm_files_dir = "spm-files" +if os.path.exists(spm_files_dir): + shutil.rmtree(spm_files_dir) +if not os.path.exists(spm_files_dir): + os.makedirs(spm_files_dir) + +combined_lines = [] +combined_lines.append("// swift-tools-version: 6.0") +combined_lines.append("// The swift-tools-version declares the minimum version of Swift required to build this package.") +combined_lines.append("") +combined_lines.append("import PackageDescription") +combined_lines.append("") +combined_lines.append("let package = Package(") +combined_lines.append(" name: \"Telegram\",") +combined_lines.append(" platforms: [") +combined_lines.append(" .iOS(.v13)") +combined_lines.append(" ],") +combined_lines.append(" products: [") + +for name, module in sorted(modules.items()): + if module["type"] == "objc_library" or module["type"] == "swift_library" or module["type"] == "cc_library": + combined_lines.append(" .library(name: \"%s\", targets: [\"%s\"])," % (module["name"], module["name"])) + +combined_lines.append(" ],") +combined_lines.append(" targets: [") + +for name, module in sorted(modules.items()): + module_type = module["type"] + if module_type == "objc_library" or module_type == "cc_library" or module_type == "swift_library": + combined_lines.append(" .target(") + combined_lines.append(" name: \"%s\"," % name) + + linked_directory = None + has_non_linked_sources = False + for source in module["sources"]: + if source.startswith("bazel-out/"): + linked_directory = "spm-files/" + name + else: + has_non_linked_sources = True + if linked_directory and has_non_linked_sources: + print("Module {} has both regular and generated sources".format(name)) + sys.exit(1) + if linked_directory: + os.makedirs(linked_directory) + + combined_lines.append(" dependencies: [") + for dep in module["deps"]: + combined_lines.append(" .target(name: \"%s\")," % dep) + combined_lines.append(" ],") + + if linked_directory: + combined_lines.append(" path: \"%s\"," % linked_directory) + else: + combined_lines.append(" path: \"%s\"," % module["path"]) + + combined_lines.append(" exclude: [") + exclude_files_and_dirs = [] + if not linked_directory: + for root, dirs, files in os.walk(module["path"]): + rel_path = os.path.relpath(root, module["path"]) + if rel_path == ".": + rel_path = "" + else: + rel_path += "/" + + # Add directories that should be excluded + for d in dirs: + dir_path = os.path.join(rel_path, d) + if any(component.startswith('.') for component in dir_path.split('/')): + continue + # Check if any source file is under this directory + has_source = False + for source in module["sources"]: + rel_source = source[len(module["path"]) + 1:] + if rel_source.startswith(dir_path + "/"): + has_source = True + break + if not has_source: + exclude_files_and_dirs.append(dir_path) + + # Add files that should be excluded + for f in files: + file_path = os.path.join(rel_path, f) + if any(component.startswith('.') for component in file_path.split('/')): + continue + if file_path not in [source[len(module["path"]) + 1:] for source in module["sources"]]: + exclude_files_and_dirs.append(file_path) + for item in exclude_files_and_dirs: + combined_lines.append(" \"%s\"," % item) + combined_lines.append(" ],") + + combined_lines.append(" sources: [") + for source in module["sources"]: + if source.endswith(('.h', '.hpp')): + continue + linked_source_file_names = [] + if not source.startswith(module["path"]): + if source.startswith("bazel-out/"): + if not linked_directory: + print("Source {} is a generated file, but module {} has no linked directory".format(source, name)) + sys.exit(1) + if module["path"] in source: + source_file_name = source[source.index(module["path"]) + len(module["path"]) + 1:] + else: + print("Source {} is not inside module path {}".format(source, module["path"])) + sys.exit(1) + if source_file_name in linked_source_file_names: + print("Source {} is a duplicate".format(source)) + sys.exit(1) + + linked_source_file_names.append(source_file_name) + + # Create any parent directories needed for the source file + symlink_location = os.path.join(linked_directory, source_file_name) + source_dir = os.path.dirname(symlink_location) + if not os.path.exists(source_dir): + os.makedirs(source_dir) + + # Calculate the relative path from the symlink location back to the workspace root + num_parent_dirs = 2 + source_file_name.count(os.path.sep) + relative_prefix = "".join(["../"] * num_parent_dirs) + symlink_target = relative_prefix + source + + os.symlink(symlink_target, symlink_location) + relative_source = source_file_name + combined_lines.append(" \"%s\"," % relative_source) + else: + print("Source {} is not inside module path {}".format(source, module["path"])) + sys.exit(1) + else: + relative_source = source[len(module["path"]) + 1:] + combined_lines.append(" \"%s\"," % relative_source) + combined_lines.append(" ],") + if module_type == "objc_library" or module_type == "cc_library": + if len(module["includes"]) == 0: + combined_lines.append(" publicHeadersPath: \"\",") + elif len(module["includes"]) == 1: + combined_lines.append(" publicHeadersPath: \"%s\"," % module["includes"][0]) + else: + print("Multiple includes are not supported yet: {}".format(module["includes"])) + sys.exit(1) + combined_lines.append(" cSettings: [") + combined_lines.append(" .unsafeFlags([") + for flag in module["copts"]: + # Escape C-string entities in flag + escaped_flag = flag.replace('\\', '\\\\').replace('"', '\\"') + combined_lines.append(" \"%s\"," % escaped_flag) + combined_lines.append(" ])") + combined_lines.append(" ],") + combined_lines.append(" linkerSettings: [") + if module_type == "objc_library": + for framework in module["sdk_frameworks"]: + combined_lines.append(" .linkedFramework(\"%s\")," % framework) + for dylib in module["sdk_dylibs"]: + combined_lines.append(" .linkedLibrary(\"%s\")," % dylib) + combined_lines.append(" ]") + + elif module_type == "swift_library": + combined_lines.append(" swiftSettings: [") + combined_lines.append(" .swiftLanguageMode(.v5),") + combined_lines.append(" .unsafeFlags([") + for flag in module["copts"]: + combined_lines.append(" \"%s\"," % flag) + combined_lines.append(" ])") + combined_lines.append(" ]") + combined_lines.append(" ),") + elif module["type"] == "root": + pass + else: + print("Unknown module type: {}".format(module["type"])) + sys.exit(1) + +combined_lines.append(" ]") +combined_lines.append(")") +combined_lines.append("") + +with open("Package.swift", "w") as f: + f.write("\n".join(combined_lines)) From 4b90fffb692856a3ffe8f6c9255a9370673c5898 Mon Sep 17 00:00:00 2001 From: Isaac <> Date: Mon, 5 May 2025 18:04:32 +0200 Subject: [PATCH 19/29] Call improvements --- .../BroadcastUploadExtension.swift | 2 +- Tests/CallUITest/Sources/ViewController.swift | 3 +- .../Sources/CallControllerNodeV2.swift | 8 +- .../Sources/PresentationGroupCall.swift | 2 +- .../VideoChatEncryptionKeyComponent.swift | 13 ++ ...pandedParticipantThumbnailsComponent.swift | 2 +- .../VideoChatParticipantVideoComponent.swift | 8 +- .../VideoChatParticipantsComponent.swift | 7 + .../Sources/VideoChatScreen.swift | 16 +- .../Components/PrivateCallVideoLayer.swift | 153 ++++++++++++++---- .../Components/VideoContainerView.swift | 8 +- .../Sources/PrivateCallScreen.swift | 10 +- .../Sources/OngoingCallThreadLocalContext.mm | 1 - 13 files changed, 185 insertions(+), 48 deletions(-) diff --git a/Telegram/BroadcastUpload/BroadcastUploadExtension.swift b/Telegram/BroadcastUpload/BroadcastUploadExtension.swift index 79733d96b9..e2ea000ca3 100644 --- a/Telegram/BroadcastUpload/BroadcastUploadExtension.swift +++ b/Telegram/BroadcastUpload/BroadcastUploadExtension.swift @@ -336,7 +336,7 @@ private final class EmbeddedBroadcastUploadImpl: BroadcastUploadImpl { let logsPath = rootPath + "/logs/broadcast-logs" let _ = try? FileManager.default.createDirectory(atPath: logsPath, withIntermediateDirectories: true, attributes: nil) - let embeddedBroadcastImplementationTypePath = rootPath + "/broadcast-coordination-type" + let embeddedBroadcastImplementationTypePath = rootPath + "/broadcast-coordination-type-v2" var useIPCContext = false if let typeData = try? Data(contentsOf: URL(fileURLWithPath: embeddedBroadcastImplementationTypePath)), let type = String(data: typeData, encoding: .utf8) { diff --git a/Tests/CallUITest/Sources/ViewController.swift b/Tests/CallUITest/Sources/ViewController.swift index 3178bdc66b..40e0e609fc 100644 --- a/Tests/CallUITest/Sources/ViewController.swift +++ b/Tests/CallUITest/Sources/ViewController.swift @@ -35,7 +35,8 @@ public final class ViewController: UIViewController { isRemoteAudioMuted: false, localVideo: nil, remoteVideo: nil, - isRemoteBatteryLow: false + isRemoteBatteryLow: false, + enableVideoSharpening: false ) private var currentLayout: (size: CGSize, insets: UIEdgeInsets)? diff --git a/submodules/TelegramCallsUI/Sources/CallControllerNodeV2.swift b/submodules/TelegramCallsUI/Sources/CallControllerNodeV2.swift index f66ebcf25b..db19e00c36 100644 --- a/submodules/TelegramCallsUI/Sources/CallControllerNodeV2.swift +++ b/submodules/TelegramCallsUI/Sources/CallControllerNodeV2.swift @@ -166,6 +166,11 @@ final class CallControllerNodeV2: ViewControllerTracingNode, CallControllerNodeP } self.conferenceAddParticipant?() } + + var enableVideoSharpening = true + if let data = call.context.currentAppConfiguration.with({ $0 }).data, let value = data["ios_call_video_sharpening"] as? Double { + enableVideoSharpening = value != 0.0 + } self.callScreenState = PrivateCallScreen.State( strings: presentationData.strings, @@ -180,7 +185,8 @@ final class CallControllerNodeV2: ViewControllerTracingNode, CallControllerNodeP remoteVideo: nil, isRemoteBatteryLow: false, isEnergySavingEnabled: !self.sharedContext.energyUsageSettings.fullTranslucency, - isConferencePossible: false + isConferencePossible: false, + enableVideoSharpening: enableVideoSharpening ) self.isMicrophoneMutedDisposable = (call.isMuted diff --git a/submodules/TelegramCallsUI/Sources/PresentationGroupCall.swift b/submodules/TelegramCallsUI/Sources/PresentationGroupCall.swift index 16b967d677..8637bc87fc 100644 --- a/submodules/TelegramCallsUI/Sources/PresentationGroupCall.swift +++ b/submodules/TelegramCallsUI/Sources/PresentationGroupCall.swift @@ -1160,7 +1160,7 @@ public final class PresentationGroupCallImpl: PresentationGroupCall { useIPCContext = value != 0.0 } - let embeddedBroadcastImplementationTypePath = self.accountContext.sharedContext.basePath + "/broadcast-coordination-type" + let embeddedBroadcastImplementationTypePath = self.accountContext.sharedContext.basePath + "/broadcast-coordination-type-v2" let screencastIPCContext: ScreencastIPCContext if useIPCContext { diff --git a/submodules/TelegramCallsUI/Sources/VideoChatEncryptionKeyComponent.swift b/submodules/TelegramCallsUI/Sources/VideoChatEncryptionKeyComponent.swift index 83abd095f9..edea3aeb9b 100644 --- a/submodules/TelegramCallsUI/Sources/VideoChatEncryptionKeyComponent.swift +++ b/submodules/TelegramCallsUI/Sources/VideoChatEncryptionKeyComponent.swift @@ -7,6 +7,7 @@ import BalancedTextComponent import TelegramPresentationData import CallsEmoji import ImageBlur +import HierarchyTrackingLayer private final class EmojiContainerView: UIView { private let maskImageView: UIImageView? @@ -207,6 +208,7 @@ private final class EmojiItemComponent: Component { } final class View: UIView { + private let hierarchyTrackingLayer: HierarchyTrackingLayer private let containerView: EmojiContainerView private let measureEmojiView = ComponentView() private var pendingContainerView: EmojiContainerView? @@ -219,11 +221,22 @@ private final class EmojiItemComponent: Component { private var pendingEmojiValues: [String]? override init(frame: CGRect) { + self.hierarchyTrackingLayer = HierarchyTrackingLayer() self.containerView = EmojiContainerView(hasMask: true) super.init(frame: frame) + self.layer.addSublayer(self.hierarchyTrackingLayer) self.addSubview(self.containerView) + + self.hierarchyTrackingLayer.isInHierarchyUpdated = { [weak self] value in + guard let self else { + return + } + if value { + self.state?.updated(transition: .immediate) + } + } } required init?(coder: NSCoder) { diff --git a/submodules/TelegramCallsUI/Sources/VideoChatExpandedParticipantThumbnailsComponent.swift b/submodules/TelegramCallsUI/Sources/VideoChatExpandedParticipantThumbnailsComponent.swift index 8729fa24a4..600c22dd9a 100644 --- a/submodules/TelegramCallsUI/Sources/VideoChatExpandedParticipantThumbnailsComponent.swift +++ b/submodules/TelegramCallsUI/Sources/VideoChatExpandedParticipantThumbnailsComponent.swift @@ -275,7 +275,7 @@ final class VideoChatParticipantThumbnailComponent: Component { if let current = self.videoLayer { videoLayer = current } else { - videoLayer = PrivateCallVideoLayer() + videoLayer = PrivateCallVideoLayer(enableSharpening: false) self.videoLayer = videoLayer self.extractedContainerView.contentView.layer.insertSublayer(videoLayer.blurredLayer, above: videoBackgroundLayer) self.extractedContainerView.contentView.layer.insertSublayer(videoLayer, above: videoLayer.blurredLayer) diff --git a/submodules/TelegramCallsUI/Sources/VideoChatParticipantVideoComponent.swift b/submodules/TelegramCallsUI/Sources/VideoChatParticipantVideoComponent.swift index 25835f0790..ec362d6601 100644 --- a/submodules/TelegramCallsUI/Sources/VideoChatParticipantVideoComponent.swift +++ b/submodules/TelegramCallsUI/Sources/VideoChatParticipantVideoComponent.swift @@ -51,6 +51,7 @@ final class VideoChatParticipantVideoComponent: Component { let contentInsets: UIEdgeInsets let controlInsets: UIEdgeInsets let interfaceOrientation: UIInterfaceOrientation + let enableVideoSharpening: Bool let action: (() -> Void)? let contextAction: ((EnginePeer, ContextExtractedContentContainingView, ContextGesture) -> Void)? let activatePinch: ((PinchSourceContainerNode) -> Void)? @@ -70,6 +71,7 @@ final class VideoChatParticipantVideoComponent: Component { contentInsets: UIEdgeInsets, controlInsets: UIEdgeInsets, interfaceOrientation: UIInterfaceOrientation, + enableVideoSharpening: Bool, action: (() -> Void)?, contextAction: ((EnginePeer, ContextExtractedContentContainingView, ContextGesture) -> Void)?, activatePinch: ((PinchSourceContainerNode) -> Void)?, @@ -88,6 +90,7 @@ final class VideoChatParticipantVideoComponent: Component { self.contentInsets = contentInsets self.controlInsets = controlInsets self.interfaceOrientation = interfaceOrientation + self.enableVideoSharpening = enableVideoSharpening self.action = action self.contextAction = contextAction self.activatePinch = activatePinch @@ -128,6 +131,9 @@ final class VideoChatParticipantVideoComponent: Component { if lhs.interfaceOrientation != rhs.interfaceOrientation { return false } + if lhs.enableVideoSharpening != rhs.enableVideoSharpening { + return false + } if (lhs.action == nil) != (rhs.action == nil) { return false } @@ -525,7 +531,7 @@ final class VideoChatParticipantVideoComponent: Component { resetVideoSource = true } } else { - videoLayer = PrivateCallVideoLayer() + videoLayer = PrivateCallVideoLayer(enableSharpening: component.enableVideoSharpening) self.videoLayer = videoLayer videoLayer.opacity = 0.0 self.pinchContainerNode.contentNode.view.layer.insertSublayer(videoLayer.blurredLayer, above: videoBackgroundLayer) diff --git a/submodules/TelegramCallsUI/Sources/VideoChatParticipantsComponent.swift b/submodules/TelegramCallsUI/Sources/VideoChatParticipantsComponent.swift index 54bc0d4a64..7a3eb86cd7 100644 --- a/submodules/TelegramCallsUI/Sources/VideoChatParticipantsComponent.swift +++ b/submodules/TelegramCallsUI/Sources/VideoChatParticipantsComponent.swift @@ -152,6 +152,7 @@ final class VideoChatParticipantsComponent: Component { let expandedInsets: UIEdgeInsets let safeInsets: UIEdgeInsets let interfaceOrientation: UIInterfaceOrientation + let enableVideoSharpening: Bool let openParticipantContextMenu: (EnginePeer.Id, ContextExtractedContentContainingView, ContextGesture?) -> Void let openInvitedParticipantContextMenu: (EnginePeer.Id, ContextExtractedContentContainingView, ContextGesture?) -> Void let updateMainParticipant: (VideoParticipantKey?, Bool?) -> Void @@ -173,6 +174,7 @@ final class VideoChatParticipantsComponent: Component { expandedInsets: UIEdgeInsets, safeInsets: UIEdgeInsets, interfaceOrientation: UIInterfaceOrientation, + enableVideoSharpening: Bool, openParticipantContextMenu: @escaping (EnginePeer.Id, ContextExtractedContentContainingView, ContextGesture?) -> Void, openInvitedParticipantContextMenu: @escaping (EnginePeer.Id, ContextExtractedContentContainingView, ContextGesture?) -> Void, updateMainParticipant: @escaping (VideoParticipantKey?, Bool?) -> Void, @@ -193,6 +195,7 @@ final class VideoChatParticipantsComponent: Component { self.expandedInsets = expandedInsets self.safeInsets = safeInsets self.interfaceOrientation = interfaceOrientation + self.enableVideoSharpening = enableVideoSharpening self.openParticipantContextMenu = openParticipantContextMenu self.openInvitedParticipantContextMenu = openInvitedParticipantContextMenu self.updateMainParticipant = updateMainParticipant @@ -239,6 +242,9 @@ final class VideoChatParticipantsComponent: Component { if lhs.interfaceOrientation != rhs.interfaceOrientation { return false } + if lhs.enableVideoSharpening != rhs.enableVideoSharpening { + return false + } return true } @@ -1074,6 +1080,7 @@ final class VideoChatParticipantsComponent: Component { contentInsets: itemContentInsets, controlInsets: itemControlInsets, interfaceOrientation: component.interfaceOrientation, + enableVideoSharpening: component.enableVideoSharpening, action: { [weak self] in guard let self, let component = self.component else { return diff --git a/submodules/TelegramCallsUI/Sources/VideoChatScreen.swift b/submodules/TelegramCallsUI/Sources/VideoChatScreen.swift index 3f2216a746..d65a9c0fdd 100644 --- a/submodules/TelegramCallsUI/Sources/VideoChatScreen.swift +++ b/submodules/TelegramCallsUI/Sources/VideoChatScreen.swift @@ -234,6 +234,8 @@ final class VideoChatScreenComponent: Component { let participants = ComponentView() var scheduleInfo: ComponentView? + + var enableVideoSharpening: Bool = false var reconnectedAsEventsDisposable: Disposable? var memberEventsDisposable: Disposable? @@ -1244,6 +1246,11 @@ final class VideoChatScreenComponent: Component { self.invitedPeers.removeAll(where: { invitedPeer in members.participants.contains(where: { $0.id == .peer(invitedPeer.peer.id) }) }) } self.callState = component.initialData.callState + + self.enableVideoSharpening = true + if let data = component.initialCall.accountContext.currentAppConfiguration.with({ $0 }).data, let value = data["ios_call_video_sharpening"] as? Double { + self.enableVideoSharpening = value != 0.0 + } } var call: VideoChatCall @@ -1359,7 +1366,7 @@ final class VideoChatScreenComponent: Component { return false } if participant.videoDescription != nil || participant.presentationDescription != nil { - if let participantPeer = participant.peer, members.speakingParticipants.contains(participantPeer.id) { + if let participantPeer = participant.peer, participantPeer.id != groupCall.accountContext.account.peerId, members.speakingParticipants.contains(participantPeer.id) { return true } } @@ -1421,7 +1428,7 @@ final class VideoChatScreenComponent: Component { var speakingParticipantPeers: [EnginePeer] = [] if let members, !members.speakingParticipants.isEmpty { for participant in members.participants { - if let participantPeer = participant.peer, members.speakingParticipants.contains(participantPeer.id) { + if let participantPeer = participant.peer, participantPeer.id != groupCall.accountContext.account.peerId, members.speakingParticipants.contains(participantPeer.id) { speakingParticipantPeers.append(participantPeer) } } @@ -1698,7 +1705,7 @@ final class VideoChatScreenComponent: Component { return false } if participant.videoDescription != nil || participant.presentationDescription != nil { - if let participantPeer = participant.peer, members.speakingParticipants.contains(participantPeer.id) { + if let participantPeer = participant.peer, participantPeer.id != conferenceSource.context.account.peerId, members.speakingParticipants.contains(participantPeer.id) { return true } } @@ -1760,7 +1767,7 @@ final class VideoChatScreenComponent: Component { var speakingParticipantPeers: [EnginePeer] = [] if !members.speakingParticipants.isEmpty { for participant in members.participants { - if let participantPeer = participant.peer, members.speakingParticipants.contains(participantPeer.id) { + if let participantPeer = participant.peer, participantPeer.id != conferenceSource.context.account.peerId, members.speakingParticipants.contains(participantPeer.id) { speakingParticipantPeers.append(participantPeer) } } @@ -2501,6 +2508,7 @@ final class VideoChatScreenComponent: Component { expandedInsets: participantsExpandedInsets, safeInsets: participantsSafeInsets, interfaceOrientation: environment.orientation ?? .portrait, + enableVideoSharpening: self.enableVideoSharpening, openParticipantContextMenu: { [weak self] id, sourceView, gesture in guard let self else { return diff --git a/submodules/TelegramUI/Components/Calls/CallScreen/Sources/Components/PrivateCallVideoLayer.swift b/submodules/TelegramUI/Components/Calls/CallScreen/Sources/Components/PrivateCallVideoLayer.swift index 7a4cce7027..5a954acc94 100644 --- a/submodules/TelegramUI/Components/Calls/CallScreen/Sources/Components/PrivateCallVideoLayer.swift +++ b/submodules/TelegramUI/Components/Calls/CallScreen/Sources/Components/PrivateCallVideoLayer.swift @@ -5,6 +5,21 @@ import MetalPerformanceShaders import Accelerate import MetalEngine +private func makeSharpenKernel(device: MTLDevice, sharpeningStrength: Float) -> MPSImageConvolution { + let centerWeight = 1.0 + 6.0 * sharpeningStrength + let adjacentWeight = -1.0 * sharpeningStrength + let diagonalWeight = -0.5 * sharpeningStrength + + let sharpenWeights: [Float] = [ + diagonalWeight, adjacentWeight, diagonalWeight, + adjacentWeight, centerWeight, adjacentWeight, + diagonalWeight, adjacentWeight, diagonalWeight + ] + let result = MPSImageConvolution(device: device, kernelWidth: 3, kernelHeight: 3, weights: sharpenWeights) + result.edgeMode = .clamp + return result +} + public final class PrivateCallVideoLayer: MetalEngineSubjectLayer, MetalEngineSubject { public var internalData: MetalEngineSubjectInternalData? @@ -16,6 +31,9 @@ public final class PrivateCallVideoLayer: MetalEngineSubjectLayer, MetalEngineSu let computePipelineStateHorizontal: MTLComputePipelineState let computePipelineStateVertical: MTLComputePipelineState let downscaleKernel: MPSImageBilinearScale + + var sharpeningStrength: Float = 0.0 + var sharpenKernel: MPSImageConvolution required init?(device: MTLDevice) { guard let library = metalLibrary(device: device) else { @@ -52,6 +70,14 @@ public final class PrivateCallVideoLayer: MetalEngineSubjectLayer, MetalEngineSu self.computePipelineStateVertical = computePipelineStateVertical self.downscaleKernel = MPSImageBilinearScale(device: device) + + self.sharpeningStrength = 1.4 + self.sharpenKernel = makeSharpenKernel(device: device, sharpeningStrength: self.sharpeningStrength) + } + + func updateSharpeningStrength(device: MTLDevice, sharpeningStrength: Float) { + self.sharpeningStrength = sharpeningStrength + self.sharpenKernel = makeSharpenKernel(device: device, sharpeningStrength: self.sharpeningStrength) } } @@ -82,21 +108,26 @@ public final class PrivateCallVideoLayer: MetalEngineSubjectLayer, MetalEngineSu self.setNeedsUpdate() } } + + private let enableSharpening: Bool public var renderSpec: RenderLayerSpec? private var rgbaTexture: PooledTexture? + private var sharpenedTexture: PooledTexture? private var downscaledTexture: PooledTexture? private var blurredHorizontalTexture: PooledTexture? private var blurredVerticalTexture: PooledTexture? - override public init() { + public init(enableSharpening: Bool) { + self.enableSharpening = enableSharpening self.blurredLayer = MetalEngineSubjectLayer() super.init() } override public init(layer: Any) { + self.enableSharpening = false self.blurredLayer = MetalEngineSubjectLayer() super.init(layer: layer) @@ -121,6 +152,9 @@ public final class PrivateCallVideoLayer: MetalEngineSubjectLayer, MetalEngineSu if self.rgbaTexture == nil || self.rgbaTexture?.spec != rgbaTextureSpec { self.rgbaTexture = MetalEngine.shared.pooledTexture(spec: rgbaTextureSpec) } + if self.sharpenedTexture == nil || self.sharpenedTexture?.spec != rgbaTextureSpec { + self.sharpenedTexture = MetalEngine.shared.pooledTexture(spec: rgbaTextureSpec) + } if self.downscaledTexture == nil { self.downscaledTexture = MetalEngine.shared.pooledTexture(spec: TextureSpec(width: 128, height: 128, pixelFormat: .rgba8UnsignedNormalized)) } @@ -134,35 +168,90 @@ public final class PrivateCallVideoLayer: MetalEngineSubjectLayer, MetalEngineSu guard let rgbaTexture = self.rgbaTexture?.get(context: context) else { return } + + var outputTexture = rgbaTexture + + var sharpenedTexture: TexturePlaceholder? + if self.enableSharpening && rgbaTextureSpec.width * rgbaTextureSpec.height >= 800 * 480 { + sharpenedTexture = self.sharpenedTexture?.get(context: context) + if let sharpenedTexture { + outputTexture = sharpenedTexture + } + } - let _ = context.compute(state: BlurState.self, inputs: rgbaTexture.placeholer, commands: { commandBuffer, blurState, rgbaTexture in - guard let rgbaTexture else { - return - } - guard let computeEncoder = commandBuffer.makeComputeCommandEncoder() else { - return - } - - let threadgroupSize = MTLSize(width: 16, height: 16, depth: 1) - let threadgroupCount = MTLSize(width: (rgbaTexture.width + threadgroupSize.width - 1) / threadgroupSize.width, height: (rgbaTexture.height + threadgroupSize.height - 1) / threadgroupSize.height, depth: 1) - - switch videoTextures.textureLayout { - case let .biPlanar(biPlanar): - computeEncoder.setComputePipelineState(blurState.computePipelineStateYUVBiPlanarToRGBA) - computeEncoder.setTexture(biPlanar.y, index: 0) - computeEncoder.setTexture(biPlanar.uv, index: 1) - computeEncoder.setTexture(rgbaTexture, index: 2) - case let .triPlanar(triPlanar): - computeEncoder.setComputePipelineState(blurState.computePipelineStateYUVTriPlanarToRGBA) - computeEncoder.setTexture(triPlanar.y, index: 0) - computeEncoder.setTexture(triPlanar.u, index: 1) - computeEncoder.setTexture(triPlanar.u, index: 2) - computeEncoder.setTexture(rgbaTexture, index: 3) - } - computeEncoder.dispatchThreadgroups(threadgroupCount, threadsPerThreadgroup: threadgroupSize) - - computeEncoder.endEncoding() - }) + if let sharpenedTexture { + let _ = context.compute(state: BlurState.self, inputs: rgbaTexture.placeholer, sharpenedTexture.placeholer, commands: { commandBuffer, blurState, rgbaTexture, sharpenedTexture in + guard let rgbaTexture else { + return + } + guard let sharpenedTexture else { + return + } + + do { + guard let computeEncoder = commandBuffer.makeComputeCommandEncoder() else { + return + } + + let threadgroupSize = MTLSize(width: 16, height: 16, depth: 1) + let threadgroupCount = MTLSize(width: (rgbaTexture.width + threadgroupSize.width - 1) / threadgroupSize.width, height: (rgbaTexture.height + threadgroupSize.height - 1) / threadgroupSize.height, depth: 1) + + switch videoTextures.textureLayout { + case let .biPlanar(biPlanar): + computeEncoder.setComputePipelineState(blurState.computePipelineStateYUVBiPlanarToRGBA) + computeEncoder.setTexture(biPlanar.y, index: 0) + computeEncoder.setTexture(biPlanar.uv, index: 1) + computeEncoder.setTexture(rgbaTexture, index: 2) + case let .triPlanar(triPlanar): + computeEncoder.setComputePipelineState(blurState.computePipelineStateYUVTriPlanarToRGBA) + computeEncoder.setTexture(triPlanar.y, index: 0) + computeEncoder.setTexture(triPlanar.u, index: 1) + computeEncoder.setTexture(triPlanar.u, index: 2) + computeEncoder.setTexture(rgbaTexture, index: 3) + } + computeEncoder.dispatchThreadgroups(threadgroupCount, threadsPerThreadgroup: threadgroupSize) + + computeEncoder.endEncoding() + } + + do { + + blurState.sharpenKernel.encode(commandBuffer: commandBuffer, sourceTexture: rgbaTexture, destinationTexture: sharpenedTexture) + } + }) + } else { + let _ = context.compute(state: BlurState.self, inputs: rgbaTexture.placeholer, commands: { commandBuffer, blurState, rgbaTexture in + guard let rgbaTexture else { + return + } + + do { + guard let computeEncoder = commandBuffer.makeComputeCommandEncoder() else { + return + } + + let threadgroupSize = MTLSize(width: 16, height: 16, depth: 1) + let threadgroupCount = MTLSize(width: (rgbaTexture.width + threadgroupSize.width - 1) / threadgroupSize.width, height: (rgbaTexture.height + threadgroupSize.height - 1) / threadgroupSize.height, depth: 1) + + switch videoTextures.textureLayout { + case let .biPlanar(biPlanar): + computeEncoder.setComputePipelineState(blurState.computePipelineStateYUVBiPlanarToRGBA) + computeEncoder.setTexture(biPlanar.y, index: 0) + computeEncoder.setTexture(biPlanar.uv, index: 1) + computeEncoder.setTexture(rgbaTexture, index: 2) + case let .triPlanar(triPlanar): + computeEncoder.setComputePipelineState(blurState.computePipelineStateYUVTriPlanarToRGBA) + computeEncoder.setTexture(triPlanar.y, index: 0) + computeEncoder.setTexture(triPlanar.u, index: 1) + computeEncoder.setTexture(triPlanar.u, index: 2) + computeEncoder.setTexture(rgbaTexture, index: 3) + } + computeEncoder.dispatchThreadgroups(threadgroupCount, threadsPerThreadgroup: threadgroupSize) + + computeEncoder.endEncoding() + } + }) + } if !self.blurredLayer.isHidden { guard let downscaledTexture = self.downscaledTexture?.get(context: context), let blurredHorizontalTexture = self.blurredHorizontalTexture?.get(context: context), let blurredVerticalTexture = self.blurredVerticalTexture?.get(context: context) else { @@ -228,8 +317,8 @@ public final class PrivateCallVideoLayer: MetalEngineSubjectLayer, MetalEngineSu }) } - context.renderToLayer(spec: renderSpec, state: RenderState.self, layer: self, inputs: rgbaTexture.placeholer, commands: { encoder, placement, rgbaTexture in - guard let rgbaTexture else { + context.renderToLayer(spec: renderSpec, state: RenderState.self, layer: self, inputs: outputTexture.placeholer, commands: { encoder, placement, outputTexture in + guard let outputTexture else { return } @@ -244,7 +333,7 @@ public final class PrivateCallVideoLayer: MetalEngineSubjectLayer, MetalEngineSu ) encoder.setVertexBytes(&mirror, length: 2 * 4, index: 1) - encoder.setFragmentTexture(rgbaTexture, index: 0) + encoder.setFragmentTexture(outputTexture, index: 0) var brightness: Float = 1.0 var saturation: Float = 1.0 diff --git a/submodules/TelegramUI/Components/Calls/CallScreen/Sources/Components/VideoContainerView.swift b/submodules/TelegramUI/Components/Calls/CallScreen/Sources/Components/VideoContainerView.swift index 4eb3db1ff5..f444cf3182 100644 --- a/submodules/TelegramUI/Components/Calls/CallScreen/Sources/Components/VideoContainerView.swift +++ b/submodules/TelegramUI/Components/Calls/CallScreen/Sources/Components/VideoContainerView.swift @@ -128,6 +128,7 @@ final class VideoContainerView: HighlightTrackingButton { } let key: Key + let enableSharpening: Bool let videoContainerLayer: VideoContainerLayer var videoContainerLayerTaken: Bool = false @@ -211,8 +212,9 @@ final class VideoContainerView: HighlightTrackingButton { var pressAction: (() -> Void)? - init(key: Key) { + init(key: Key, enableSharpening: Bool) { self.key = key + self.enableSharpening = enableSharpening self.videoContainerLayer = VideoContainerLayer() self.videoContainerLayer.backgroundColor = nil @@ -223,7 +225,7 @@ final class VideoContainerView: HighlightTrackingButton { self.videoContainerLayer.contentsLayer.cornerCurve = .circular } - self.videoLayer = PrivateCallVideoLayer() + self.videoLayer = PrivateCallVideoLayer(enableSharpening: self.enableSharpening) self.videoLayer.masksToBounds = true self.videoLayer.isDoubleSided = false if #available(iOS 13.0, *) { @@ -454,7 +456,7 @@ final class VideoContainerView: HighlightTrackingButton { let previousVideoLayer = self.videoLayer self.disappearingVideoLayer = DisappearingVideo(flipAnimationInfo: flipAnimationInfo, videoLayer: self.videoLayer, videoMetrics: videoMetrics) - self.videoLayer = PrivateCallVideoLayer() + self.videoLayer = PrivateCallVideoLayer(enableSharpening: self.enableSharpening) self.videoLayer.opacity = previousVideoLayer.opacity self.videoLayer.masksToBounds = true self.videoLayer.isDoubleSided = false diff --git a/submodules/TelegramUI/Components/Calls/CallScreen/Sources/PrivateCallScreen.swift b/submodules/TelegramUI/Components/Calls/CallScreen/Sources/PrivateCallScreen.swift index 3927200df2..86d49a667c 100644 --- a/submodules/TelegramUI/Components/Calls/CallScreen/Sources/PrivateCallScreen.swift +++ b/submodules/TelegramUI/Components/Calls/CallScreen/Sources/PrivateCallScreen.swift @@ -81,6 +81,7 @@ public final class PrivateCallScreen: OverlayMaskContainerView, AVPictureInPictu public var isRemoteBatteryLow: Bool public var isEnergySavingEnabled: Bool public var isConferencePossible: Bool + public var enableVideoSharpening: Bool public init( strings: PresentationStrings, @@ -95,7 +96,8 @@ public final class PrivateCallScreen: OverlayMaskContainerView, AVPictureInPictu remoteVideo: VideoSource?, isRemoteBatteryLow: Bool, isEnergySavingEnabled: Bool, - isConferencePossible: Bool + isConferencePossible: Bool, + enableVideoSharpening: Bool ) { self.strings = strings self.lifecycleState = lifecycleState @@ -110,6 +112,7 @@ public final class PrivateCallScreen: OverlayMaskContainerView, AVPictureInPictu self.isRemoteBatteryLow = isRemoteBatteryLow self.isEnergySavingEnabled = isEnergySavingEnabled self.isConferencePossible = isConferencePossible + self.enableVideoSharpening = enableVideoSharpening } public static func ==(lhs: State, rhs: State) -> Bool { @@ -152,6 +155,9 @@ public final class PrivateCallScreen: OverlayMaskContainerView, AVPictureInPictu if lhs.isConferencePossible != rhs.isConferencePossible { return false } + if lhs.enableVideoSharpening != rhs.enableVideoSharpening { + return false + } return true } } @@ -994,7 +1000,7 @@ public final class PrivateCallScreen: OverlayMaskContainerView, AVPictureInPictu videoContainerView = current } else { animateIn = true - videoContainerView = VideoContainerView(key: videoContainerKey) + videoContainerView = VideoContainerView(key: videoContainerKey, enableSharpening: params.state.enableVideoSharpening) switch videoContainerKey { case .foreground: self.overlayContentsView.layer.addSublayer(videoContainerView.blurredContainerLayer) diff --git a/submodules/TgVoipWebrtc/Sources/OngoingCallThreadLocalContext.mm b/submodules/TgVoipWebrtc/Sources/OngoingCallThreadLocalContext.mm index 00025f77a2..1ceee49216 100644 --- a/submodules/TgVoipWebrtc/Sources/OngoingCallThreadLocalContext.mm +++ b/submodules/TgVoipWebrtc/Sources/OngoingCallThreadLocalContext.mm @@ -1631,7 +1631,6 @@ static void (*InternalVoipLoggingFunction)(NSString *) = NULL; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ tgcalls::Register(); - //tgcalls::Register(); tgcalls::Register(); tgcalls::Register(); }); From 241c3832fa65179c0ddf5a2596e21f92dd72ecfa Mon Sep 17 00:00:00 2001 From: Isaac <> Date: Mon, 5 May 2025 18:04:44 +0200 Subject: [PATCH 20/29] Cleanup --- Telegram/BUILD | 69 ++++++++++++++++++++++++++++++++------------------ 1 file changed, 44 insertions(+), 25 deletions(-) diff --git a/Telegram/BUILD b/Telegram/BUILD index 04eb005bdd..f2f2f00f06 100644 --- a/Telegram/BUILD +++ b/Telegram/BUILD @@ -55,6 +55,10 @@ load("@build_bazel_rules_apple//apple:resources.bzl", "swift_intent_library", ) +load("//build-system/bazel-utils:spm.bzl", + "generate_spm", +) + config_setting( name = "debug", values = { @@ -952,29 +956,6 @@ plist_fragment( ) ) -ios_framework( - name = "TelegramApiFramework", - bundle_id = "{telegram_bundle_id}.TelegramApi".format( - telegram_bundle_id = telegram_bundle_id, - ), - families = [ - "iphone", - "ipad", - ], - infoplists = [ - ":TelegramApiInfoPlist", - ":BuildNumberInfoPlist", - ":VersionInfoPlist", - ":RequiredDeviceCapabilitiesPlist", - ], - minimum_os_version = minimum_os_version, - extension_safe = True, - ipa_post_processor = strip_framework, - deps = [ - "//submodules/TelegramApi:TelegramApi", - ], -) - plist_fragment( name = "TelegramCoreInfoPlist", extension = "plist", @@ -2022,7 +2003,45 @@ xcodeproj( default_xcode_configuration = "Debug" ) -# Temporary targets used to simplify webrtc build tests +# Temporary targets used to simplify build tests + +ios_application( + name = "spm_build_app", + bundle_id = "{telegram_bundle_id}".format( + telegram_bundle_id = telegram_bundle_id, + ), + families = ["iphone", "ipad"], + minimum_os_version = minimum_os_version, + provisioning_profile = select({ + ":disableProvisioningProfilesSetting": None, + "//conditions:default": "@build_configuration//provisioning:Telegram.mobileprovision", + }), + entitlements = ":TelegramEntitlements.entitlements", + infoplists = [ + ":TelegramInfoPlist", + ":BuildNumberInfoPlist", + ":VersionInfoPlist", + ":RequiredDeviceCapabilitiesPlist", + ":UrlTypesInfoPlist", + ], + deps = [ + #"//submodules/MtProtoKit", + #"//submodules/SSignalKit/SwiftSignalKit", + #"//submodules/Postbox", + #"//submodules/TelegramApi", + #"//submodules/TelegramCore", + #"//submodules/FFMpegBinding", + "//submodules/Display", + #"//third-party/webrtc", + ], +) + +generate_spm( + name = "spm_build_root", + deps = [ + ":spm_build_app", + ] +) ios_application( name = "webrtc_build_test", @@ -2044,7 +2063,7 @@ ios_application( ":UrlTypesInfoPlist", ], deps = [ - "//third-party/webrtc:webrtc_lib", + "//third-party/webrtc:webrtc", ], ) From 79eb359587df6f159ff2b52ff715cc3294751180 Mon Sep 17 00:00:00 2001 From: Ilya Laktyushin Date: Mon, 5 May 2025 20:08:50 +0400 Subject: [PATCH 21/29] Various fixes --- .../Sources/BotCheckoutControllerNode.swift | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/submodules/BotPaymentsUI/Sources/BotCheckoutControllerNode.swift b/submodules/BotPaymentsUI/Sources/BotCheckoutControllerNode.swift index ae27df6f66..191f9b27c8 100644 --- a/submodules/BotPaymentsUI/Sources/BotCheckoutControllerNode.swift +++ b/submodules/BotPaymentsUI/Sources/BotCheckoutControllerNode.swift @@ -611,7 +611,7 @@ private final class ActionButtonPanelNode: ASDisplayNode { private(set) var isAccepted: Bool = false var isAcceptedUpdated: (() -> Void)? var openRecurrentTerms: (() -> Void)? - private var recurrentConfirmationNode: RecurrentConfirmationNode? + var recurrentConfirmationNode: RecurrentConfirmationNode? func update(presentationData: PresentationData, layout: ContainerViewLayout, invoice: BotPaymentInvoice?, botName: String?) -> (CGFloat, CGFloat) { let bottomPanelVerticalInset: CGFloat = 16.0 @@ -1211,7 +1211,8 @@ final class BotCheckoutControllerNode: ItemListControllerNode, PKPaymentAuthoriz payString = self.presentationData.strings.CheckoutInfo_Pay } - self.actionButton.isEnabled = isButtonEnabled + self.actionButton.isEnabled = true + self.actionButton.isImplicitlyDisabled = !isButtonEnabled if let currentPaymentMethod = self.currentPaymentMethod { switch currentPaymentMethod { @@ -1268,7 +1269,11 @@ final class BotCheckoutControllerNode: ItemListControllerNode, PKPaymentAuthoriz } @objc func actionButtonPressed() { - self.pay() + if let recurrentConfirmationNode = self.actionButtonPanelNode.recurrentConfirmationNode, !self.actionButtonPanelNode.isAccepted { + recurrentConfirmationNode.layer.addShakeAnimation() + } else { + self.pay() + } } private func pay(savedCredentialsToken: TemporaryTwoStepPasswordToken? = nil, liabilityNoticeAccepted: Bool = false, receivedCredentials: BotPaymentCredentials? = nil) { From 17df575f541987891574fd58b068e839e52df12b Mon Sep 17 00:00:00 2001 From: Isaac <> Date: Mon, 5 May 2025 22:20:06 +0200 Subject: [PATCH 22/29] Revert "Fix typo and crash" This reverts commit e060b91947a0b3bbb6109bccfd87f202503ceb6f. --- .../Sources/StoryItemContentComponent.swift | 2 +- .../Sources/StoryItemSetContainerComponent.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemContentComponent.swift b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemContentComponent.swift index 513d067018..e50d333e51 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemContentComponent.swift +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemContentComponent.swift @@ -466,7 +466,7 @@ final class StoryItemContentComponent: Component { return } - var useLegacyImplementation = true + var useLegacyImplementation = false if let data = component.context.currentAppConfiguration.with({ $0 }).data, let value = data["ios_video_legacystoryplayer"] as? Double { useLegacyImplementation = value != 0.0 } diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift index f88ddc8fd8..27b69c1316 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift @@ -1875,7 +1875,7 @@ public final class StoryItemSetContainerComponent: Component { continue } var nextVisibleItem: VisibleItem? - if i != component.slice.allItems.count - 1 { + if i != component.slice.allItems.count { nextVisibleItem = self.visibleItems[component.slice.allItems[i + 1].id] } if let itemView = visibleItem.view.view as? StoryItemContentComponent.View { From 631f942a7f66d7670d03edbb7238c7310d48031e Mon Sep 17 00:00:00 2001 From: Isaac <> Date: Mon, 5 May 2025 22:20:22 +0200 Subject: [PATCH 23/29] Revert "Improve story video playback" This reverts commit d4443f9682bfa5ba72b1d311dd2862c07a8857e6. --- .../Sources/ChunkMediaPlayerV2.swift | 184 +++---- .../Components/LivestreamVideoViewV1.swift | 2 +- .../Sources/StoryContent.swift | 2 - .../Sources/StoryItemContentComponent.swift | 452 ++++-------------- .../StoryItemSetContainerComponent.swift | 15 +- .../Sources/HLSVideoJSNativeContentNode.swift | 2 +- .../Sources/NativeVideoContent.swift | 2 +- 7 files changed, 144 insertions(+), 515 deletions(-) diff --git a/submodules/MediaPlayer/Sources/ChunkMediaPlayerV2.swift b/submodules/MediaPlayer/Sources/ChunkMediaPlayerV2.swift index 9825eb7b4c..2026c7b8c7 100644 --- a/submodules/MediaPlayer/Sources/ChunkMediaPlayerV2.swift +++ b/submodules/MediaPlayer/Sources/ChunkMediaPlayerV2.swift @@ -33,101 +33,6 @@ private final class ChunkMediaPlayerExternalSourceImpl: ChunkMediaPlayerSourceIm } public final class ChunkMediaPlayerV2: ChunkMediaPlayer { - public final class AudioContext { - fileprivate let audioSessionManager: ManagedAudioSession - private var audioSessionDisposable: Disposable? - private(set) var hasAudioSession: Bool = false - private(set) var isAmbientMode: Bool = false - private(set) var isInitialized: Bool = false - - private var updatedListeners = Bag<() -> Void>() - - public init( - audioSessionManager: ManagedAudioSession - ) { - self.audioSessionManager = audioSessionManager - } - - deinit { - self.audioSessionDisposable?.dispose() - } - - func onUpdated(_ f: @escaping () -> Void) -> Disposable { - let index = self.updatedListeners.add(f) - return ActionDisposable { [weak self] in - Queue.mainQueue().async { - guard let self else { - return - } - self.updatedListeners.remove(index) - } - } - } - - func setIsAmbient(isAmbient: Bool) { - self.hasAudioSession = false - - for f in self.updatedListeners.copyItems() { - f() - } - - self.audioSessionDisposable?.dispose() - self.audioSessionDisposable = nil - } - - func update(type: ManagedAudioSessionType?) { - if let type { - if self.audioSessionDisposable == nil { - self.isInitialized = true - - self.audioSessionDisposable = self.audioSessionManager.push(params: ManagedAudioSessionClientParams( - audioSessionType: type, - activateImmediately: false, - manualActivate: { [weak self] control in - control.setupAndActivate(synchronous: false, { state in - Queue.mainQueue().async { - guard let self else { - return - } - self.hasAudioSession = true - for f in self.updatedListeners.copyItems() { - f() - } - } - }) - }, - deactivate: { [weak self] _ in - return Signal { subscriber in - guard let self else { - subscriber.putCompletion() - return EmptyDisposable - } - - self.hasAudioSession = false - for f in self.updatedListeners.copyItems() { - f() - } - subscriber.putCompletion() - - return EmptyDisposable - } - |> runOn(.mainQueue()) - }, - headsetConnectionStatusChanged: { _ in }, - availableOutputsChanged: { _, _ in } - )) - } - } else { - if let audioSessionDisposable = self.audioSessionDisposable { - self.audioSessionDisposable = nil - audioSessionDisposable.dispose() - } - - self.hasAudioSession = false - } - } - } - public enum SourceDescription { public final class ResourceDescription { public let postbox: Postbox @@ -261,10 +166,10 @@ public final class ChunkMediaPlayerV2: ChunkMediaPlayer { private let dataQueue: Queue private let mediaDataReaderParams: MediaDataReaderParams + private let audioSessionManager: ManagedAudioSession private let onSeeked: (() -> Void)? private weak var playerNode: MediaPlayerNode? - private let audioContext: AudioContext private let renderSynchronizer: AVSampleBufferRenderSynchronizer private var videoRenderer: AVSampleBufferDisplayLayer private var audioRenderer: AVSampleBufferAudioRenderer? @@ -293,20 +198,13 @@ public final class ChunkMediaPlayerV2: ChunkMediaPlayer { } public var actionAtEnd: MediaPlayerActionAtEnd = .stop - public weak var migrateToNextPlayerOnEnd: ChunkMediaPlayerV2? { - didSet { - if self.migrateToNextPlayerOnEnd !== oldValue { - self.updateInternalState() - } - } - } private var didSeekOnce: Bool = false private var isPlaying: Bool = false private var baseRate: Double = 1.0 private var isSoundEnabled: Bool private var isMuted: Bool - private var initialIsAmbient: Bool + private var isAmbientMode: Bool private var seekId: Int = 0 private var seekTimestamp: Double = 0.0 @@ -325,11 +223,12 @@ public final class ChunkMediaPlayerV2: ChunkMediaPlayer { private var partsStateDisposable: Disposable? private var updateTimer: Foundation.Timer? - private var audioContextUpdatedDisposable: Disposable? + private var audioSessionDisposable: Disposable? + private var hasAudioSession: Bool = false public init( params: MediaDataReaderParams, - audioContext: AudioContext, + audioSessionManager: ManagedAudioSession, source: SourceDescription, video: Bool, playAutomatically: Bool = false, @@ -348,7 +247,7 @@ public final class ChunkMediaPlayerV2: ChunkMediaPlayer { self.dataQueue = ChunkMediaPlayerV2.sharedDataQueue self.mediaDataReaderParams = params - self.audioContext = audioContext + self.audioSessionManager = audioSessionManager self.onSeeked = onSeeked self.playerNode = playerNode @@ -358,7 +257,7 @@ public final class ChunkMediaPlayerV2: ChunkMediaPlayer { self.isSoundEnabled = enableSound self.isMuted = soundMuted - self.initialIsAmbient = ambient + self.isAmbientMode = ambient self.baseRate = baseRate self.renderSynchronizer = AVSampleBufferRenderSynchronizer() @@ -397,19 +296,12 @@ public final class ChunkMediaPlayerV2: ChunkMediaPlayer { } else { self.renderSynchronizer.addRenderer(self.videoRenderer) } - - self.audioContextUpdatedDisposable = self.audioContext.onUpdated({ [weak self] in - guard let self else { - return - } - self.updateInternalState() - }) } deinit { self.partsStateDisposable?.dispose() self.updateTimer?.invalidate() - self.audioContextUpdatedDisposable?.dispose() + self.audioSessionDisposable?.dispose() if #available(iOS 17.0, *) { self.videoRenderer.sampleBufferRenderer.stopRequestingMediaData() @@ -429,19 +321,51 @@ public final class ChunkMediaPlayerV2: ChunkMediaPlayer { } private func updateInternalState() { - var audioSessionType: ManagedAudioSessionType? if self.isSoundEnabled && self.hasSound { - let isAmbient: Bool - if self.audioContext.isInitialized { - isAmbient = self.audioContext.isAmbientMode - } else { - isAmbient = self.initialIsAmbient + if self.audioSessionDisposable == nil { + self.audioSessionDisposable = self.audioSessionManager.push(params: ManagedAudioSessionClientParams( + audioSessionType: self.isAmbientMode ? .ambient : .play(mixWithOthers: false), + activateImmediately: false, + manualActivate: { [weak self] control in + control.setupAndActivate(synchronous: false, { state in + Queue.mainQueue().async { + guard let self else { + return + } + self.hasAudioSession = true + self.updateInternalState() + } + }) + }, + deactivate: { [weak self] _ in + return Signal { subscriber in + guard let self else { + subscriber.putCompletion() + return EmptyDisposable + } + + self.hasAudioSession = false + self.updateInternalState() + subscriber.putCompletion() + + return EmptyDisposable + } + |> runOn(.mainQueue()) + }, + headsetConnectionStatusChanged: { _ in }, + availableOutputsChanged: { _, _ in } + )) } - audioSessionType = isAmbient ? .ambient : .play(mixWithOthers: false) + } else { + if let audioSessionDisposable = self.audioSessionDisposable { + self.audioSessionDisposable = nil + audioSessionDisposable.dispose() + } + + self.hasAudioSession = false } - self.audioContext.update(type: audioSessionType) - if self.isSoundEnabled && self.hasSound && self.audioContext.hasAudioSession { + if self.isSoundEnabled && self.hasSound && self.hasAudioSession { if self.audioRenderer == nil { let audioRenderer = AVSampleBufferAudioRenderer() audioRenderer.isMuted = self.isMuted @@ -875,9 +799,13 @@ public final class ChunkMediaPlayerV2: ChunkMediaPlayer { } public func continueWithOverridingAmbientMode(isAmbient: Bool) { - if self.audioContext.isAmbientMode != isAmbient { - self.initialIsAmbient = isAmbient - self.audioContext.setIsAmbient(isAmbient: isAmbient) + if self.isAmbientMode != isAmbient { + self.isAmbientMode = isAmbient + + self.hasAudioSession = false + self.updateInternalState() + self.audioSessionDisposable?.dispose() + self.audioSessionDisposable = nil let currentTimestamp: CMTime if let pendingSeekTimestamp = self.pendingSeekTimestamp { diff --git a/submodules/TelegramCallsUI/Sources/Components/LivestreamVideoViewV1.swift b/submodules/TelegramCallsUI/Sources/Components/LivestreamVideoViewV1.swift index 030ecff2b5..e9d61c78a6 100644 --- a/submodules/TelegramCallsUI/Sources/Components/LivestreamVideoViewV1.swift +++ b/submodules/TelegramCallsUI/Sources/Components/LivestreamVideoViewV1.swift @@ -70,7 +70,7 @@ final class LivestreamVideoViewV1: UIView { var onSeeked: (() -> Void)? self.player = ChunkMediaPlayerV2( params: ChunkMediaPlayerV2.MediaDataReaderParams(context: context), - audioContext: ChunkMediaPlayerV2.AudioContext(audioSessionManager: audioSessionManager), + audioSessionManager: audioSessionManager, source: .externalParts(self.chunkPlayerPartsState.get()), video: true, enableSound: true, diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContent.swift b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContent.swift index 725a27a253..b4e4c11784 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContent.swift +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContent.swift @@ -6,7 +6,6 @@ import SwiftSignalKit import TelegramCore import Postbox import TelegramPresentationData -import UniversalMediaPlayer public final class StoryContentItem: Equatable { public final class ExternalState { @@ -33,7 +32,6 @@ public final class StoryContentItem: Equatable { public final class SharedState { public var replyDrafts: [StoryId: NSAttributedString] = [:] public var baseRate: Double = 1.0 - public var audioContext: ChunkMediaPlayerV2.AudioContext? public init() { } diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemContentComponent.swift b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemContentComponent.swift index e50d333e51..f7adeea7cd 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemContentComponent.swift +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemContentComponent.swift @@ -15,275 +15,6 @@ import ButtonComponent import MultilineTextComponent import TelegramPresentationData -private protocol StoryVideoView: UIView { - var audioMode: StoryContentItem.AudioMode { get set } - var playbackCompleted: (() -> Void)? { get set } - var status: Signal { get } - - func play() - func pause() - func seek(timestamp: Double) - func setSoundMuted(soundMuted: Bool) - func continueWithOverridingAmbientMode(isAmbient: Bool) - func setBaseRate(baseRate: Double) - func update(size: CGSize, transition: ComponentTransition) -} - -private final class LegacyStoryVideoView: UIView, StoryVideoView { - private let videoNode: UniversalVideoNode - - var audioMode: StoryContentItem.AudioMode - var playbackCompleted: (() -> Void)? - - var status: Signal { - return self.videoNode.status - } - - init( - context: AccountContext, - file: FileMediaReference, - audioMode: StoryContentItem.AudioMode, - baseRate: Double, - isCaptureProtected: Bool - ) { - self.audioMode = audioMode - - var userLocation: MediaResourceUserLocation = .other - switch file { - case let .story(peer, _, _): - userLocation = .peer(peer.id) - default: - break - } - var hasSentFramesToDisplay: (() -> Void)? - self.videoNode = UniversalVideoNode( - context: context, - postbox: context.account.postbox, - audioSession: context.sharedContext.mediaManager.audioSession, - manager: context.sharedContext.mediaManager.universalVideoManager, - decoration: StoryVideoDecoration(), - content: NativeVideoContent( - id: .contextResult(0, "\(UInt64.random(in: 0 ... UInt64.max))"), - userLocation: userLocation, - fileReference: file, - imageReference: nil, - streamVideo: .story, - loopVideo: true, - enableSound: true, - soundMuted: audioMode == .off, - beginWithAmbientSound: audioMode == .ambient, - mixWithOthers: true, - useLargeThumbnail: false, - autoFetchFullSizeThumbnail: false, - tempFilePath: nil, - captureProtected: isCaptureProtected, - hintDimensions: file.media.dimensions?.cgSize, - storeAfterDownload: nil, - displayImage: false, - hasSentFramesToDisplay: { - hasSentFramesToDisplay?() - } - ), - priority: .gallery - ) - self.videoNode.isHidden = true - self.videoNode.setBaseRate(baseRate) - - super.init(frame: CGRect()) - - hasSentFramesToDisplay = { [weak self] in - guard let self else { - return - } - self.videoNode.isHidden = false - } - - self.videoNode.playbackCompleted = { [weak self] in - guard let self else { - return - } - self.playbackCompleted?() - } - - self.addSubview(self.videoNode.view) - - self.videoNode.ownsContentNodeUpdated = { [weak self] value in - guard let self else { - return - } - if value { - self.videoNode.seek(0.0) - if self.audioMode != .off { - self.videoNode.playOnceWithSound(playAndRecord: false, actionAtEnd: .stop) - } else { - self.videoNode.play() - } - } - } - self.videoNode.canAttachContent = true - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - func play() { - self.videoNode.play() - } - - func pause() { - self.videoNode.pause() - } - - func seek(timestamp: Double) { - self.videoNode.seek(timestamp) - } - - func setSoundMuted(soundMuted: Bool) { - self.videoNode.setSoundMuted(soundMuted: soundMuted) - } - - func continueWithOverridingAmbientMode(isAmbient: Bool) { - self.videoNode.continueWithOverridingAmbientMode(isAmbient: isAmbient) - } - - func setBaseRate(baseRate: Double) { - self.videoNode.setBaseRate(baseRate) - } - - func update(size: CGSize, transition: ComponentTransition) { - transition.setFrame(view: self.videoNode.view, frame: CGRect(origin: CGPoint(), size: size)) - self.videoNode.updateLayout(size: size, transition: transition.containedViewLayoutTransition) - } -} - -private final class ModernStoryVideoView: UIView, StoryVideoView { - private let player: ChunkMediaPlayerV2 - private let playerNode: MediaPlayerNode - - var audioMode: StoryContentItem.AudioMode - var playbackCompleted: (() -> Void)? - var isFirstPlay: Bool = true - - var status: Signal { - return self.player.status |> map(Optional.init) - } - - init( - context: AccountContext, - audioContext: ChunkMediaPlayerV2.AudioContext, - file: FileMediaReference, - audioMode: StoryContentItem.AudioMode, - baseRate: Double, - isCaptureProtected: Bool - ) { - self.audioMode = audioMode - - self.playerNode = MediaPlayerNode( - backgroundThread: false, - captureProtected: isCaptureProtected - ) - - var userLocation: MediaResourceUserLocation = .other - switch file { - case let .story(peer, _, _): - userLocation = .peer(peer.id) - default: - break - } - - self.player = ChunkMediaPlayerV2( - params: ChunkMediaPlayerV2.MediaDataReaderParams(context: context), - audioContext: audioContext, - source: .directFetch(ChunkMediaPlayerV2.SourceDescription.ResourceDescription( - postbox: context.account.postbox, - size: file.media.size ?? 0, - reference: file.resourceReference(file.media.resource), - userLocation: userLocation, - userContentType: .story, - statsCategory: statsCategoryForFileWithAttributes(file.media.attributes), - fetchAutomatically: false - )), - video: true, - playAutomatically: false, - enableSound: true, - baseRate: baseRate, - soundMuted: audioMode == .off, - ambient: audioMode == .ambient, - mixWithOthers: true, - continuePlayingWithoutSoundOnLostAudioSession: false, - isAudioVideoMessage: false, - playerNode: self.playerNode - ) - self.playerNode.isHidden = true - self.player.setBaseRate(baseRate) - - super.init(frame: CGRect()) - - self.addSubview(self.playerNode.view) - - self.playerNode.hasSentFramesToDisplay = { [weak self] in - guard let self else { - return - } - self.playerNode.isHidden = false - } - - self.player.actionAtEnd = .action({ [weak self] in - guard let self else { - return - } - self.playbackCompleted?() - }) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - func play() { - if self.isFirstPlay { - self.isFirstPlay = false - - if self.audioMode != .off { - self.player.playOnceWithSound(playAndRecord: false, seek: .start) - } else { - self.player.play() - } - } else { - self.player.play() - } - } - - func pause() { - self.player.pause() - } - - func seek(timestamp: Double) { - self.player.seek(timestamp: timestamp, play: nil) - } - - func setSoundMuted(soundMuted: Bool) { - self.player.setSoundMuted(soundMuted: soundMuted) - } - - func continueWithOverridingAmbientMode(isAmbient: Bool) { - self.player.continueWithOverridingAmbientMode(isAmbient: isAmbient) - } - - func setBaseRate(baseRate: Double) { - self.player.setBaseRate(baseRate) - } - - func update(size: CGSize, transition: ComponentTransition) { - transition.containedViewLayoutTransition.updateFrame(node: self.playerNode, frame: CGRect(origin: CGPoint(), size: size)) - } - - func updateNext(nextVideoView: ModernStoryVideoView?) { - self.player.migrateToNextPlayerOnEnd = nextVideoView?.player - } -} - final class StoryItemContentComponent: Component { typealias EnvironmentType = StoryContentItem.Environment @@ -360,11 +91,10 @@ final class StoryItemContentComponent: Component { final class View: StoryContentItem.View { private let imageView: StoryItemImageView private let overlaysView: StoryItemOverlaysView + private var videoNode: UniversalVideoNode? private var loadingEffectView: StoryItemLoadingEffectView? private var loadingEffectAppearanceTimer: SwiftSignalKit.Timer? - private var videoView: StoryVideoView? - private var mediaAreasEffectView: StoryItemLoadingEffectView? private var currentMessageMedia: EngineMedia? @@ -399,8 +129,6 @@ final class StoryItemContentComponent: Component { private var fetchPriorityResourceId: String? private var currentFetchPriority: (isMain: Bool, disposable: Disposable)? - private weak var nextItemView: StoryItemContentComponent.View? - override init(frame: CGRect) { self.hierarchyTrackingLayer = HierarchyTrackingLayer() self.imageView = StoryItemImageView() @@ -458,7 +186,10 @@ final class StoryItemContentComponent: Component { } private func initializeVideoIfReady(update: Bool) { - if self.videoView != nil { + if self.videoNode != nil { + return + } + if case .pause = self.progressMode { return } @@ -466,49 +197,48 @@ final class StoryItemContentComponent: Component { return } - var useLegacyImplementation = false - if let data = component.context.currentAppConfiguration.with({ $0 }).data, let value = data["ios_video_legacystoryplayer"] as? Double { - useLegacyImplementation = value != 0.0 - } - - if case .pause = self.progressMode { - if useLegacyImplementation { - return - } - } - if case let .file(file) = currentMessageMedia, let peerReference = PeerReference(component.peer._asPeer()) { - if self.videoView == nil { - let videoView: StoryVideoView - if useLegacyImplementation { - videoView = LegacyStoryVideoView( - context: component.context, - file: .story(peer: peerReference, id: component.item.id, media: file), - audioMode: component.audioMode, - baseRate: component.baseRate, - isCaptureProtected: component.item.isForwardingDisabled - ) - } else { - let audioContext: ChunkMediaPlayerV2.AudioContext - if let current = self.environment?.sharedState.audioContext { - audioContext = current - } else { - audioContext = ChunkMediaPlayerV2.AudioContext(audioSessionManager: component.context.sharedContext.mediaManager.audioSession) - self.environment?.sharedState.audioContext = audioContext - } - videoView = ModernStoryVideoView( - context: component.context, - audioContext: audioContext, - file: .story(peer: peerReference, id: component.item.id, media: file), - audioMode: component.audioMode, - baseRate: component.baseRate, - isCaptureProtected: component.item.isForwardingDisabled - ) - } - self.videoView = videoView - self.insertSubview(videoView, aboveSubview: self.imageView) + if self.videoNode == nil { + let videoNode = UniversalVideoNode( + context: component.context, + postbox: component.context.account.postbox, + audioSession: component.context.sharedContext.mediaManager.audioSession, + manager: component.context.sharedContext.mediaManager.universalVideoManager, + decoration: StoryVideoDecoration(), + content: NativeVideoContent( + id: .contextResult(0, "\(UInt64.random(in: 0 ... UInt64.max))"), + userLocation: .peer(peerReference.id), + fileReference: .story(peer: peerReference, id: component.item.id, media: file), + imageReference: nil, + streamVideo: .story, + loopVideo: true, + enableSound: true, + soundMuted: component.audioMode == .off, + beginWithAmbientSound: component.audioMode == .ambient, + mixWithOthers: true, + useLargeThumbnail: false, + autoFetchFullSizeThumbnail: false, + tempFilePath: nil, + captureProtected: component.item.isForwardingDisabled, + hintDimensions: file.dimensions?.cgSize, + storeAfterDownload: nil, + displayImage: false, + hasSentFramesToDisplay: { [weak self] in + guard let self else { + return + } + self.videoNode?.isHidden = false + } + ), + priority: .gallery + ) + videoNode.isHidden = true + videoNode.setBaseRate(component.baseRate) - videoView.playbackCompleted = { [weak self] in + self.videoNode = videoNode + self.insertSubview(videoNode.view, aboveSubview: self.imageView) + + videoNode.playbackCompleted = { [weak self] in guard let self else { return } @@ -523,24 +253,38 @@ final class StoryItemContentComponent: Component { if shouldLoop { self.rewind() - if let videoView = self.videoView { + if let videoNode = self.videoNode { if self.contentLoaded { - videoView.play() + videoNode.play() } } } else { self.environment?.presentationProgressUpdated(1.0, false, true) } } + videoNode.ownsContentNodeUpdated = { [weak self] value in + guard let self, let component = self.component else { + return + } + if value { + self.videoNode?.seek(0.0) + if component.audioMode != .off { + self.videoNode?.playOnceWithSound(playAndRecord: false, actionAtEnd: .stop) + } else { + self.videoNode?.play() + } + } + } + videoNode.canAttachContent = true if update { self.state?.updated(transition: .immediate) } } } - if let videoView = self.videoView { + if let videoNode = self.videoNode { if self.videoProgressDisposable == nil { - self.videoProgressDisposable = (videoView.status + self.videoProgressDisposable = (videoNode.status |> deliverOnMainQueue).start(next: { [weak self] status in guard let self, let status else { return @@ -552,17 +296,7 @@ final class StoryItemContentComponent: Component { } }) } - - let canPlay = self.progressMode != .pause && self.contentLoaded && self.hierarchyTrackingLayer.isInHierarchy - - if canPlay { - videoView.play() - } else { - videoView.pause() - } } - - self.updateVideoNextItem() } override func setProgressMode(_ progressMode: StoryContentItem.ProgressMode) { @@ -576,62 +310,48 @@ final class StoryItemContentComponent: Component { } } - func setNextItemView(nextItemView: StoryItemContentComponent.View?) { - if self.nextItemView !== nextItemView { - self.nextItemView = nextItemView - self.updateVideoNextItem() - } - } - - private func updateVideoNextItem() { - if let videoView = self.videoView as? ModernStoryVideoView { - let nextVideoView = self.nextItemView?.videoView as? ModernStoryVideoView - videoView.updateNext(nextVideoView: nextVideoView) - } - } - override func rewind() { self.currentProgressTimerValue = 0.0 - if let videoView = self.videoView { + if let videoNode = self.videoNode { if self.contentLoaded { - videoView.seek(timestamp: 0.0) + videoNode.seek(0.0) } } } override func leaveAmbientMode() { - if let videoView = self.videoView { + if let videoNode = self.videoNode { self.ignoreBufferingTimestamp = CFAbsoluteTimeGetCurrent() - videoView.setSoundMuted(soundMuted: false) - videoView.continueWithOverridingAmbientMode(isAmbient: false) + videoNode.setSoundMuted(soundMuted: false) + videoNode.continueWithOverridingAmbientMode(isAmbient: false) } } override func enterAmbientMode(ambient: Bool) { - if let videoView = self.videoView { + if let videoNode = self.videoNode { self.ignoreBufferingTimestamp = CFAbsoluteTimeGetCurrent() if ambient { - videoView.continueWithOverridingAmbientMode(isAmbient: true) + videoNode.continueWithOverridingAmbientMode(isAmbient: true) } else { - videoView.setSoundMuted(soundMuted: true) + videoNode.setSoundMuted(soundMuted: true) } } } override func setBaseRate(_ baseRate: Double) { - if let videoView = self.videoView { - videoView.setBaseRate(baseRate: baseRate) + if let videoNode = self.videoNode { + videoNode.setBaseRate(baseRate) } } private func updateProgressMode(update: Bool) { - if let videoView = self.videoView { + if let videoNode = self.videoNode { let canPlay = self.progressMode != .pause && self.contentLoaded && self.hierarchyTrackingLayer.isInHierarchy if canPlay { - videoView.play() + videoNode.play() } else { - videoView.pause() + videoNode.pause() } } @@ -846,11 +566,11 @@ final class StoryItemContentComponent: Component { private var isSeeking = false func seekTo(_ timestamp: Double, apply: Bool) { - guard let videoView = self.videoView else { + guard let videoNode = self.videoNode else { return } if apply { - videoView.seek(timestamp: min(timestamp, self.effectiveDuration - 0.3)) + videoNode.seek(min(timestamp, self.effectiveDuration - 0.3)) } self.isSeeking = true self.updateVideoPlaybackProgress(timestamp) @@ -868,10 +588,6 @@ final class StoryItemContentComponent: Component { let environment = environment[StoryContentItem.Environment.self].value self.environment = environment - if let videoView = self.videoView { - videoView.audioMode = component.audioMode - } - var synchronousLoad = false if let hint = transition.userData(Hint.self) { synchronousLoad = hint.synchronousLoad @@ -916,12 +632,12 @@ final class StoryItemContentComponent: Component { self.currentMessageMedia = messageMedia reloadMedia = true - if let videoView = self.videoView { + if let videoNode = self.videoNode { self.videoProgressDisposable?.dispose() self.videoProgressDisposable = nil - self.videoView = nil - videoView.removeFromSuperview() + self.videoNode = nil + videoNode.view.removeFromSuperview() } } self.currentMessageMetadataMedia = component.item.media @@ -1051,10 +767,10 @@ final class StoryItemContentComponent: Component { } let _ = imageSize - if let videoView = self.videoView { + if let videoNode = self.videoNode { let videoSize = dimensions.aspectFilled(availableSize) - videoView.frame = CGRect(origin: CGPoint(x: floor((availableSize.width - videoSize.width) * 0.5), y: floor((availableSize.height - videoSize.height) * 0.5)), size: videoSize) - videoView.update(size: videoSize, transition: .immediate) + videoNode.frame = CGRect(origin: CGPoint(x: floor((availableSize.width - videoSize.width) * 0.5), y: floor((availableSize.height - videoSize.height) * 0.5)), size: videoSize) + videoNode.updateLayout(size: videoSize, transition: .immediate) } } } diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift index 27b69c1316..856df6d79b 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift @@ -1478,7 +1478,7 @@ public final class StoryItemSetContainerComponent: Component { } if itemLayout.contentScaleFraction <= 0.0001 && !self.preparingToDisplayViewList { - if index != centralIndex && index != centralIndex + 1 { + if index != centralIndex { itemVisible = false } } @@ -1870,19 +1870,6 @@ public final class StoryItemSetContainerComponent: Component { } } - for i in 0 ..< component.slice.allItems.count { - guard let visibleItem = self.visibleItems[component.slice.allItems[i].id] else { - continue - } - var nextVisibleItem: VisibleItem? - if i != component.slice.allItems.count { - nextVisibleItem = self.visibleItems[component.slice.allItems[i + 1].id] - } - if let itemView = visibleItem.view.view as? StoryItemContentComponent.View { - itemView.setNextItemView(nextItemView: nextVisibleItem?.view.view as? StoryItemContentComponent.View) - } - } - self.trulyValidIds = trulyValidIds var removeIds: [StoryId] = [] diff --git a/submodules/TelegramUniversalVideoContent/Sources/HLSVideoJSNativeContentNode.swift b/submodules/TelegramUniversalVideoContent/Sources/HLSVideoJSNativeContentNode.swift index e4dca05f71..2115c94488 100644 --- a/submodules/TelegramUniversalVideoContent/Sources/HLSVideoJSNativeContentNode.swift +++ b/submodules/TelegramUniversalVideoContent/Sources/HLSVideoJSNativeContentNode.swift @@ -1093,7 +1093,7 @@ final class HLSVideoJSNativeContentNode: ASDisplayNode, UniversalVideoContentNod var onSeeked: (() -> Void)? self.player = ChunkMediaPlayerV2( params: ChunkMediaPlayerV2.MediaDataReaderParams(context: context), - audioContext: ChunkMediaPlayerV2.AudioContext(audioSessionManager: audioSessionManager), + audioSessionManager: audioSessionManager, source: .externalParts(self.chunkPlayerPartsState.get()), video: true, enableSound: self.enableSound, diff --git a/submodules/TelegramUniversalVideoContent/Sources/NativeVideoContent.swift b/submodules/TelegramUniversalVideoContent/Sources/NativeVideoContent.swift index afb8dadbba..e8b67bd497 100644 --- a/submodules/TelegramUniversalVideoContent/Sources/NativeVideoContent.swift +++ b/submodules/TelegramUniversalVideoContent/Sources/NativeVideoContent.swift @@ -520,7 +520,7 @@ private final class NativeVideoContentNode: ASDisplayNode, UniversalVideoContent } else { let mediaPlayer = ChunkMediaPlayerV2( params: ChunkMediaPlayerV2.MediaDataReaderParams(context: context), - audioContext: ChunkMediaPlayerV2.AudioContext(audioSessionManager: audioSessionManager), + audioSessionManager: audioSessionManager, source: .directFetch(ChunkMediaPlayerV2.SourceDescription.ResourceDescription( postbox: postbox, size: selectedFile.size ?? 0, From 995fea2943d2250532d472915fb960660147a32c Mon Sep 17 00:00:00 2001 From: Isaac <> Date: Mon, 5 May 2025 22:30:03 +0200 Subject: [PATCH 24/29] Cleanup for release --- .../Sources/ChatChannelSubscriberInputPanelNode.swift | 8 +------- .../Sources/Chat/ChatControllerLoadDisplayNode.swift | 5 +++-- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/submodules/TelegramUI/Components/Chat/ChatChannelSubscriberInputPanelNode/Sources/ChatChannelSubscriberInputPanelNode.swift b/submodules/TelegramUI/Components/Chat/ChatChannelSubscriberInputPanelNode/Sources/ChatChannelSubscriberInputPanelNode.swift index 993fab330b..5caccf8eb1 100644 --- a/submodules/TelegramUI/Components/Chat/ChatChannelSubscriberInputPanelNode/Sources/ChatChannelSubscriberInputPanelNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatChannelSubscriberInputPanelNode/Sources/ChatChannelSubscriberInputPanelNode.swift @@ -469,13 +469,7 @@ public final class ChatChannelSubscriberInputPanelNode: ChatInputPanelNode { self.giftButton.isHidden = false self.helpButton.isHidden = true - //TODO:release - self.suggestedPostButton.isHidden = false - self.presentGiftOrSuggestTooltip() - } else if case .broadcast = peer.info { - self.giftButton.isHidden = true - self.helpButton.isHidden = true - self.suggestedPostButton.isHidden = false + self.suggestedPostButton.isHidden = true self.presentGiftOrSuggestTooltip() } else if peer.flags.contains(.isGigagroup), self.action == .muteNotifications || self.action == .unmuteNotifications { self.giftButton.isHidden = true diff --git a/submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift b/submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift index 3ca76e4c6f..010ccc7f6e 100644 --- a/submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift +++ b/submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift @@ -4131,7 +4131,8 @@ extension ChatControllerImpl { strongSelf.present(UndoOverlayController(presentationData: strongSelf.presentationData, content: .info(title: nil, text: strongSelf.presentationData.strings.Conversation_GigagroupDescription, timeout: nil, customUndoText: nil), elevatedLayout: false, action: { _ in return true }), in: .current) } }, openSuggestPost: { [weak self] in - guard let self else { + let _ = self + /*guard let self else { return } guard let peerId = self.chatLocation.peerId else { @@ -4152,7 +4153,7 @@ extension ChatControllerImpl { ) chatController.navigationPresentation = .modal - self.push(chatController) + self.push(chatController)*/ }, editMessageMedia: { [weak self] messageId, draw in if let strongSelf = self { strongSelf.controllerInteraction?.editMessageMedia(messageId, draw) From 9db5eb726d44a6ab6f79f37271961899385ffa82 Mon Sep 17 00:00:00 2001 From: Isaac <> Date: Mon, 5 May 2025 22:31:26 +0200 Subject: [PATCH 25/29] Change defaults --- submodules/TelegramCallsUI/Sources/CallControllerNodeV2.swift | 2 +- submodules/TelegramCallsUI/Sources/VideoChatScreen.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/submodules/TelegramCallsUI/Sources/CallControllerNodeV2.swift b/submodules/TelegramCallsUI/Sources/CallControllerNodeV2.swift index db19e00c36..e2bbc23abb 100644 --- a/submodules/TelegramCallsUI/Sources/CallControllerNodeV2.swift +++ b/submodules/TelegramCallsUI/Sources/CallControllerNodeV2.swift @@ -167,7 +167,7 @@ final class CallControllerNodeV2: ViewControllerTracingNode, CallControllerNodeP self.conferenceAddParticipant?() } - var enableVideoSharpening = true + var enableVideoSharpening = false if let data = call.context.currentAppConfiguration.with({ $0 }).data, let value = data["ios_call_video_sharpening"] as? Double { enableVideoSharpening = value != 0.0 } diff --git a/submodules/TelegramCallsUI/Sources/VideoChatScreen.swift b/submodules/TelegramCallsUI/Sources/VideoChatScreen.swift index d65a9c0fdd..b18aa64aa1 100644 --- a/submodules/TelegramCallsUI/Sources/VideoChatScreen.swift +++ b/submodules/TelegramCallsUI/Sources/VideoChatScreen.swift @@ -1247,7 +1247,7 @@ final class VideoChatScreenComponent: Component { } self.callState = component.initialData.callState - self.enableVideoSharpening = true + self.enableVideoSharpening = false if let data = component.initialCall.accountContext.currentAppConfiguration.with({ $0 }).data, let value = data["ios_call_video_sharpening"] as? Double { self.enableVideoSharpening = value != 0.0 } From 3865e253ffa0e4dba49d14301f6e3b7003ab6b6f Mon Sep 17 00:00:00 2001 From: Ilya Laktyushin Date: Tue, 6 May 2025 02:09:44 +0400 Subject: [PATCH 26/29] Various fixes --- .../Sources/ChatMessageItemCommon.swift | 4 --- .../Sources/GiftItemComponent.swift | 20 +++++++----- .../Sources/GiftOptionsScreen.swift | 6 ++-- .../Sources/GiftStoreScreen.swift | 2 +- .../Sources/GiftViewScreen.swift | 32 +++++++++++++------ .../Sources/PeerInfoGiftsPaneNode.swift | 2 +- .../Sources/TabSelectorComponent.swift | 16 ++++++++-- 7 files changed, 52 insertions(+), 30 deletions(-) diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageItemCommon/Sources/ChatMessageItemCommon.swift b/submodules/TelegramUI/Components/Chat/ChatMessageItemCommon/Sources/ChatMessageItemCommon.swift index 38c0ff482f..f749c3c46a 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageItemCommon/Sources/ChatMessageItemCommon.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageItemCommon/Sources/ChatMessageItemCommon.swift @@ -295,10 +295,6 @@ public func canAddMessageReactions(message: Message) -> Bool { return true } } - } else if let story = media as? TelegramMediaStory { - if story.isMention { - return false - } } } return true diff --git a/submodules/TelegramUI/Components/Gifts/GiftItemComponent/Sources/GiftItemComponent.swift b/submodules/TelegramUI/Components/Gifts/GiftItemComponent/Sources/GiftItemComponent.swift index 001fa6f4fe..2a0b88458e 100644 --- a/submodules/TelegramUI/Components/Gifts/GiftItemComponent/Sources/GiftItemComponent.swift +++ b/submodules/TelegramUI/Components/Gifts/GiftItemComponent/Sources/GiftItemComponent.swift @@ -559,7 +559,7 @@ public final class GiftItemComponent: Component { let price: String switch component.subject { case let .premium(_, priceValue), let .starGift(_, priceValue): - if priceValue.containsEmoji { + if priceValue.contains("#") { buttonColor = component.theme.overallDarkAppearance ? UIColor(rgb: 0xffc337) : UIColor(rgb: 0xd3720a) if !component.isSoldOut { starsColor = UIColor(rgb: 0xffbe27) @@ -867,10 +867,12 @@ public final class GiftItemComponent: Component { } ) let dateTimeFormat = component.context.sharedContext.currentPresentationData.with { $0 }.dateTimeFormat - let labelText = NSMutableAttributedString(attributedString: parseMarkdownIntoAttributedString("#\(presentationStringsFormattedNumber(Int32(resellPrice), dateTimeFormat.groupingSeparator))", attributes: attributes)) - if let range = labelText.string.range(of: "#") { - labelText.addAttribute(NSAttributedString.Key.font, value: Font.semibold(10.0), range: NSRange(range, in: labelText.string)) - labelText.addAttribute(ChatTextInputAttributes.customEmoji, value: ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: nil, fileId: 0, file: nil, custom: .stars(tinted: true)), range: NSRange(range, in: labelText.string)) + let labelText = NSMutableAttributedString(attributedString: parseMarkdownIntoAttributedString("# \(presentationStringsFormattedNumber(Int32(resellPrice), dateTimeFormat.groupingSeparator))", attributes: attributes)) + let range = (labelText.string as NSString).range(of: "#") + if range.location != NSNotFound { + labelText.addAttribute(NSAttributedString.Key.font, value: Font.semibold(10.0), range: range) + labelText.addAttribute(ChatTextInputAttributes.customEmoji, value: ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: nil, fileId: 0, file: nil, custom: .stars(tinted: true)), range: range) + labelText.addAttribute(.kern, value: -2.0, range: NSRange(location: range.lowerBound, length: 1)) } let resellSize = self.reselLabel.update( @@ -1048,11 +1050,13 @@ private final class ButtonContentComponent: Component { self.componentState = state let attributedText = NSMutableAttributedString(string: component.text, font: Font.semibold(11.0), textColor: component.color) - let range = (attributedText.string as NSString).range(of: "⭐️") + let range = (attributedText.string as NSString).range(of: "#") if range.location != NSNotFound { attributedText.addAttribute(ChatTextInputAttributes.customEmoji, value: ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: nil, fileId: 0, file: nil, custom: .stars(tinted: component.tinted)), range: range) - attributedText.addAttribute(.font, value: Font.semibold(15.0), range: range) - attributedText.addAttribute(.baselineOffset, value: 2.0, range: NSRange(location: range.upperBound, length: attributedText.length - range.upperBound)) + attributedText.addAttribute(.font, value: Font.semibold(component.tinted ? 14.0 : 15.0), range: range) + attributedText.addAttribute(.baselineOffset, value: -3.0, range: range) + attributedText.addAttribute(.baselineOffset, value: 1.5, range: NSRange(location: range.upperBound + 1, length: attributedText.length - range.upperBound - 1)) + attributedText.addAttribute(.kern, value: -1.5, range: NSRange(location: range.upperBound, length: 1)) } let titleSize = self.title.update( diff --git a/submodules/TelegramUI/Components/Gifts/GiftOptionsScreen/Sources/GiftOptionsScreen.swift b/submodules/TelegramUI/Components/Gifts/GiftOptionsScreen/Sources/GiftOptionsScreen.swift index 5b79de4903..08eac311a9 100644 --- a/submodules/TelegramUI/Components/Gifts/GiftOptionsScreen/Sources/GiftOptionsScreen.swift +++ b/submodules/TelegramUI/Components/Gifts/GiftOptionsScreen/Sources/GiftOptionsScreen.swift @@ -412,12 +412,12 @@ final class GiftOptionsScreenComponent: Component { if let availability = gift.availability, availability.remains == 0, let minResaleStars = availability.minResaleStars { let priceString = presentationStringsFormattedNumber(Int32(minResaleStars), environment.dateTimeFormat.groupingSeparator) if let resaleConfiguration = self.resaleConfiguration, minResaleStars == resaleConfiguration.starGiftResaleMaxAmount || availability.resale == 1 { - subject = .starGift(gift: gift, price: "⭐️ \(priceString)") + subject = .starGift(gift: gift, price: "# \(priceString)") } else { - subject = .starGift(gift: gift, price: "⭐️ \(priceString)+") + subject = .starGift(gift: gift, price: "# \(priceString)+") } } else { - subject = .starGift(gift: gift, price: "⭐️ \(presentationStringsFormattedNumber(Int32(gift.price), environment.dateTimeFormat.groupingSeparator))") + subject = .starGift(gift: gift, price: "# \(presentationStringsFormattedNumber(Int32(gift.price), environment.dateTimeFormat.groupingSeparator))") } case let .unique(gift): subject = .uniqueGift(gift: gift, price: nil) diff --git a/submodules/TelegramUI/Components/Gifts/GiftStoreScreen/Sources/GiftStoreScreen.swift b/submodules/TelegramUI/Components/Gifts/GiftStoreScreen/Sources/GiftStoreScreen.swift index ae889947fc..6827a62fbf 100644 --- a/submodules/TelegramUI/Components/Gifts/GiftStoreScreen/Sources/GiftStoreScreen.swift +++ b/submodules/TelegramUI/Components/Gifts/GiftStoreScreen/Sources/GiftStoreScreen.swift @@ -230,7 +230,7 @@ final class GiftStoreScreenComponent: Component { color: ribbonColor ) - let subject: GiftItemComponent.Subject = .uniqueGift(gift: uniqueGift, price: "⭐️\(presentationStringsFormattedNumber(Int32(uniqueGift.resellStars ?? 0), environment.dateTimeFormat.groupingSeparator))") + let subject: GiftItemComponent.Subject = .uniqueGift(gift: uniqueGift, price: "# \(presentationStringsFormattedNumber(Int32(uniqueGift.resellStars ?? 0), environment.dateTimeFormat.groupingSeparator))") let _ = visibleItem.update( transition: itemTransition, component: AnyComponent( diff --git a/submodules/TelegramUI/Components/Gifts/GiftViewScreen/Sources/GiftViewScreen.swift b/submodules/TelegramUI/Components/Gifts/GiftViewScreen/Sources/GiftViewScreen.swift index 3d8134dfbc..5cc4395dcb 100644 --- a/submodules/TelegramUI/Components/Gifts/GiftViewScreen/Sources/GiftViewScreen.swift +++ b/submodules/TelegramUI/Components/Gifts/GiftViewScreen/Sources/GiftViewScreen.swift @@ -309,17 +309,29 @@ private final class GiftViewSheetContent: CombinedComponent { let context = self.context let action = { if gifts { - if let profileController = context.sharedContext.makePeerInfoController( - context: context, - updatedPresentationData: nil, - peer: peer._asPeer(), - mode: peer.id == context.account.peerId ? .myProfileGifts : .gifts, - avatarInitiallyExpanded: false, - fromChat: false, - requestsContext: nil - ) { - navigationController.pushViewController(profileController) + let profileGifts = ProfileGiftsContext(account: context.account, peerId: peer.id) + let _ = (profileGifts.state + |> filter { state in + if case .ready = state.dataState { + return true + } + return false } + |> take(1) + |> deliverOnMainQueue).start(next: { [weak navigationController] _ in + if let profileController = context.sharedContext.makePeerInfoController( + context: context, + updatedPresentationData: nil, + peer: peer._asPeer(), + mode: peer.id == context.account.peerId ? .myProfileGifts : .gifts, + avatarInitiallyExpanded: false, + fromChat: false, + requestsContext: nil + ) { + navigationController?.pushViewController(profileController) + } + let _ = profileGifts + }) } else { context.sharedContext.navigateToChatController(NavigateToChatControllerParams( navigationController: navigationController, diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoGiftsPaneNode.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoGiftsPaneNode.swift index 2a88b1ced1..51dd286db5 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoGiftsPaneNode.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoGiftsPaneNode.swift @@ -488,7 +488,7 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr switch product.gift { case let .generic(gift): - subject = .starGift(gift: gift, price: "⭐️ \(gift.price)") + subject = .starGift(gift: gift, price: "# \(gift.price)") peer = product.fromPeer.flatMap { .peer($0) } ?? .anonymous if let availability = gift.availability { diff --git a/submodules/TelegramUI/Components/TabSelectorComponent/Sources/TabSelectorComponent.swift b/submodules/TelegramUI/Components/TabSelectorComponent/Sources/TabSelectorComponent.swift index f107fd6366..efce5db5c6 100644 --- a/submodules/TelegramUI/Components/TabSelectorComponent/Sources/TabSelectorComponent.swift +++ b/submodules/TelegramUI/Components/TabSelectorComponent/Sources/TabSelectorComponent.swift @@ -117,6 +117,8 @@ public final class TabSelectorComponent: Component { private let selectionView: UIImageView private var visibleItems: [AnyHashable: VisibleItem] = [:] + private var didInitiallyScroll = false + override init(frame: CGRect) { self.selectionView = UIImageView() @@ -238,11 +240,15 @@ public final class TabSelectorComponent: Component { )), effectAlignment: .center, minSize: nil, - action: { [weak self] in + action: { [weak self, weak itemView] in guard let self, let component = self.component else { return } component.setSelectedId(itemId) + + if let view = itemView?.title.view, allowScroll && self.contentSize.width > self.bounds.width { + self.scrollRectToVisible(view.frame.insetBy(dx: -64.0, dy: 0.0), animated: true) + } }, animateScale: !isLineSelection )), @@ -336,11 +342,15 @@ public final class TabSelectorComponent: Component { self.selectionView.alpha = 0.0 } - self.contentSize = CGSize(width: contentWidth, height: baseHeight + verticalInset * 2.0) + let contentSize = CGSize(width: contentWidth, height: baseHeight + verticalInset * 2.0) + if self.contentSize != contentSize { + self.contentSize = contentSize + } self.disablesInteractiveTransitionGestureRecognizer = contentWidth > availableSize.width - if let selectedBackgroundRect, self.bounds.width > 0.0 { + if let selectedBackgroundRect, self.bounds.width > 0.0 && !self.didInitiallyScroll { self.scrollRectToVisible(selectedBackgroundRect.insetBy(dx: -spacing, dy: 0.0), animated: false) + self.didInitiallyScroll = true } return CGSize(width: min(contentWidth, availableSize.width), height: baseHeight + verticalInset * 2.0) From 7afa30f3d898d283693308aea1285b6d0c6fae46 Mon Sep 17 00:00:00 2001 From: Ilya Laktyushin Date: Tue, 6 May 2025 03:43:48 +0400 Subject: [PATCH 27/29] Various fixes --- .../Sources/GiftItemComponent.swift | 2 +- .../Sources/MediaEditorValues.swift | 110 +++++++++++++++++- .../Sources/MediaEditorVideoExport.swift | 19 ++- .../WebUI/Sources/WebAppController.swift | 20 +++- 4 files changed, 138 insertions(+), 13 deletions(-) diff --git a/submodules/TelegramUI/Components/Gifts/GiftItemComponent/Sources/GiftItemComponent.swift b/submodules/TelegramUI/Components/Gifts/GiftItemComponent/Sources/GiftItemComponent.swift index 2a0b88458e..ae3d159ef2 100644 --- a/submodules/TelegramUI/Components/Gifts/GiftItemComponent/Sources/GiftItemComponent.swift +++ b/submodules/TelegramUI/Components/Gifts/GiftItemComponent/Sources/GiftItemComponent.swift @@ -872,7 +872,7 @@ public final class GiftItemComponent: Component { if range.location != NSNotFound { labelText.addAttribute(NSAttributedString.Key.font, value: Font.semibold(10.0), range: range) labelText.addAttribute(ChatTextInputAttributes.customEmoji, value: ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: nil, fileId: 0, file: nil, custom: .stars(tinted: true)), range: range) - labelText.addAttribute(.kern, value: -2.0, range: NSRange(location: range.lowerBound, length: 1)) + labelText.addAttribute(.kern, value: -1.5, range: NSRange(location: range.upperBound, length: 1)) } let resellSize = self.reselLabel.update( diff --git a/submodules/TelegramUI/Components/MediaEditor/Sources/MediaEditorValues.swift b/submodules/TelegramUI/Components/MediaEditor/Sources/MediaEditorValues.swift index 47b4d4c792..373483fe9b 100644 --- a/submodules/TelegramUI/Components/MediaEditor/Sources/MediaEditorValues.swift +++ b/submodules/TelegramUI/Components/MediaEditor/Sources/MediaEditorValues.swift @@ -230,7 +230,7 @@ public enum MediaCropOrientation: Int32 { } } -public final class MediaEditorValues: Codable, Equatable { +public final class MediaEditorValues: Codable, Equatable, CustomStringConvertible { public static func == (lhs: MediaEditorValues, rhs: MediaEditorValues) -> Bool { if lhs.peerId != rhs.peerId { return false @@ -1010,6 +1010,114 @@ public final class MediaEditorValues: Codable, Equatable { } return false } + + public var description: String { + var components: [String] = [] + + components.append("originalDimensions: \(self.originalDimensions.width)x\(self.originalDimensions.height)") + + if self.cropOffset != .zero { + components.append("cropOffset: \(cropOffset)") + } + + if let cropRect = self.cropRect { + components.append("cropRect: \(cropRect)") + } + + if self.cropScale != 1.0 { + components.append("cropScale: \(self.cropScale)") + } + + if self.cropRotation != 0.0 { + components.append("cropRotation: \(self.cropRotation)") + } + + if self.cropMirroring { + components.append("cropMirroring: true") + } + + if let cropOrientation = self.cropOrientation { + components.append("cropOrientation: \(cropOrientation)") + } + + if let gradientColors = self.gradientColors, !gradientColors.isEmpty { + components.append("gradientColors: \(gradientColors.count) colors") + } + + if let videoTrimRange = self.videoTrimRange { + components.append("videoTrimRange: \(videoTrimRange.lowerBound) - \(videoTrimRange.upperBound)") + } + + if self.videoIsMuted { + components.append("videoIsMuted: true") + } + + if self.videoIsFullHd { + components.append("videoIsFullHd: true") + } + + if self.videoIsMirrored { + components.append("videoIsMirrored: true") + } + + if let videoVolume = self.videoVolume, videoVolume != 1.0 { + components.append("videoVolume: \(videoVolume)") + } + + if let additionalVideoPath = self.additionalVideoPath { + components.append("additionalVideo: \(additionalVideoPath)") + } + + if let position = self.additionalVideoPosition { + components.append("additionalVideoPosition: \(position)") + } + + if let scale = self.additionalVideoScale { + components.append("additionalVideoScale: \(scale)") + } + + if let rotation = self.additionalVideoRotation { + components.append("additionalVideoRotation: \(rotation)") + } + + if !self.additionalVideoPositionChanges.isEmpty { + components.append("additionalVideoPositionChanges: \(additionalVideoPositionChanges.count) changes") + } + + if !self.collage.isEmpty { + components.append("collage: \(collage.count) items") + } + + if self.nightTheme { + components.append("nightTheme: true") + } + + if self.drawing != nil { + components.append("drawing: true") + } + + if self.maskDrawing != nil { + components.append("maskDrawing: true") + } + + if !self.entities.isEmpty { + components.append("entities: \(self.entities.count) items") + } + + if !self.toolValues.isEmpty { + components.append("toolValues: \(self.toolValues.count) tools") + } + + if let audioTrack = self.audioTrack { + components.append("audioTrack: \(audioTrack.path)") + } + + if let qualityPreset = self.qualityPreset { + components.append("qualityPreset: \(qualityPreset)") + } + + return "MediaEditorValues(\(components.joined(separator: ", ")))" + } } public struct TintValue: Equatable, Codable { diff --git a/submodules/TelegramUI/Components/MediaEditor/Sources/MediaEditorVideoExport.swift b/submodules/TelegramUI/Components/MediaEditor/Sources/MediaEditorVideoExport.swift index 6061765718..89bfd124cc 100644 --- a/submodules/TelegramUI/Components/MediaEditor/Sources/MediaEditorVideoExport.swift +++ b/submodules/TelegramUI/Components/MediaEditor/Sources/MediaEditorVideoExport.swift @@ -264,6 +264,11 @@ public final class MediaEditorVideoExport { self.outputPath = outputPath self.textScale = textScale + Logger.shared.log("VideoExport", "Init") + Logger.shared.log("VideoExport", "Subject: \(subject)") + Logger.shared.log("VideoExport", "Output Path: \(outputPath)") + Logger.shared.log("VideoExport", "Configuration: \(configuration)") + if FileManager.default.fileExists(atPath: outputPath) { try? FileManager.default.removeItem(atPath: outputPath) } @@ -297,6 +302,9 @@ public final class MediaEditorVideoExport { } private func setup() { + Logger.shared.log("VideoExport", "Setting up") + + var mainAsset: AVAsset? var signals: [Signal] = [] @@ -948,11 +956,6 @@ public final class MediaEditorVideoExport { return false } } - } else { -// if !writer.appendVideoBuffer(sampleBuffer) { -// writer.markVideoAsFinished() -// return false -// } } } return true @@ -983,17 +986,21 @@ public final class MediaEditorVideoExport { } private func start() { + Logger.shared.log("VideoExport", "Start") guard self.internalStatus == .idle, let writer = self.writer else { + Logger.shared.log("VideoExport", "Failed with invalid state") self.statusValue = .failed(.invalid) return } guard writer.startWriting() else { + Logger.shared.log("VideoExport", "Failed on startWriting") self.statusValue = .failed(.writing(nil)) return } if let reader = self.reader, !reader.startReading() { + Logger.shared.log("VideoExport", "Failed on startReading") self.statusValue = .failed(.reading(nil)) return } @@ -1067,6 +1074,7 @@ public final class MediaEditorVideoExport { } if cancelled { + Logger.shared.log("VideoExport", "Cancelled") try? FileManager.default.removeItem(at: outputUrl) self.internalStatus = .finished self.statusValue = .failed(.cancelled) @@ -1108,6 +1116,7 @@ public final class MediaEditorVideoExport { let exportDuration = end - self.startTimestamp print("video processing took \(exportDuration)s") if duration.seconds > 0 { + Logger.shared.log("VideoExport", "Completed with path \(self.outputPath)") Logger.shared.log("VideoExport", "Video processing took \(exportDuration / duration.seconds)") } }) diff --git a/submodules/WebUI/Sources/WebAppController.swift b/submodules/WebUI/Sources/WebAppController.swift index 4324257087..dda6079112 100644 --- a/submodules/WebUI/Sources/WebAppController.swift +++ b/submodules/WebUI/Sources/WebAppController.swift @@ -828,7 +828,11 @@ public final class WebAppController: ViewController, AttachmentContainable { } if let webView = self.webView { - var scrollInset = UIEdgeInsets(top: 0.0, left: 0.0, bottom: layout.intrinsicInsets.bottom, right: 0.0) + let inputHeight = self.validLayout?.0.inputHeight ?? 0.0 + + let intrinsicBottomInset = layout.intrinsicInsets.bottom > 40.0 ? layout.intrinsicInsets.bottom : 0.0 + + var scrollInset = UIEdgeInsets(top: 0.0, left: 0.0, bottom: max(inputHeight, intrinsicBottomInset), right: 0.0) var frameBottomInset: CGFloat = 0.0 if scrollInset.bottom > 40.0 { frameBottomInset = scrollInset.bottom @@ -841,12 +845,12 @@ public final class WebAppController: ViewController, AttachmentContainable { if !webView.frame.width.isZero && webView.frame != webViewFrame { self.updateWebViewWhenStable = true } - - var bottomInset = layout.intrinsicInsets.bottom + layout.additionalInsets.bottom - if let inputHeight = self.validLayout?.0.inputHeight, inputHeight > 44.0 { - bottomInset = max(bottomInset, inputHeight) + + var viewportBottomInset = max(frameBottomInset, scrollInset.bottom) + if (self.validLayout?.0.inputHeight ?? 0.0) < 44.0 { + viewportBottomInset += layout.additionalInsets.bottom } - let viewportFrame = CGRect(origin: CGPoint(x: layout.safeInsets.left, y: topInset), size: CGSize(width: layout.size.width - layout.safeInsets.left - layout.safeInsets.right, height: max(1.0, layout.size.height - topInset - bottomInset))) + let viewportFrame = CGRect(origin: CGPoint(x: layout.safeInsets.left, y: topInset), size: CGSize(width: layout.size.width - layout.safeInsets.left - layout.safeInsets.right, height: max(1.0, layout.size.height - topInset - viewportBottomInset))) if webView.scrollView.contentInset != scrollInset { webView.scrollView.contentInset = scrollInset @@ -1061,6 +1065,10 @@ public final class WebAppController: ViewController, AttachmentContainable { } else { self.lastExpansionTimestamp = currentTimestamp controller.requestAttachmentMenuExpansion() + + Queue.mainQueue().after(0.4) { + self.webView?.setNeedsLayout() + } } case "web_app_close": controller.dismiss() From b7f84a97b5be994c48121520e462a685df1ea95f Mon Sep 17 00:00:00 2001 From: Ilya Laktyushin Date: Tue, 6 May 2025 04:14:21 +0400 Subject: [PATCH 28/29] Various fixes --- .../Sources/GiftOptionsScreen.swift | 3 +++ .../Sources/PeerInfoGiftsCoverComponent.swift | 16 +++++++++++++--- .../Wallet/QrIcon.imageset/Contents.json | 12 ------------ .../Wallet/QrIcon.imageset/ic_qrcode.pdf | Bin 4962 -> 0 bytes 4 files changed, 16 insertions(+), 15 deletions(-) delete mode 100644 submodules/TelegramUI/Images.xcassets/Wallet/QrIcon.imageset/Contents.json delete mode 100644 submodules/TelegramUI/Images.xcassets/Wallet/QrIcon.imageset/ic_qrcode.pdf diff --git a/submodules/TelegramUI/Components/Gifts/GiftOptionsScreen/Sources/GiftOptionsScreen.swift b/submodules/TelegramUI/Components/Gifts/GiftOptionsScreen/Sources/GiftOptionsScreen.swift index 08eac311a9..643d1f7131 100644 --- a/submodules/TelegramUI/Components/Gifts/GiftOptionsScreen/Sources/GiftOptionsScreen.swift +++ b/submodules/TelegramUI/Components/Gifts/GiftOptionsScreen/Sources/GiftOptionsScreen.swift @@ -1567,6 +1567,9 @@ final class GiftOptionsScreenComponent: Component { } } } + if disallowedGifts.contains(.unique) && gift.availability?.remains == 0 { + return false + } } return true } diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoCoverComponent/Sources/PeerInfoGiftsCoverComponent.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoCoverComponent/Sources/PeerInfoGiftsCoverComponent.swift index 81ba25209d..4f32eca5a3 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoCoverComponent/Sources/PeerInfoGiftsCoverComponent.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoCoverComponent/Sources/PeerInfoGiftsCoverComponent.swift @@ -184,7 +184,9 @@ public final class PeerInfoGiftsCoverComponent: Component { } } + private var scheduledAnimateIn = false public func willAnimateIn() { + self.scheduledAnimateIn = true for (_, layer) in self.iconLayers { layer.opacity = 0.0 } @@ -194,6 +196,7 @@ public final class PeerInfoGiftsCoverComponent: Component { guard let _ = self.currentSize, let component = self.component else { return } + self.scheduledAnimateIn = false for (_, layer) in self.iconLayers { layer.opacity = 1.0 @@ -319,8 +322,12 @@ public final class PeerInfoGiftsCoverComponent: Component { self.iconLayers[id] = iconLayer self.layer.addSublayer(iconLayer) - iconLayer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) - iconLayer.animateScale(from: 0.01, to: 1.0, duration: 0.2) + if self.scheduledAnimateIn { + iconLayer.opacity = 0.0 + } else { + iconLayer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) + iconLayer.animateScale(from: 0.01, to: 1.0, duration: 0.2) + } iconLayer.startAnimations(index: index) } @@ -349,7 +356,10 @@ public final class PeerInfoGiftsCoverComponent: Component { iconTransition.setPosition(layer: iconLayer, position: absolutePosition) iconLayer.updateRotation(effectiveAngle, transition: iconTransition) iconTransition.setScale(layer: iconLayer, scale: iconPosition.scale * (1.0 - itemScaleFraction)) - iconTransition.setAlpha(layer: iconLayer, alpha: 1.0 - itemScaleFraction) + + if !self.scheduledAnimateIn { + iconTransition.setAlpha(layer: iconLayer, alpha: 1.0 - itemScaleFraction) + } index += 1 } diff --git a/submodules/TelegramUI/Images.xcassets/Wallet/QrIcon.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Wallet/QrIcon.imageset/Contents.json deleted file mode 100644 index f8015db3bc..0000000000 --- a/submodules/TelegramUI/Images.xcassets/Wallet/QrIcon.imageset/Contents.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "images" : [ - { - "filename" : "ic_qrcode.pdf", - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/submodules/TelegramUI/Images.xcassets/Wallet/QrIcon.imageset/ic_qrcode.pdf b/submodules/TelegramUI/Images.xcassets/Wallet/QrIcon.imageset/ic_qrcode.pdf deleted file mode 100644 index cf234b82239e82c027356a047d742e9c99dadf49..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4962 zcmai&cT`hL*TyNKqvDkgLO_ZlB?$>d=~V;?h#=LFgwUIW-g^xo%>YVA=|w?Bklv)c z1f+>lL#WS2XZG1^&SbA={bmI9)HKAv;*#`&9mGxIO3_yC zU`H1{6bJ&kpzP`8(jB9QLAzLCfC>uqSa&xJ z(uv-i9Al)NFeSq{dC05~g6=u~`Vd&gog1P|?Jch~xJ&a`L11k>1>jStmKMkS=uOVX z;+~D8)4Q(cx=u4)gePB@?adHwfOA_z1gYnk%w5mVnQtp8H6H97O^$7#qluxu#5d>a zh}^^emECvS9fwB~Ju9U9rLtBg$1!^#;XQ`4(J7v7ZzXU6_4jKkV$V^tifNneQiHlP zi@d%0_+bf}J|!vbA;hqq_t{=#b>K(5Vb4Dx<>qShcoF}NL8Q(85( zWd8tuqu{}mYPb8qA#P{5#Z@O@Vt~V9st7YJmFi@#+0Oe6tth?O!akq|jXNK>#d0o$ zuKLJy$d4-ax$L|8TCNc%bo5fD+rdaB(UkE@@ZgiG^V!N;bvcU>?i9>d7s~quAIkY= zw^p{gzSVuWFc7hYNBqtmO@S2&)=%sz3nMKNcAIP-EQ z|5E6o`{2{)O^Wa--m=_1*1&s?uJhEp9~rd$waU=L$x%+vVWL}UXK%T2G>M3AIw^Yx zw^nDR^58Hlxy&|9nOA16q$!@dsP9@(F4FSzN|V7uzmisbBC2GjM3UU+i(y z+>EtuzQ7~=;Ql}yJla1i+YhEPc&3zbgROh`I`uwZ4W&rTy+pC|;7NEdrkG2&sS;#7 zAShSip;V&~^1+$s;3yMmze^3{^rL&x{1KJz+7&;dA z2Pg9fYi~4s$*bn`F9C?aWY~2|rtjFZ_FkYmZ@R>40Atc94Cs$G(`v4*dPuS9SjwA{ z_Dz4eJkS!jPnnGlOU|<78t~fZ(j(Ij?BUhs@7a}SWl@QoH{}^vY}b5210nZG`q*7( zsIN=At3Sjj#C+wTB7Z0He#FJxQd@F)5fLLpwcR{y(3Bn+p=3Br;X48io1#JvmSrA7?UU`PCYm7d_qE957(!P;YQM|_{<*44a(shxVLuzW{!?de^o4HS_uBchg5oIE z)&kEo6yM_4rL>L77?+kqBe}ix&I?%iQ}h|8xlOdXvs5$N`QENMoqnuk)6|0l-S2}x z#`+eHy&J3GeA9hYM0;H$DoQOfc5#dBVBecp&(b07Mrfqk1=nKu7z5wPdBuzT2&(E& z5oBC1;BUgagZ3?}#Vjt5v1^a9M4x@PwB{=i?Md~VK_b#G%gL}H9WC1IM{|Hi;f^4? zA(KJz#|c}tiN`p_?*}R<#9VnZf|q^4ZXN&b%*%;r-rCpzb@JY!?>Q|&iHgUR!n-wb z^cZKWpN2*1o%q~|xt+9Nuq^0g^rR2@%>!@wxMF}3dPtiezit?3ccA3oes~jub@6aR zW3WJ}zXeqnXLrI@;JH&49rXTFp1U!JPx=7KiBxC6~dN!2uf7C;GA7e^O216L#( z13WQZRV)}N{WHODIb?pz@za-Oe?&>>k|rdfeB#Z{Bx43js9`+q(3qQ=D*xNEv+jG{ zGMHeEA~Z@j4*tat6x`s(*QE(JRi`DtVFmRW0SfaabW#mKit`HYO>Yh5)vpT&c%8s3XG>j`1Uj)Ri; zrBwx}$>`NbrQ`yI*oYLV1~r`=kYhmacG@LKBpAY)N0(pDKi5&^xKp%nxuGW=FEpRW zX)?g^3Yv{;(3{Q_oGris^32l(KiCxb(_k8JX=RcTcP|bP?>*EZ12JbH`WJ=r9AhBr z%p9L%HYvzxQ#)i|xt)&Nm$l6%^L}hI9}9@lLOnh^o^D56dTo8MQDd-=0$)yTzcCx_ ztwY$ZWlR9E1y{M;S0@)VamuhwTC3n*TQeD3u-$K-nBfUDIFf(uduX^Jv`WZU#$}t# zIIfmUG_sQI2$()NU(=LkNLK4atwfwHhpz+4HmS&Jryp+dL^|F{?)bWC%J zaS*?}Bz>3@>eAaBDZ87|_xaH1>SH#=kPBKYk{EU3#00z9())A09M18^kUpOv4eF<-ly~fglTrkkzZx zqIvBiPo{c-Qovc!mm*-rj0sHty6Kf|MN$lPtPPsBL25TzON49E%$frZ>XhXj+vE(b4-eWC1lc6%k z#AQnBqpGYY!%W$!8gq{+S!sVzsF+Z{zenr(d_)Vx_9(y+C>pS@nks&M*68`~_`7c$WZL|Y22(ra4EAq`_% zt_?Mnd&UCfXZakG_zNjY)HCm$F64R{e=X_mJSBBh9x|PswToje$^Op+n{p^s3bPaYc!mn(YGNu7NVFVAb@Z!ukt|54({nV_`hW9IxY3B9NGp*P!D1W{t`XKHgo|#16u4dtTbfy4@ z&@+7ZfdY;Kx0pC-7io{e&$dDJLyF<<{EyJyig-wO3xK5O*eKEr91FSfgB)0@JzCzUg||5X83h(b(INS+3PQre3X1t zAKg4v>^x#znqHq}3U5>kxE>&(nIehb!q4?oq?9E&q^;on1mUUUcrQFcJVzM87j*sm z<8O5o`mIW>1|bF2MtK^uwQcedTI!kkX{cyaCMpIM_7T>pFZe1sB>BZ(@Diov>NkRq z_&8ODANjngtG!Ws?Ta{z_^0uwS*MYwC6?+Kx{NZ9W|caX!bd>% zLiR}e3H!rQ@v?YA5y2~)kcEIA&wq>hR9zqATX$gpwf&R)rxeai&L&O{PD4&Sv73bE57H3!P}APTbA{NS^}fPf~^p= z4A#({Up;AJQm!$UE88yj+P}c(!Tx0i`8eaaEY>Mjgy@>6f!K;DugN77Dr2YGPt^zQ z?>aIaEYJbnk@j=wnt{ht3(qP_#AZC3*8;9)T2xQ5Ht04Gc24i`9||3UY2>54qZ&`| zzIFG!*bp8tdUj=Lwr*epH%BAvC;Vhst7s<6WZ=@kseu9pMz$|(BVz1sLw9HExZRS( zt;BH%Z`ZsL!}n=(rYi^iH^$=^Jv*E_rryd=9Mv6OKBIjmNi(iHwJ2qglfZB%8eerg z>2}qNrLd%6#ed-Lo1HHWf zVkmVr)sBX1iLc+h){Kj1)n@h5=+W#{AJx2}?x&7d*Tx%*n$*l|;7oDA$MUSaTr7S9 zo^B<(Z>F@P=2kz_&wUn`G{0Z7`p)ecv80bsc@NQQa#w_hi}(Bt({aqXwi2RFA_ z7ZEU?(hzl+yx4VlF@D2h5A!VvfDeBfKBR{4u!uoxRcf(l%~8OWpMCInir5U9FG;W`cfMz<=P(dv4fFD_Y4ZFY`K=Z9+2M3W z#8{>I``tD7usYt|RKR9>p+&o+t; zn4Na)bYyW{o3gH{uz!!|zccg|)%P*>BV2GYd1b~V;P(Dh|4#T? z%!S5JelOOt-gUnpZ=P`q8YYjY?4(iPVdj1n^m>6NCs- zTf4TAM9_@TeAL~R=rMYAWL&yfI#sGIKDq13+}&=MqYqzI{mzvw>q!>8+ylt)$!>h? z-s4@*W|W)oi47=S7Cy?^c|K?wYnnGUITrK0P|D6v$8TmicsT3)zFy+Zo4bg*UrDms;sV@?hCwJ6#DwE4 zF3LFSgU{kke#dtv<(C7dkA`SU&fHP#_mw%aJwmU}lw^wt= zEeSQzHi?mSsuIU(`&ICEDnbe2vPP}OC(Q~?0&X8S<@;p2^GhENKRg%FeChl1-L#>y zoItO`MjKR5#}|j8l?=t})5T4)2V*-5{t0{o&fAk zpvA9{6NvuB#Q!ih7ASE8V`Ya_aq$M4fk+{eC-)CX_8^fr7$~7_XN4uf^9iDpvi%0g z;FIuwXH-SHBOP6A{>FFgPj3GQ%faBEZ(MXjqTvQWBa9o?&czuB28n~A;xM53MHLS_ zM=Ky$MgszpG`|GY_dvS2`vOVa{sZ;B-7o$68t|_z!$q|#5D;lAAq##X$op5ApavLNxb1=_51Tb^Y?bcSkr@mU`cw= z|6M>S7)%ldvn;zFvLlgc11q?WU_3#o+u?yHfCNDd6MvbKiEqDZSGg@R#V5Njk71OXv2V5AfT g1BEKk|L>5$ixBHhDvck12 Date: Tue, 6 May 2025 06:46:51 +0400 Subject: [PATCH 29/29] Fix text formatting when pasting from notes on iPad --- .../Pasteboard/Sources/Pasteboard.swift | 32 +++++++++++++------ submodules/TelegramUI/BUILD | 1 + .../Sources/ChatTextInputPanelNode.swift | 10 ++++-- 3 files changed, 32 insertions(+), 11 deletions(-) diff --git a/submodules/Pasteboard/Sources/Pasteboard.swift b/submodules/Pasteboard/Sources/Pasteboard.swift index bbd784a466..7ffa9fc9d4 100644 --- a/submodules/Pasteboard/Sources/Pasteboard.swift +++ b/submodules/Pasteboard/Sources/Pasteboard.swift @@ -53,15 +53,29 @@ private func chatInputStateString(attributedString: NSAttributedString) -> NSAtt } if let value = attributes[.font], let font = value as? UIFont { let fontName = font.fontName.lowercased() - if fontName.contains("bolditalic") { - string.addAttribute(ChatTextInputAttributes.bold, value: true as NSNumber, range: range) - string.addAttribute(ChatTextInputAttributes.italic, value: true as NSNumber, range: range) - } else if fontName.contains("bold") { - string.addAttribute(ChatTextInputAttributes.bold, value: true as NSNumber, range: range) - } else if fontName.contains("italic") { - string.addAttribute(ChatTextInputAttributes.italic, value: true as NSNumber, range: range) - } else if fontName.contains("menlo") || fontName.contains("courier") || fontName.contains("sfmono") { - string.addAttribute(ChatTextInputAttributes.monospace, value: true as NSNumber, range: range) + if fontName.hasPrefix(".sfui") { + let traits = font.fontDescriptor.symbolicTraits + if traits.contains(.traitMonoSpace) { + string.addAttribute(ChatTextInputAttributes.monospace, value: true as NSNumber, range: range) + } else { + if traits.contains(.traitBold) { + string.addAttribute(ChatTextInputAttributes.bold, value: true as NSNumber, range: range) + } + if traits.contains(.traitItalic) { + string.addAttribute(ChatTextInputAttributes.italic, value: true as NSNumber, range: range) + } + } + } else { + if fontName.contains("bolditalic") { + string.addAttribute(ChatTextInputAttributes.bold, value: true as NSNumber, range: range) + string.addAttribute(ChatTextInputAttributes.italic, value: true as NSNumber, range: range) + } else if fontName.contains("bold") { + string.addAttribute(ChatTextInputAttributes.bold, value: true as NSNumber, range: range) + } else if fontName.contains("italic") { + string.addAttribute(ChatTextInputAttributes.italic, value: true as NSNumber, range: range) + } else if fontName.contains("menlo") || fontName.contains("courier") || fontName.contains("sfmono") { + string.addAttribute(ChatTextInputAttributes.monospace, value: true as NSNumber, range: range) + } } } if let value = attributes[.backgroundColor] as? UIColor, value.rgb == UIColor.gray.rgb { diff --git a/submodules/TelegramUI/BUILD b/submodules/TelegramUI/BUILD index 3f0e1ae3d6..4c64d17ab7 100644 --- a/submodules/TelegramUI/BUILD +++ b/submodules/TelegramUI/BUILD @@ -70,6 +70,7 @@ swift_library( "//submodules/MediaPlayer:UniversalMediaPlayer", "//submodules/TelegramVoip:TelegramVoip", "//submodules/DeviceAccess:DeviceAccess", + "//submodules/Utils/DeviceModel", "//submodules/WatchCommon/Host:WatchCommon", "//submodules/BuildConfig:BuildConfig", "//submodules/BuildConfigExtra:BuildConfigExtra", diff --git a/submodules/TelegramUI/Sources/ChatTextInputPanelNode.swift b/submodules/TelegramUI/Sources/ChatTextInputPanelNode.swift index 193ccfa97b..1e884bf566 100644 --- a/submodules/TelegramUI/Sources/ChatTextInputPanelNode.swift +++ b/submodules/TelegramUI/Sources/ChatTextInputPanelNode.swift @@ -1,4 +1,5 @@ import Foundation +import UniformTypeIdentifiers import UIKit import Display import AsyncDisplayKit @@ -44,6 +45,7 @@ import TelegramNotices import AnimatedCountLabelNode import TelegramStringFormatting import TextNodeWithEntities +import DeviceModel private let accessoryButtonFont = Font.medium(14.0) private let counterFont = Font.with(size: 14.0, design: .regular, traits: [.monospacedNumbers]) @@ -4473,10 +4475,14 @@ class ChatTextInputPanelNode: ChatInputPanelNode, ASEditableTextNodeDelegate, Ch var attributedString: NSAttributedString? if let data = pasteboard.data(forPasteboardType: "private.telegramtext"), let value = chatInputStateStringFromAppSpecificString(data: data) { attributedString = value - } else if let data = pasteboard.data(forPasteboardType: kUTTypeRTF as String) { + } else if let data = pasteboard.data(forPasteboardType: "public.rtf") { attributedString = chatInputStateStringFromRTF(data, type: NSAttributedString.DocumentType.rtf) } else if let data = pasteboard.data(forPasteboardType: "com.apple.flat-rtfd") { - attributedString = chatInputStateStringFromRTF(data, type: NSAttributedString.DocumentType.rtfd) + if let _ = pasteboard.data(forPasteboardType: "com.apple.notes.richtext"), DeviceModel.current.isIpad, let htmlData = pasteboard.data(forPasteboardType: "public.html") { + attributedString = chatInputStateStringFromRTF(htmlData, type: NSAttributedString.DocumentType.html) + } else { + attributedString = chatInputStateStringFromRTF(data, type: NSAttributedString.DocumentType.rtfd) + } } if let attributedString = attributedString {