From 3d509c7bdac108cf7e97af396b271d30a6eb2830 Mon Sep 17 00:00:00 2001 From: Isaac <> Date: Wed, 25 Sep 2024 01:14:28 +0800 Subject: [PATCH 1/9] Video chat improvements --- .../Sources/PresentationCallManager.swift | 5 +- submodules/TelegramCallsUI/BUILD | 1 + .../Sources/PresentationGroupCall.swift | 7 +- .../ScheduleVideoChatSheetScreen.swift | 78 ++++++++++++- .../VideoChatActionButtonComponent.swift | 23 +++- .../Sources/VideoChatMuteIconComponent.swift | 6 +- .../VideoChatParticipantAvatarComponent.swift | 28 ++++- .../VideoChatParticipantStatusComponent.swift | 4 +- .../VideoChatParticipantVideoComponent.swift | 5 +- .../VideoChatParticipantsComponent.swift | 8 ++ .../VideoChatScheduledInfoComponent.swift | 67 +++++++++-- .../Sources/VideoChatScreen.swift | 107 +++++++++++++++++- .../Sources/PeerListItemComponent.swift | 4 +- 13 files changed, 315 insertions(+), 28 deletions(-) diff --git a/submodules/AccountContext/Sources/PresentationCallManager.swift b/submodules/AccountContext/Sources/PresentationCallManager.swift index d4d605a69a..c285caa029 100644 --- a/submodules/AccountContext/Sources/PresentationCallManager.swift +++ b/submodules/AccountContext/Sources/PresentationCallManager.swift @@ -212,6 +212,7 @@ public struct PresentationGroupCallState: Equatable { public var subscribedToScheduled: Bool public var isVideoEnabled: Bool public var isVideoWatchersLimitReached: Bool + public var hasVideo: Bool public init( myPeerId: EnginePeer.Id, @@ -226,7 +227,8 @@ public struct PresentationGroupCallState: Equatable { scheduleTimestamp: Int32?, subscribedToScheduled: Bool, isVideoEnabled: Bool, - isVideoWatchersLimitReached: Bool + isVideoWatchersLimitReached: Bool, + hasVideo: Bool ) { self.myPeerId = myPeerId self.networkState = networkState @@ -241,6 +243,7 @@ public struct PresentationGroupCallState: Equatable { self.subscribedToScheduled = subscribedToScheduled self.isVideoEnabled = isVideoEnabled self.isVideoWatchersLimitReached = isVideoWatchersLimitReached + self.hasVideo = hasVideo } } diff --git a/submodules/TelegramCallsUI/BUILD b/submodules/TelegramCallsUI/BUILD index eb0553d7a1..55988887b0 100644 --- a/submodules/TelegramCallsUI/BUILD +++ b/submodules/TelegramCallsUI/BUILD @@ -115,6 +115,7 @@ swift_library( "//submodules/TelegramUI/Components/LottieComponent", "//submodules/TelegramUI/Components/Stories/PeerListItemComponent", "//submodules/TelegramUI/Components/BackButtonComponent", + "//submodules/TelegramUI/Components/AnimatedTextComponent", "//submodules/DirectMediaImageCache", "//submodules/FastBlur", ], diff --git a/submodules/TelegramCallsUI/Sources/PresentationGroupCall.swift b/submodules/TelegramCallsUI/Sources/PresentationGroupCall.swift index 4bcace59a3..f990bdf289 100644 --- a/submodules/TelegramCallsUI/Sources/PresentationGroupCall.swift +++ b/submodules/TelegramCallsUI/Sources/PresentationGroupCall.swift @@ -268,7 +268,8 @@ private extension PresentationGroupCallState { scheduleTimestamp: scheduleTimestamp, subscribedToScheduled: subscribedToScheduled, isVideoEnabled: false, - isVideoWatchersLimitReached: false + isVideoWatchersLimitReached: false, + hasVideo: false ) } } @@ -2971,11 +2972,12 @@ public final class PresentationGroupCallImpl: PresentationGroupCall { self.updateLocalVideoState() } + self.stateValue.hasVideo = self.hasVideo } public func disableVideo() { self.hasVideo = false - self.useFrontCamera = true; + self.useFrontCamera = true if let _ = self.videoCapturer { self.videoCapturer = nil self.isVideoMutedDisposable.set(nil) @@ -2984,6 +2986,7 @@ public final class PresentationGroupCallImpl: PresentationGroupCall { self.updateLocalVideoState() } + self.stateValue.hasVideo = self.hasVideo } private func updateLocalVideoState() { diff --git a/submodules/TelegramCallsUI/Sources/ScheduleVideoChatSheetScreen.swift b/submodules/TelegramCallsUI/Sources/ScheduleVideoChatSheetScreen.swift index 08b7019d97..a6445e05ff 100644 --- a/submodules/TelegramCallsUI/Sources/ScheduleVideoChatSheetScreen.swift +++ b/submodules/TelegramCallsUI/Sources/ScheduleVideoChatSheetScreen.swift @@ -13,6 +13,10 @@ import BalancedTextComponent import TelegramPresentationData import TelegramStringFormatting import Markdown +import HierarchyTrackingLayer + +private let purple = UIColor(rgb: 0x3252ef) +private let pink = UIColor(rgb: 0xef436c) private final class ScheduleVideoChatSheetContentComponent: Component { typealias EnvironmentType = ViewControllerComponentContainer.Environment @@ -33,7 +37,11 @@ private final class ScheduleVideoChatSheetContentComponent: Component { } final class View: UIView { + private let hierarchyTrackingLayer: HierarchyTrackingLayer + private let button = ComponentView() + private let buttonBackgroundLayer: SimpleGradientLayer + private let cancelButton = ComponentView() private let title = ComponentView() @@ -52,7 +60,29 @@ private final class ScheduleVideoChatSheetContentComponent: Component { self.dateFormatter.dateStyle = .short self.dateFormatter.timeZone = TimeZone.current + self.hierarchyTrackingLayer = HierarchyTrackingLayer() + + self.buttonBackgroundLayer = SimpleGradientLayer() + self.buttonBackgroundLayer.type = .radial + self.buttonBackgroundLayer.colors = [pink.cgColor, purple.cgColor, purple.cgColor] + self.buttonBackgroundLayer.locations = [0.0, 0.85, 1.0] + self.buttonBackgroundLayer.startPoint = CGPoint(x: 1.0, y: 0.0) + let radius = CGSize(width: 1.0, height: 2.0) + let endEndPoint = CGPoint(x: (self.buttonBackgroundLayer.startPoint.x + radius.width) * 1.0, y: (self.buttonBackgroundLayer.startPoint.y + radius.height) * 1.0) + self.buttonBackgroundLayer.endPoint = endEndPoint + self.buttonBackgroundLayer.cornerRadius = 10.0 + super.init(frame: frame) + + self.layer.addSublayer(self.hierarchyTrackingLayer) + self.hierarchyTrackingLayer.isInHierarchyUpdated = { [weak self] value in + guard let self else { + return + } + if value { + self.updateAnimations() + } + } } required init?(coder: NSCoder) { @@ -96,6 +126,46 @@ private final class ScheduleVideoChatSheetContentComponent: Component { } } + private func updateAnimations() { + if let _ = self.buttonBackgroundLayer.animation(forKey: "movement") { + } else { + let previousValue = self.buttonBackgroundLayer.startPoint + let previousEndValue = self.buttonBackgroundLayer.endPoint + let newValue = CGPoint(x: CGFloat.random(in: 0.65 ..< 0.85), y: CGFloat.random(in: 0.1 ..< 0.45)) + self.buttonBackgroundLayer.startPoint = newValue + + let radius = CGSize(width: 1.0, height: 2.0) + let newEndValue = CGPoint(x: (self.buttonBackgroundLayer.startPoint.x + radius.width) * 1.0, y: (self.buttonBackgroundLayer.startPoint.y + radius.height) * 1.0) + + CATransaction.begin() + + let animation = CABasicAnimation(keyPath: "startPoint") + animation.duration = Double.random(in: 0.8 ..< 1.4) + animation.fromValue = previousValue + animation.toValue = newValue + + CATransaction.setCompletionBlock { [weak self] in + guard let self else { + return + } + if self.hierarchyTrackingLayer.isInHierarchy { + self.updateAnimations() + } + } + + self.buttonBackgroundLayer.add(animation, forKey: "movement") + + let endAnimation = CABasicAnimation(keyPath: "endPoint") + endAnimation.duration = animation.duration + endAnimation.fromValue = previousEndValue + endAnimation.toValue = newEndValue + + self.buttonBackgroundLayer.add(animation, forKey: "movementEnd") + + CATransaction.commit() + } + } + func update(component: ScheduleVideoChatSheetContentComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { let previousComponent = self.component let _ = previousComponent @@ -233,9 +303,9 @@ private final class ScheduleVideoChatSheetContentComponent: Component { transition: buttonTransition, component: AnyComponent(ButtonComponent( background: ButtonComponent.Background( - color: UIColor(rgb: 0x3252EF), + color: .clear, foreground: .white, - pressedColor: UIColor(rgb: 0x3252EF).withMultipliedAlpha(0.8) + pressedColor: UIColor(white: 1.0, alpha: 0.1) ), content: AnyComponentWithIdentity(id: AnyHashable(0 as Int), component: AnyComponent( HStack(buttonContents, spacing: 5.0) @@ -256,8 +326,10 @@ private final class ScheduleVideoChatSheetContentComponent: Component { let buttonFrame = CGRect(origin: CGPoint(x: sideInset, y: contentHeight), size: buttonSize) if let buttonView = self.button.view { if buttonView.superview == nil { + self.layer.addSublayer(self.buttonBackgroundLayer) self.addSubview(buttonView) } + transition.setFrame(layer: self.buttonBackgroundLayer, frame: buttonFrame) transition.setFrame(view: buttonView, frame: buttonFrame) } contentHeight += buttonSize.height @@ -302,6 +374,8 @@ private final class ScheduleVideoChatSheetContentComponent: Component { contentHeight += environment.safeInsets.bottom + 14.0 } + self.updateAnimations() + return CGSize(width: availableSize.width, height: contentHeight) } } diff --git a/submodules/TelegramCallsUI/Sources/VideoChatActionButtonComponent.swift b/submodules/TelegramCallsUI/Sources/VideoChatActionButtonComponent.swift index 8d6b3f69bd..435bdecdaf 100644 --- a/submodules/TelegramCallsUI/Sources/VideoChatActionButtonComponent.swift +++ b/submodules/TelegramCallsUI/Sources/VideoChatActionButtonComponent.swift @@ -34,11 +34,13 @@ final class VideoChatActionButtonComponent: Component { case audio(audio: Audio) case video case leave + case switchVideo } case audio(audio: Audio) case video(isActive: Bool) case leave + case switchVideo fileprivate var iconType: IconType { switch self { @@ -57,6 +59,8 @@ final class VideoChatActionButtonComponent: Component { return .video case .leave: return .leave + case .switchVideo: + return .switchVideo } } } @@ -174,6 +178,19 @@ final class VideoChatActionButtonComponent: Component { backgroundColor = UIColor(rgb: 0x3252EF) } iconDiameter = 60.0 + case .switchVideo: + titleText = "" + switch component.microphoneState { + case .connecting: + backgroundColor = UIColor(white: 0.1, alpha: 1.0) + case .muted: + backgroundColor = UIColor(rgb: 0x027FFF) + case .unmuted: + backgroundColor = UIColor(rgb: 0x34C659) + case .raiseHand, .scheduled: + backgroundColor = UIColor(rgb: 0x3252EF) + } + iconDiameter = 54.0 case .leave: titleText = "leave" backgroundColor = UIColor(rgb: 0x47191E) @@ -204,6 +221,8 @@ final class VideoChatActionButtonComponent: Component { self.contentImage = UIImage(bundleImageName: iconName)?.precomposed().withRenderingMode(.alwaysTemplate) case .video: self.contentImage = UIImage(bundleImageName: "Call/CallCameraButton")?.precomposed().withRenderingMode(.alwaysTemplate) + case .switchVideo: + self.contentImage = UIImage(bundleImageName: "Call/CallSwitchCameraButton")?.precomposed().withRenderingMode(.alwaysTemplate) case .leave: self.contentImage = generateImage(CGSize(width: 28.0, height: 28.0), opaque: false, rotatedContext: { size, context in let bounds = CGRect(origin: CGPoint(), size: size) @@ -275,7 +294,9 @@ final class VideoChatActionButtonComponent: Component { if iconView.superview == nil { self.addSubview(iconView) } - transition.setFrame(view: iconView, frame: iconFrame) + transition.setPosition(view: iconView, position: iconFrame.center) + transition.setBounds(view: iconView, bounds: CGRect(origin: CGPoint(), size: iconFrame.size)) + transition.setScale(view: iconView, scale: availableSize.width / 56.0) } return size diff --git a/submodules/TelegramCallsUI/Sources/VideoChatMuteIconComponent.swift b/submodules/TelegramCallsUI/Sources/VideoChatMuteIconComponent.swift index 3bc92c4bcf..f50c490402 100644 --- a/submodules/TelegramCallsUI/Sources/VideoChatMuteIconComponent.swift +++ b/submodules/TelegramCallsUI/Sources/VideoChatMuteIconComponent.swift @@ -62,6 +62,7 @@ final class VideoChatMuteIconComponent: Component { self.isUpdating = false } + let previousComponent = self.component self.component = component if case let .mute(isFilled, isMuted) = component.content { @@ -77,7 +78,10 @@ final class VideoChatMuteIconComponent: Component { let animationSize = availableSize let animationFrame = animationSize.centered(in: CGRect(origin: CGPoint(), size: availableSize)) transition.setFrame(view: icon.view, frame: animationFrame) - icon.update(state: VoiceChatMicrophoneNode.State(muted: isMuted, filled: isFilled, color: component.color), animated: !transition.animation.isImmediate) + if let previousComponent, previousComponent.content == component.content, previousComponent.color == component.color { + } else { + icon.update(state: VoiceChatMicrophoneNode.State(muted: isMuted, filled: isFilled, color: component.color), animated: !transition.animation.isImmediate) + } } else { if let icon = self.icon { self.icon = nil diff --git a/submodules/TelegramCallsUI/Sources/VideoChatParticipantAvatarComponent.swift b/submodules/TelegramCallsUI/Sources/VideoChatParticipantAvatarComponent.swift index 834fc03318..bcc2c08954 100644 --- a/submodules/TelegramCallsUI/Sources/VideoChatParticipantAvatarComponent.swift +++ b/submodules/TelegramCallsUI/Sources/VideoChatParticipantAvatarComponent.swift @@ -136,6 +136,7 @@ final class VideoChatParticipantAvatarComponent: Component { let peer: EnginePeer let myPeerId: EnginePeer.Id let isSpeaking: Bool + let isMutedForMe: Bool let theme: PresentationTheme init( @@ -143,12 +144,14 @@ final class VideoChatParticipantAvatarComponent: Component { peer: EnginePeer, myPeerId: EnginePeer.Id, isSpeaking: Bool, + isMutedForMe: Bool, theme: PresentationTheme ) { self.call = call self.peer = peer self.myPeerId = myPeerId self.isSpeaking = isSpeaking + self.isMutedForMe = isMutedForMe self.theme = theme } @@ -159,10 +162,13 @@ final class VideoChatParticipantAvatarComponent: Component { if lhs.peer != rhs.peer { return false } + if lhs.myPeerId != rhs.myPeerId { + return false + } if lhs.isSpeaking != rhs.isSpeaking { return false } - if lhs.myPeerId != rhs.myPeerId { + if lhs.isMutedForMe != rhs.isMutedForMe { return false } if lhs.theme !== rhs.theme { @@ -259,7 +265,15 @@ final class VideoChatParticipantAvatarComponent: Component { } else { tintTransition = .immediate } - tintTransition.setTintColor(layer: blobView.blobsLayer, color: component.isSpeaking ? UIColor(rgb: 0x33C758) : component.theme.list.itemAccentColor) + let tintColor: UIColor + if component.isMutedForMe { + tintColor = UIColor(rgb: 0xff3b30) + } else if component.isSpeaking { + tintColor = UIColor(rgb: 0x33C758) + } else { + tintColor = component.theme.list.itemAccentColor + } + tintTransition.setTintColor(layer: blobView.blobsLayer, color: tintColor) } if component.peer.smallProfileImage != nil { @@ -362,7 +376,15 @@ final class VideoChatParticipantAvatarComponent: Component { avatarNode.layer.transform = CATransform3DMakeScale(1.0 + additionalScale, 1.0 + additionalScale, 1.0) } - ComponentTransition.immediate.setTintColor(layer: blobView.blobsLayer, color: component.isSpeaking ? UIColor(rgb: 0x33C758) : component.theme.list.itemAccentColor) + let tintColor: UIColor + if component.isMutedForMe { + tintColor = UIColor(rgb: 0xff3b30) + } else if component.isSpeaking { + tintColor = UIColor(rgb: 0x33C758) + } else { + tintColor = component.theme.list.itemAccentColor + } + ComponentTransition.immediate.setTintColor(layer: blobView.blobsLayer, color: tintColor) } if blobView.alpha == 0.0 { diff --git a/submodules/TelegramCallsUI/Sources/VideoChatParticipantStatusComponent.swift b/submodules/TelegramCallsUI/Sources/VideoChatParticipantStatusComponent.swift index 5b480bac46..7375afb149 100644 --- a/submodules/TelegramCallsUI/Sources/VideoChatParticipantStatusComponent.swift +++ b/submodules/TelegramCallsUI/Sources/VideoChatParticipantStatusComponent.swift @@ -121,7 +121,9 @@ final class VideoChatParticipantStatusComponent: Component { } if let iconView = muteStatusView.iconView { let iconTintColor: UIColor - if component.isSpeaking { + if let muteState = component.muteState, muteState.mutedByYou { + iconTintColor = UIColor(rgb: 0xff3b30) + } else if component.isSpeaking { iconTintColor = UIColor(rgb: 0x33C758) } else { if let muteState = component.muteState { diff --git a/submodules/TelegramCallsUI/Sources/VideoChatParticipantVideoComponent.swift b/submodules/TelegramCallsUI/Sources/VideoChatParticipantVideoComponent.swift index d295c40a7f..7fd4ae4af7 100644 --- a/submodules/TelegramCallsUI/Sources/VideoChatParticipantVideoComponent.swift +++ b/submodules/TelegramCallsUI/Sources/VideoChatParticipantVideoComponent.swift @@ -352,7 +352,10 @@ final class VideoChatParticipantVideoComponent: Component { alphaTransition.setAlpha(view: titleView, alpha: controlsAlpha) } - let videoDescription = component.isPresentation ? component.participant.presentationDescription : component.participant.videoDescription + var videoDescription = component.isPresentation ? component.participant.presentationDescription : component.participant.videoDescription + if component.isPresentation && component.isMyPeer { + videoDescription = nil + } var isEffectivelyPaused = false if let videoDescription, videoDescription.isPaused { diff --git a/submodules/TelegramCallsUI/Sources/VideoChatParticipantsComponent.swift b/submodules/TelegramCallsUI/Sources/VideoChatParticipantsComponent.swift index dda1b187c8..e7e0c24831 100644 --- a/submodules/TelegramCallsUI/Sources/VideoChatParticipantsComponent.swift +++ b/submodules/TelegramCallsUI/Sources/VideoChatParticipantsComponent.swift @@ -1154,9 +1154,16 @@ final class VideoChatParticipantsComponent: Component { let itemFrame = itemLayout.listItemFrame(at: i) + var isMutedForMe = false + if let muteState = participant.muteState, muteState.mutedByYou { + isMutedForMe = true + } + let subtitle: PeerListItemComponent.Subtitle if participant.peer.id == component.call.accountContext.account.peerId { subtitle = PeerListItemComponent.Subtitle(text: "this is you", color: .accent) + } else if let muteState = participant.muteState, muteState.mutedByYou { + subtitle = PeerListItemComponent.Subtitle(text: "muted for you", color: .destructive) } else if component.speakingParticipants.contains(participant.peer.id) { if let volume = participant.volume, volume != 10000 { subtitle = PeerListItemComponent.Subtitle(text: "\(volume / 100)% speaking", color: .constructive) @@ -1190,6 +1197,7 @@ final class VideoChatParticipantsComponent: Component { peer: EnginePeer(participant.peer), myPeerId: component.participants?.myPeerId ?? component.call.accountContext.account.peerId, isSpeaking: component.speakingParticipants.contains(participant.peer.id), + isMutedForMe: isMutedForMe, theme: component.theme )), peer: EnginePeer(participant.peer), diff --git a/submodules/TelegramCallsUI/Sources/VideoChatScheduledInfoComponent.swift b/submodules/TelegramCallsUI/Sources/VideoChatScheduledInfoComponent.swift index 3c97c76306..47e5a2407b 100644 --- a/submodules/TelegramCallsUI/Sources/VideoChatScheduledInfoComponent.swift +++ b/submodules/TelegramCallsUI/Sources/VideoChatScheduledInfoComponent.swift @@ -6,6 +6,7 @@ import MultilineTextComponent import TelegramPresentationData import TelegramStringFormatting import HierarchyTrackingLayer +import AnimatedTextComponent private let purple = UIColor(rgb: 0x3252ef) private let pink = UIColor(rgb: 0xef436c) @@ -13,6 +14,33 @@ private let pink = UIColor(rgb: 0xef436c) private let latePurple = UIColor(rgb: 0x974aa9) private let latePink = UIColor(rgb: 0xf0436c) +private func textItemsForTimeout(value: Int32) -> [AnimatedTextComponent.Item] { + if value < 3600 { + let minutes = value / 60 + let seconds = value % 60 + + var items: [AnimatedTextComponent.Item] = [] + items.append(AnimatedTextComponent.Item(id: AnyHashable(11), content: .number(Int(minutes), minDigits: 1))) + items.append(AnimatedTextComponent.Item(id: AnyHashable(12), content: .text(":"))) + items.append(AnimatedTextComponent.Item(id: AnyHashable(13), content: .number(Int(seconds), minDigits: 2))) + + return items + } else { + let hours = value / 3600 + let minutes = (value % 3600) / 60 + let seconds = value % 60 + + var items: [AnimatedTextComponent.Item] = [] + items.append(AnimatedTextComponent.Item(id: AnyHashable(9), content: .number(Int(hours), minDigits: 1))) + items.append(AnimatedTextComponent.Item(id: AnyHashable(10), content: .text(":"))) + items.append(AnimatedTextComponent.Item(id: AnyHashable(11), content: .number(Int(minutes), minDigits: 2))) + items.append(AnimatedTextComponent.Item(id: AnyHashable(12), content: .text(":"))) + items.append(AnimatedTextComponent.Item(id: AnyHashable(13), content: .number(Int(seconds), minDigits: 2))) + + return items + } +} + final class VideoChatScheduledInfoComponent: Component { let timestamp: Int32 let strings: PresentationStrings @@ -46,8 +74,11 @@ final class VideoChatScheduledInfoComponent: Component { private let hierarchyTrackingLayer: HierarchyTrackingLayer private var component: VideoChatScheduledInfoComponent? + private weak var state: EmptyComponentState? private var isUpdating: Bool = false + private var countdownTimer: Foundation.Timer? + override init(frame: CGRect) { self.countdownContainerView = UIView() self.countdownMaskView = UIView() @@ -76,6 +107,9 @@ final class VideoChatScheduledInfoComponent: Component { } if value { self.updateAnimations() + } else { + self.countdownTimer?.invalidate() + self.countdownTimer = nil } } } @@ -84,6 +118,10 @@ final class VideoChatScheduledInfoComponent: Component { fatalError("init(coder:) has not been implemented") } + deinit { + self.countdownTimer?.invalidate() + } + private func updateAnimations() { if let _ = self.countdownGradientLayer.animation(forKey: "movement") { } else { @@ -110,6 +148,15 @@ final class VideoChatScheduledInfoComponent: Component { self.countdownGradientLayer.add(animation, forKey: "movement") CATransaction.commit() } + + if self.countdownTimer == nil { + self.countdownTimer = Foundation.Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true, block: { [weak self] _ in + guard let self else { + return + } + self.state?.updated(transition: .easeInOut(duration: 0.2)) + }) + } } func update(component: VideoChatScheduledInfoComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { @@ -119,6 +166,7 @@ final class VideoChatScheduledInfoComponent: Component { } self.component = component + self.state = state let titleSize = self.title.update( transition: .immediate, @@ -130,21 +178,20 @@ final class VideoChatScheduledInfoComponent: Component { ) let remainingSeconds: Int32 = max(0, component.timestamp - Int32(Date().timeIntervalSince1970)) - let countdownText: String + var items: [AnimatedTextComponent.Item] = [] if remainingSeconds >= 86400 { - countdownText = scheduledTimeIntervalString(strings: component.strings, value: remainingSeconds) + let countdownText = scheduledTimeIntervalString(strings: component.strings, value: remainingSeconds) + items.append(AnimatedTextComponent.Item(id: AnyHashable(0), content: .text(countdownText))) } else { - countdownText = textForTimeout(value: abs(remainingSeconds)) - /*if remainingSeconds < 0 && !self.isLate { - self.isLate = true - self.foregroundGradientLayer.colors = [latePink.cgColor, latePurple.cgColor, latePurple.cgColor] - }*/ + items = textItemsForTimeout(value: remainingSeconds) } let countdownTextSize = self.countdownText.update( - transition: .immediate, - component: AnyComponent(MultilineTextComponent( - text: .plain(NSAttributedString(string: countdownText, font: Font.with(size: 68.0, design: .round, weight: .semibold, traits: [.monospacedNumbers]), textColor: .white)) + transition: transition, + component: AnyComponent(AnimatedTextComponent( + font: Font.with(size: 68.0, design: .round, weight: .semibold, traits: [.monospacedNumbers]), + color: .white, + items: items )), environment: {}, containerSize: CGSize(width: availableSize.width - 16.0 * 2.0, height: 400.0) diff --git a/submodules/TelegramCallsUI/Sources/VideoChatScreen.swift b/submodules/TelegramCallsUI/Sources/VideoChatScreen.swift index ff0435974f..2bc963014d 100644 --- a/submodules/TelegramCallsUI/Sources/VideoChatScreen.swift +++ b/submodules/TelegramCallsUI/Sources/VideoChatScreen.swift @@ -76,6 +76,7 @@ final class VideoChatScreenComponent: Component { var navigationSidebarButton: ComponentView? let videoButton = ComponentView() + var switchVideoButton: ComponentView? let leaveButton = ComponentView() let microphoneButton = ComponentView() @@ -1316,10 +1317,17 @@ final class VideoChatScreenComponent: Component { } } + let actionButtonPlacementArea: (x: CGFloat, width: CGFloat) + if isTwoColumnLayout { + actionButtonPlacementArea = (availableSize.width - sideInset - mainColumnWidth, mainColumnWidth) + } else { + actionButtonPlacementArea = (0.0, availableSize.width) + } + let buttonsSideInset: CGFloat = 26.0 let buttonsWidth: CGFloat = actionButtonDiameter * 2.0 + microphoneButtonDiameter - let remainingButtonsSpace: CGFloat = availableSize.width - buttonsSideInset * 2.0 - buttonsWidth + let remainingButtonsSpace: CGFloat = actionButtonPlacementArea.width - buttonsSideInset * 2.0 - buttonsWidth let effectiveMaxActionMicrophoneButtonSpacing: CGFloat if areButtonsCollapsed { @@ -1355,7 +1363,7 @@ final class VideoChatScreenComponent: Component { } } - let microphoneButtonFrame: CGRect + var microphoneButtonFrame: CGRect if areButtonsCollapsed { microphoneButtonFrame = expandedMicrophoneButtonFrame } else { @@ -1376,8 +1384,41 @@ final class VideoChatScreenComponent: Component { expandedParticipantsClippingY = expandedMicrophoneButtonFrame.minY - 24.0 } - let leftActionButtonFrame = CGRect(origin: CGPoint(x: microphoneButtonFrame.minX - actionMicrophoneButtonSpacing - actionButtonDiameter, y: microphoneButtonFrame.minY + floor((microphoneButtonFrame.height - actionButtonDiameter) * 0.5)), size: CGSize(width: actionButtonDiameter, height: actionButtonDiameter)) - let rightActionButtonFrame = CGRect(origin: CGPoint(x: microphoneButtonFrame.maxX + actionMicrophoneButtonSpacing, y: microphoneButtonFrame.minY + floor((microphoneButtonFrame.height - actionButtonDiameter) * 0.5)), size: CGSize(width: actionButtonDiameter, height: actionButtonDiameter)) + var leftActionButtonFrame = CGRect(origin: CGPoint(x: microphoneButtonFrame.minX - actionMicrophoneButtonSpacing - actionButtonDiameter, y: microphoneButtonFrame.minY + floor((microphoneButtonFrame.height - actionButtonDiameter) * 0.5)), size: CGSize(width: actionButtonDiameter, height: actionButtonDiameter)) + var rightActionButtonFrame = CGRect(origin: CGPoint(x: microphoneButtonFrame.maxX + actionMicrophoneButtonSpacing, y: microphoneButtonFrame.minY + floor((microphoneButtonFrame.height - actionButtonDiameter) * 0.5)), size: CGSize(width: actionButtonDiameter, height: actionButtonDiameter)) + + var additionalLeftActionButtonFrame: CGRect? + if let callState = self.callState, callState.hasVideo { + let additionalButtonDiameter: CGFloat + if areButtonsCollapsed { + additionalButtonDiameter = actionButtonDiameter + } else { + additionalButtonDiameter = floor(actionButtonDiameter * 0.64) + } + + if areButtonsCollapsed { + let buttonCount: CGFloat = 4.0 + + let buttonsWidth: CGFloat = actionButtonDiameter * buttonCount + let remainingButtonsSpace: CGFloat = actionButtonPlacementArea.width - buttonsSideInset * 2.0 - buttonsWidth + let maxSpacing: CGFloat = 80.0 + let effectiveSpacing = min(maxSpacing, floor(remainingButtonsSpace / (buttonCount - 1.0))) + + let totalButtonsWidth: CGFloat = buttonsWidth + (buttonCount - 1.0) * effectiveSpacing + let totalButtonsX: CGFloat = actionButtonPlacementArea.x + floor((actionButtonPlacementArea.width - totalButtonsWidth) * 0.5) + additionalLeftActionButtonFrame = CGRect(origin: CGPoint(x: totalButtonsX + CGFloat(0.0) * (actionButtonDiameter + effectiveSpacing), y: leftActionButtonFrame.minY), size: CGSize(width: actionButtonDiameter, height: actionButtonDiameter)) + leftActionButtonFrame = CGRect(origin: CGPoint(x: totalButtonsX + CGFloat(1.0) * (actionButtonDiameter + effectiveSpacing), y: leftActionButtonFrame.minY), size: CGSize(width: actionButtonDiameter, height: actionButtonDiameter)) + microphoneButtonFrame = CGRect(origin: CGPoint(x: totalButtonsX + CGFloat(2.0) * (actionButtonDiameter + effectiveSpacing), y: leftActionButtonFrame.minY), size: CGSize(width: actionButtonDiameter, height: actionButtonDiameter)) + rightActionButtonFrame = CGRect(origin: CGPoint(x: totalButtonsX + CGFloat(3.0) * (actionButtonDiameter + effectiveSpacing), y: leftActionButtonFrame.minY), size: CGSize(width: actionButtonDiameter, height: actionButtonDiameter)) + } else { + let additionalButtonSpacing = 12.0 + let totalLeftButtonHeight: CGFloat = leftActionButtonFrame.height + additionalButtonSpacing + additionalButtonDiameter + let totalLeftButtonOriginY: CGFloat = leftActionButtonFrame.minY + floor((leftActionButtonFrame.height - totalLeftButtonHeight) * 0.5) + leftActionButtonFrame.origin.y = totalLeftButtonOriginY + additionalButtonDiameter + additionalButtonSpacing + + additionalLeftActionButtonFrame = CGRect(origin: CGPoint(x: leftActionButtonFrame.minX + floor((leftActionButtonFrame.width - additionalButtonDiameter) * 0.5), y: leftActionButtonFrame.minY - additionalButtonSpacing - additionalButtonDiameter), size: CGSize(width: additionalButtonDiameter, height: additionalButtonDiameter)) + } + } let participantsSize = availableSize @@ -1728,7 +1769,7 @@ final class VideoChatScreenComponent: Component { videoButtonContent = .audio(audio: buttonAudio) } else { //TODO:release - videoButtonContent = .video(isActive: false) + videoButtonContent = .video(isActive: self.callState?.hasVideo ?? false) } let _ = self.videoButton.update( transition: transition, @@ -1763,6 +1804,62 @@ final class VideoChatScreenComponent: Component { transition.setBounds(view: videoButtonView, bounds: CGRect(origin: CGPoint(), size: leftActionButtonFrame.size)) } + if let additionalLeftActionButtonFrame { + let switchVideoButton: ComponentView + var switchVideoButtonTransition = transition + if let current = self.switchVideoButton { + switchVideoButton = current + } else { + switchVideoButtonTransition = switchVideoButtonTransition.withAnimation(.none) + switchVideoButton = ComponentView() + self.switchVideoButton = switchVideoButton + } + + let switchVideoButtonContent: VideoChatActionButtonComponent.Content = .switchVideo + + let _ = switchVideoButton.update( + transition: switchVideoButtonTransition, + component: AnyComponent(PlainButtonComponent( + content: AnyComponent(VideoChatActionButtonComponent( + strings: environment.strings, + content: switchVideoButtonContent, + microphoneState: actionButtonMicrophoneState, + isCollapsed: areButtonsCollapsed + )), + effectAlignment: .center, + action: { [weak self] in + guard let self, let component = self.component else { + return + } + component.call.switchVideoCamera() + }, + animateAlpha: false + )), + environment: {}, + containerSize: additionalLeftActionButtonFrame.size + ) + if let switchVideoButtonView = switchVideoButton.view { + var animateIn = false + if switchVideoButtonView.superview == nil { + self.containerView.addSubview(switchVideoButtonView) + animateIn = true + } + switchVideoButtonTransition.setFrame(view: switchVideoButtonView, frame: additionalLeftActionButtonFrame) + if animateIn { + alphaTransition.animateAlpha(view: switchVideoButtonView, from: 0.0, to: 1.0) + transition.animateScale(view: switchVideoButtonView, from: 0.001, to: 1.0) + } + } + } else if let switchVideoButton = self.switchVideoButton { + self.switchVideoButton = nil + if let switchVideoButtonView = switchVideoButton.view { + alphaTransition.setAlpha(view: switchVideoButtonView, alpha: 0.0, completion: { [weak switchVideoButtonView] _ in + switchVideoButtonView?.removeFromSuperview() + }) + transition.setScale(view: switchVideoButtonView, scale: 0.001) + } + } + let _ = self.leaveButton.update( transition: transition, component: AnyComponent(PlainButtonComponent( diff --git a/submodules/TelegramUI/Components/Stories/PeerListItemComponent/Sources/PeerListItemComponent.swift b/submodules/TelegramUI/Components/Stories/PeerListItemComponent/Sources/PeerListItemComponent.swift index 349aa6da22..972ce43e09 100644 --- a/submodules/TelegramUI/Components/Stories/PeerListItemComponent/Sources/PeerListItemComponent.swift +++ b/submodules/TelegramUI/Components/Stories/PeerListItemComponent/Sources/PeerListItemComponent.swift @@ -169,6 +169,7 @@ public final class PeerListItemComponent: Component { case neutral case accent case constructive + case destructive } public var text: String @@ -937,8 +938,9 @@ public final class PeerListItemComponent: Component { case .accent: labelColor = component.theme.list.itemAccentColor case .constructive: - //TODO:release labelColor = UIColor(rgb: 0x33C758) + case .destructive: + labelColor = UIColor(rgb: 0xff3b30) } var animateLabelDirection: Bool? From b71275482055b2b59ab41da9080fde4a843f98c3 Mon Sep 17 00:00:00 2001 From: Isaac <> Date: Wed, 25 Sep 2024 01:26:17 +0800 Subject: [PATCH 2/9] Fix video chat action button tint transition --- .../Source/Base/Transition.swift | 21 +++++++++++++++++++ .../VideoChatActionButtonComponent.swift | 6 +++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/submodules/ComponentFlow/Source/Base/Transition.swift b/submodules/ComponentFlow/Source/Base/Transition.swift index b42f21063e..837eacb44d 100644 --- a/submodules/ComponentFlow/Source/Base/Transition.swift +++ b/submodules/ComponentFlow/Source/Base/Transition.swift @@ -1189,6 +1189,27 @@ public struct ComponentTransition { } } + public func animateTintColor(layer: CALayer, from: UIColor, to: UIColor, completion: ((Bool) -> Void)? = nil) { + switch self.animation { + case .none: + completion?(true) + case let .curve(duration, curve): + let previousColor: CGColor = from.cgColor + + layer.animate( + from: previousColor, + to: to.cgColor, + keyPath: "contentsMultiplyColor", + duration: duration, + delay: 0.0, + curve: curve, + removeOnCompletion: true, + additive: false, + completion: completion + ) + } + } + public func setGradientColors(layer: CAGradientLayer, colors: [UIColor], completion: ((Bool) -> Void)? = nil) { if let current = layer.colors { if current.count == colors.count { diff --git a/submodules/TelegramCallsUI/Sources/VideoChatActionButtonComponent.swift b/submodules/TelegramCallsUI/Sources/VideoChatActionButtonComponent.swift index 435bdecdaf..e965986f30 100644 --- a/submodules/TelegramCallsUI/Sources/VideoChatActionButtonComponent.swift +++ b/submodules/TelegramCallsUI/Sources/VideoChatActionButtonComponent.swift @@ -267,7 +267,11 @@ final class VideoChatActionButtonComponent: Component { } else { tintTransition = .immediate } - tintTransition.setTintColor(layer: self.background.layer, color: backgroundColor) + let previousTintColor = self.background.tintColor + self.background.tintColor = backgroundColor + if let previousTintColor, previousTintColor != backgroundColor { + tintTransition.animateTintColor(layer: self.background.layer, from: previousTintColor, to: backgroundColor) + } let titleFrame = CGRect(origin: CGPoint(x: floor((size.width - titleSize.width) * 0.5), y: size.height + 8.0), size: titleSize) if let titleView = self.title.view { From e2d20e16b106a7854640bd208c7419706c15442a Mon Sep 17 00:00:00 2001 From: Ilya Laktyushin Date: Tue, 24 Sep 2024 21:45:35 +0400 Subject: [PATCH 3/9] Add pcm_f32 decoder --- submodules/ffmpeg/Sources/FFMpeg/build-ffmpeg-bazel.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/submodules/ffmpeg/Sources/FFMpeg/build-ffmpeg-bazel.sh b/submodules/ffmpeg/Sources/FFMpeg/build-ffmpeg-bazel.sh index be58ac5e4f..0bd88402a1 100755 --- a/submodules/ffmpeg/Sources/FFMpeg/build-ffmpeg-bazel.sh +++ b/submodules/ffmpeg/Sources/FFMpeg/build-ffmpeg-bazel.sh @@ -48,7 +48,7 @@ CONFIGURE_FLAGS="--enable-cross-compile --disable-programs \ --enable-libvpx \ --enable-audiotoolbox \ --enable-bsf=aac_adtstoasc,vp9_superframe,h264_mp4toannexb \ - --enable-decoder=h264,libvpx_vp9,hevc,libopus,mp3,aac,flac,alac_at,pcm_s16le,pcm_s24le,gsm_ms_at \ + --enable-decoder=h264,libvpx_vp9,hevc,libopus,mp3,aac,flac,alac_at,pcm_s16le,pcm_s24le,pcm_f32le,gsm_ms_at \ --enable-encoder=libvpx_vp9,aac_at \ --enable-demuxer=aac,mov,m4v,mp3,ogg,libopus,flac,wav,aiff,matroska,mpegts \ --enable-parser=aac,h264,mp3,libopus \ From 4293bf79c973b5594f8ce2ee9f0f49434885524c Mon Sep 17 00:00:00 2001 From: Mikhail Filimonov Date: Tue, 24 Sep 2024 15:11:59 -0300 Subject: [PATCH 4/9] macos related changes --- .../StandaloneSendMessage.swift | 14 +++++++--- .../TelegramEngine/Data/PeersData.swift | 28 +++++++++++++++++++ 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/submodules/TelegramCore/Sources/PendingMessages/StandaloneSendMessage.swift b/submodules/TelegramCore/Sources/PendingMessages/StandaloneSendMessage.swift index 9a0baa37cf..fd9398aa56 100644 --- a/submodules/TelegramCore/Sources/PendingMessages/StandaloneSendMessage.swift +++ b/submodules/TelegramCore/Sources/PendingMessages/StandaloneSendMessage.swift @@ -524,7 +524,7 @@ private func sendUploadedMessageContent( |> switchToLatest } -public func standaloneSendMessage(account: Account, peerId: PeerId, text: String, attributes: [MessageAttribute], media: StandaloneMedia?, replyToMessageId: MessageId?) -> Signal { +public func standaloneSendMessage(account: Account, peerId: PeerId, text: String, attributes: [MessageAttribute], media: StandaloneMedia?, replyToMessageId: MessageId?, threadId: Int32? = nil) -> Signal { let content: Signal if let media = media { switch media { @@ -561,14 +561,14 @@ public func standaloneSendMessage(account: Account, peerId: PeerId, text: String case let .progress(progress): return .single(progress) case let .result(result): - let sendContent = sendMessageContent(account: account, peerId: peerId, attributes: attributes, content: result) |> map({ _ -> Float in return 1.0 }) + let sendContent = sendMessageContent(account: account, peerId: peerId, attributes: attributes, content: result, threadId: threadId) |> map({ _ -> Float in return 1.0 }) return .single(1.0) |> then(sendContent |> mapError { _ -> StandaloneSendMessageError in }) } } } -private func sendMessageContent(account: Account, peerId: PeerId, attributes: [MessageAttribute], content: StandaloneMessageContent) -> Signal { +private func sendMessageContent(account: Account, peerId: PeerId, attributes: [MessageAttribute], content: StandaloneMessageContent, threadId: Int32?) -> Signal { return account.postbox.transaction { transaction -> Signal in if peerId.namespace == Namespaces.Peer.SecretChat { return .complete() @@ -631,9 +631,12 @@ private func sendMessageContent(account: Account, peerId: PeerId, attributes: [M flags |= 1 << 0 replyTo = .inputReplyToStory(peer: inputPeer, storyId: replyToStoryId.id) } + } else if let threadId { + flags |= 1 << 0 + replyTo = .inputReplyToMessage(flags: flags, replyToMsgId: threadId, topMsgId: threadId, replyToPeerId: nil, quoteText: nil, quoteEntities: nil, quoteOffset: nil) } - sendMessageRequest = account.network.request(Api.functions.messages.sendMessage(flags: flags, peer: inputPeer, replyTo: replyTo, message: text, randomId: uniqueId, replyMarkup: nil, entities: messageEntities, scheduleDate: scheduleTime, sendAs: sendAsInputPeer, quickReplyShortcut: nil, effect: nil)) + sendMessageRequest = account.network.request(Api.functions.messages.sendMessage(flags: flags, peer: inputPeer, replyTo: replyTo, message: text, randomId: uniqueId, replyMarkup: nil, entities: messageEntities, scheduleDate: scheduleTime, sendAs: sendAsInputPeer, quickReplyShortcut: nil, effect: nil)) |> `catch` { _ -> Signal in return .complete() } @@ -649,6 +652,9 @@ private func sendMessageContent(account: Account, peerId: PeerId, attributes: [M flags |= 1 << 0 replyTo = .inputReplyToStory(peer: inputPeer, storyId: replyToStoryId.id) } + } else if let threadId { + flags |= 1 << 0 + replyTo = .inputReplyToMessage(flags: flags, replyToMsgId: threadId, topMsgId: threadId, replyToPeerId: nil, quoteText: nil, quoteEntities: nil, quoteOffset: nil) } sendMessageRequest = account.network.request(Api.functions.messages.sendMedia(flags: flags, peer: inputPeer, replyTo: replyTo, media: inputMedia, message: text, randomId: uniqueId, replyMarkup: nil, entities: messageEntities, scheduleDate: scheduleTime, sendAs: sendAsInputPeer, quickReplyShortcut: nil, effect: nil)) diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Data/PeersData.swift b/submodules/TelegramCore/Sources/TelegramEngine/Data/PeersData.swift index fdac6337f0..d36eeccb24 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Data/PeersData.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Data/PeersData.swift @@ -730,6 +730,34 @@ public extension TelegramEngine.EngineData.Item { } } } + + public struct StarGiftsCount: TelegramEngineDataItem, TelegramEngineMapKeyDataItem, PostboxViewDataItem { + public typealias Result = Int32? + + fileprivate var id: EnginePeer.Id + public var mapKey: EnginePeer.Id { + return self.id + } + + public init(id: EnginePeer.Id) { + self.id = id + } + + var key: PostboxViewKey { + return .cachedPeerData(peerId: self.id) + } + + func extract(view: PostboxView) -> Result { + guard let view = view as? CachedPeerDataView else { + preconditionFailure() + } + if let cachedData = view.cachedPeerData as? CachedUserData { + return cachedData.starGiftsCount + } else { + return nil + } + } + } public struct LinkedDiscussionPeerId: TelegramEngineDataItem, TelegramEngineMapKeyDataItem, PostboxViewDataItem { public typealias Result = EnginePeerCachedInfoItem From b7d6514e00352254753d72bb25f3e867a2af4aff Mon Sep 17 00:00:00 2001 From: Isaac <> Date: Wed, 25 Sep 2024 02:47:44 +0800 Subject: [PATCH 5/9] Video chat improvements --- .../Sources/PresentationGroupCall.swift | 2 +- .../TelegramCallsUI/Sources/VideoChatScreen.swift | 11 +++++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/submodules/TelegramCallsUI/Sources/PresentationGroupCall.swift b/submodules/TelegramCallsUI/Sources/PresentationGroupCall.swift index f990bdf289..f857776cc3 100644 --- a/submodules/TelegramCallsUI/Sources/PresentationGroupCall.swift +++ b/submodules/TelegramCallsUI/Sources/PresentationGroupCall.swift @@ -2111,7 +2111,7 @@ public final class PresentationGroupCallImpl: PresentationGroupCall { var topParticipants: [GroupCallParticipantsContext.Participant] = [] var reportSpeakingParticipants: [PeerId: UInt32] = [:] - let timestamp = CACurrentMediaTime() + let timestamp = CFAbsoluteTimeGetCurrent() for (peerId, ssrc) in speakingParticipants { let shouldReport: Bool if let previousTimestamp = strongSelf.speakingParticipantsReportTimestamp[peerId] { diff --git a/submodules/TelegramCallsUI/Sources/VideoChatScreen.swift b/submodules/TelegramCallsUI/Sources/VideoChatScreen.swift index 2bc963014d..fa1b22cd3e 100644 --- a/submodules/TelegramCallsUI/Sources/VideoChatScreen.swift +++ b/submodules/TelegramCallsUI/Sources/VideoChatScreen.swift @@ -110,6 +110,7 @@ final class VideoChatScreenComponent: Component { var applicationStateDisposable: Disposable? var expandedParticipantsVideoState: VideoChatParticipantsComponent.ExpandedVideoState? + var focusedSpeakerAutoSwitchDeadline: Double = 0.0 var isTwoColumnSidebarHidden: Bool = false let inviteDisposable = MetaDisposable() @@ -762,7 +763,7 @@ final class VideoChatScreenComponent: Component { if self.members != members { var members = members - #if DEBUG && true + #if DEBUG && false if let membersValue = members { var participants = membersValue.participants for i in 1 ... 20 { @@ -841,12 +842,13 @@ final class VideoChatScreenComponent: Component { if videoCount == 1, let participantsView = self.participants.view as? VideoChatParticipantsComponent.View, let participantsComponent = participantsView.component { if participantsComponent.layout.videoColumn != nil { self.expandedParticipantsVideoState = nil + self.focusedSpeakerAutoSwitchDeadline = 0.0 } } } if let expandedParticipantsVideoState = self.expandedParticipantsVideoState, let members { - if !expandedParticipantsVideoState.isMainParticipantPinned, let participant = members.participants.first(where: { participant in + if CFAbsoluteTimeGetCurrent() > self.focusedSpeakerAutoSwitchDeadline, !expandedParticipantsVideoState.isMainParticipantPinned, let participant = members.participants.first(where: { participant in if let callState = self.callState, participant.peer.id == callState.myPeerId { return false } @@ -863,6 +865,7 @@ final class VideoChatScreenComponent: Component { } else { self.expandedParticipantsVideoState = VideoChatParticipantsComponent.ExpandedVideoState(mainParticipant: VideoChatParticipantsComponent.VideoParticipantKey(id: participant.peer.id, isPresentation: false), isMainParticipantPinned: false, isUIHidden: expandedParticipantsVideoState.isUIHidden) } + self.focusedSpeakerAutoSwitchDeadline = CFAbsoluteTimeGetCurrent() + 1.0 } } @@ -895,11 +898,14 @@ final class VideoChatScreenComponent: Component { } else { self.expandedParticipantsVideoState = VideoChatParticipantsComponent.ExpandedVideoState(mainParticipant: VideoChatParticipantsComponent.VideoParticipantKey(id: participant.peer.id, isPresentation: false), isMainParticipantPinned: false, isUIHidden: expandedParticipantsVideoState.isUIHidden) } + self.focusedSpeakerAutoSwitchDeadline = CFAbsoluteTimeGetCurrent() + 1.0 } else { self.expandedParticipantsVideoState = nil + self.focusedSpeakerAutoSwitchDeadline = 0.0 } } else { self.expandedParticipantsVideoState = nil + self.focusedSpeakerAutoSwitchDeadline = 0.0 } if !self.isUpdating { @@ -1509,6 +1515,7 @@ final class VideoChatScreenComponent: Component { } self.expandedParticipantsVideoState = VideoChatParticipantsComponent.ExpandedVideoState(mainParticipant: key, isMainParticipantPinned: false, isUIHidden: isUIHidden) + self.focusedSpeakerAutoSwitchDeadline = CFAbsoluteTimeGetCurrent() + 3.0 self.state?.updated(transition: .spring(duration: 0.4)) } else if self.expandedParticipantsVideoState != nil { self.expandedParticipantsVideoState = nil From d0fe10221073c5f65e47c39a0f90b5cddc2e7b66 Mon Sep 17 00:00:00 2001 From: Ilya Laktyushin Date: Wed, 25 Sep 2024 20:37:17 +0400 Subject: [PATCH 6/9] Various fixes --- .../Sources/GiftOptionsScreen.swift | 5 +- .../Sources/GiftSetupScreen.swift | 2 +- .../PremiumGiftAttachmentScreen/BUILD | 2 +- .../Sources/PremiumGiftAttachmentScreen.swift | 13 ++-- .../ChatControllerOpenAttachmentMenu.swift | 16 +++-- .../Sources/ContactSelectionController.swift | 62 +++++++++++++++++-- .../ContactSelectionControllerNode.swift | 1 + .../Sources/SharedAccountContext.swift | 14 ++--- 8 files changed, 84 insertions(+), 31 deletions(-) diff --git a/submodules/TelegramUI/Components/Gifts/GiftOptionsScreen/Sources/GiftOptionsScreen.swift b/submodules/TelegramUI/Components/Gifts/GiftOptionsScreen/Sources/GiftOptionsScreen.swift index eb54cd350d..31acafe4dd 100644 --- a/submodules/TelegramUI/Components/Gifts/GiftOptionsScreen/Sources/GiftOptionsScreen.swift +++ b/submodules/TelegramUI/Components/Gifts/GiftOptionsScreen/Sources/GiftOptionsScreen.swift @@ -1011,14 +1011,15 @@ final class GiftOptionsScreenComponent: Component { } } -public final class GiftOptionsScreen: ViewControllerComponentContainer, GiftOptionsScreenProtocol { +open class GiftOptionsScreen: ViewControllerComponentContainer, GiftOptionsScreenProtocol { private let context: AccountContext public init( context: AccountContext, starsContext: StarsContext, peerId: EnginePeer.Id, - premiumOptions: [CachedPremiumGiftOption] + premiumOptions: [CachedPremiumGiftOption], + completion: @escaping () -> Void = {} ) { self.context = context diff --git a/submodules/TelegramUI/Components/Gifts/GiftSetupScreen/Sources/GiftSetupScreen.swift b/submodules/TelegramUI/Components/Gifts/GiftSetupScreen/Sources/GiftSetupScreen.swift index 805b8aaf6c..e1f56350a5 100644 --- a/submodules/TelegramUI/Components/Gifts/GiftSetupScreen/Sources/GiftSetupScreen.swift +++ b/submodules/TelegramUI/Components/Gifts/GiftSetupScreen/Sources/GiftSetupScreen.swift @@ -341,7 +341,7 @@ final class GiftSetupScreenComponent: Component { autocapitalizationType: .none, autocorrectionType: .no, returnKeyType: .done, - characterLimit: 70, + characterLimit: 255, displayCharacterLimit: true, emptyLineHandling: .notAllowed, updated: { _ in diff --git a/submodules/TelegramUI/Components/PremiumGiftAttachmentScreen/BUILD b/submodules/TelegramUI/Components/PremiumGiftAttachmentScreen/BUILD index 154b99fe70..69caa55031 100644 --- a/submodules/TelegramUI/Components/PremiumGiftAttachmentScreen/BUILD +++ b/submodules/TelegramUI/Components/PremiumGiftAttachmentScreen/BUILD @@ -17,7 +17,7 @@ swift_library( "//submodules/SSignalKit/SwiftSignalKit", "//submodules/AccountContext", "//submodules/AttachmentUI", - "//submodules/PremiumUI", + "//submodules/TelegramUI/Components/Gifts/GiftOptionsScreen", ], visibility = [ "//visibility:public", diff --git a/submodules/TelegramUI/Components/PremiumGiftAttachmentScreen/Sources/PremiumGiftAttachmentScreen.swift b/submodules/TelegramUI/Components/PremiumGiftAttachmentScreen/Sources/PremiumGiftAttachmentScreen.swift index 520c5d79da..bada8afbba 100644 --- a/submodules/TelegramUI/Components/PremiumGiftAttachmentScreen/Sources/PremiumGiftAttachmentScreen.swift +++ b/submodules/TelegramUI/Components/PremiumGiftAttachmentScreen/Sources/PremiumGiftAttachmentScreen.swift @@ -5,15 +5,17 @@ import AsyncDisplayKit import ComponentFlow import SwiftSignalKit import AccountContext -import PremiumUI import AttachmentUI +import GiftOptionsScreen -public class PremiumGiftAttachmentScreen: PremiumGiftScreen, AttachmentContainable { +public class PremiumGiftAttachmentScreen: GiftOptionsScreen, AttachmentContainable { public var requestAttachmentMenuExpansion: () -> Void = {} public var updateNavigationStack: (@escaping ([AttachmentContainable]) -> ([AttachmentContainable], AttachmentMediaPickerContext?)) -> Void = { _ in } public var parentController: () -> ViewController? = { return nil } + public var updateTabBarAlpha: (CGFloat, ContainedViewLayoutTransition) -> Void = { _, _ in } + public var updateTabBarVisibility: (Bool, ContainedViewLayoutTransition) -> Void = { _, _ in } public var cancelPanGesture: () -> Void = { } public var isContainerPanning: () -> Bool = { return false } public var isContainerExpanded: () -> Bool = { return false } @@ -25,17 +27,16 @@ public class PremiumGiftAttachmentScreen: PremiumGiftScreen, AttachmentContainab } private final class PremiumGiftContext: AttachmentMediaPickerContext { - private weak var controller: PremiumGiftScreen? + private weak var controller: GiftOptionsScreen? public var mainButtonState: Signal { - return self.controller?.mainButtonStatePromise.get() ?? .single(nil) + return .single(nil) } - init(controller: PremiumGiftScreen) { + init(controller: GiftOptionsScreen) { self.controller = controller } func mainButtonAction() { - self.controller?.mainButtonPressed() } } diff --git a/submodules/TelegramUI/Sources/ChatControllerOpenAttachmentMenu.swift b/submodules/TelegramUI/Sources/ChatControllerOpenAttachmentMenu.swift index e3ba2c90f7..37d82d35fd 100644 --- a/submodules/TelegramUI/Sources/ChatControllerOpenAttachmentMenu.swift +++ b/submodules/TelegramUI/Sources/ChatControllerOpenAttachmentMenu.swift @@ -588,19 +588,17 @@ extension ChatControllerImpl { strongSelf.controllerNavigationDisposable.set(nil) } case .gift: - if let peer = strongSelf.presentationInterfaceState.renderedPeer?.peer { + if let peer = strongSelf.presentationInterfaceState.renderedPeer?.peer, let starsContext = context.starsContext { let premiumGiftOptions = strongSelf.presentationInterfaceState.premiumGiftOptions if !premiumGiftOptions.isEmpty { - let controller = PremiumGiftAttachmentScreen(context: context, peerIds: [peer.id], options: premiumGiftOptions, source: .attachMenu, pushController: { [weak self] c in - if let strongSelf = self { - strongSelf.push(c) - } - }, completion: { [weak self] in - if let strongSelf = self { - strongSelf.hintPlayNextOutgoingGift() - strongSelf.attachmentController?.dismiss(animated: true) + let controller = PremiumGiftAttachmentScreen(context: context, starsContext: starsContext, peerId: peer.id, premiumOptions: premiumGiftOptions, completion: { [weak self] in + guard let self else { + return } + self.hintPlayNextOutgoingGift() + self.attachmentController?.dismiss(animated: true) }) + completion(controller, controller.mediaPickerContext) strongSelf.controllerNavigationDisposable.set(nil) diff --git a/submodules/TelegramUI/Sources/ContactSelectionController.swift b/submodules/TelegramUI/Sources/ContactSelectionController.swift index cabf9baa3e..fdd0d2397d 100644 --- a/submodules/TelegramUI/Sources/ContactSelectionController.swift +++ b/submodules/TelegramUI/Sources/ContactSelectionController.swift @@ -14,6 +14,7 @@ import AttachmentUI import SearchBarNode import ChatSendAudioMessageContextPreview import ChatSendMessageActionUI +import ContextUI class ContactSelectionControllerImpl: ViewController, ContactSelectionController, PresentableController, AttachmentContainable { private let context: AccountContext @@ -42,6 +43,9 @@ class ContactSelectionControllerImpl: ViewController, ContactSelectionController private let multipleSelection: Bool private let requirePhoneNumbers: Bool + private let openProfile: ((EnginePeer) -> Void)? + private let sendMessage: ((EnginePeer) -> Void)? + private var _ready = Promise() override var ready: Promise { return self._ready @@ -105,6 +109,9 @@ class ContactSelectionControllerImpl: ViewController, ContactSelectionController self.multipleSelection = params.multipleSelection self.requirePhoneNumbers = params.requirePhoneNumbers + self.openProfile = params.openProfile + self.sendMessage = params.sendMessage + self.presentationData = params.updatedPresentationData?.initial ?? params.context.sharedContext.currentPresentationData.with { $0 } super.init(navigationBarPresentationData: NavigationBarPresentationData(presentationData: self.presentationData)) @@ -219,15 +226,15 @@ class ContactSelectionControllerImpl: ViewController, ContactSelectionController } self.contactsNode.requestOpenPeerFromSearch = { [weak self] peer in - self?.openPeer(peer: peer, action: .generic) + self?.openPeer(peer: peer, action: .generic, node: nil, gesture: nil) } self.contactsNode.contactListNode.activateSearch = { [weak self] in self?.activateSearch() } - self.contactsNode.contactListNode.openPeer = { [weak self] peer, action, _, _ in - self?.openPeer(peer: peer, action: action) + self.contactsNode.contactListNode.openPeer = { [weak self] peer, action, node, gesture in + self?.openPeer(peer: peer, action: action, node: node, gesture: gesture) } self.contactsNode.contactListNode.suppressPermissionWarning = { [weak self] in @@ -357,7 +364,40 @@ class ContactSelectionControllerImpl: ViewController, ContactSelectionController } } - private func openPeer(peer: ContactListPeer, action: ContactListAction) { + private func openPeer(peer: ContactListPeer, action: ContactListAction, node: ASDisplayNode?, gesture: ContextGesture?) { + if case .more = action { + guard case let .peer(peer, _, _) = peer, let node = node as? ContextReferenceContentNode else { + return + } + + let presentationData = self.presentationData + + var items: [ContextMenuItem] = [] + items.append(.action(ContextMenuActionItem(text: presentationData.strings.Premium_Gift_ContactSelection_SendMessage, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/MessageBubble"), color: theme.contextMenu.primaryColor) + }, iconPosition: .left, action: { [weak self] _, a in + a(.default) + + if let self { + self.sendMessage?(EnginePeer(peer)) + } + }))) + + items.append(.action(ContextMenuActionItem(text: presentationData.strings.Premium_Gift_ContactSelection_OpenProfile, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/User"), color: theme.contextMenu.primaryColor) + }, iconPosition: .left, action: { [weak self] _, a in + a(.default) + + if let self { + self.openProfile?(EnginePeer(peer)) + } + }))) + + let contextController = ContextController(presentationData: presentationData, source: .reference(ContactContextReferenceContentSource(controller: self, sourceNode: node)), items: .single(ContextController.Items(content: .list(items))), gesture: gesture) + self.present(contextController, in: .window(.root)) + return + } + self.contactsNode.contactListNode.listNode.clearHighlightAnimated(true) self.confirmationDisposable.set((self.confirmation(peer) |> deliverOnMainQueue).startStrict(next: { [weak self] value in if let strongSelf = self { @@ -477,3 +517,17 @@ final class ContactsPickerContext: AttachmentMediaPickerContext { func mainButtonAction() { } } + +private final class ContactContextReferenceContentSource: ContextReferenceContentSource { + private let controller: ViewController + private let sourceNode: ContextReferenceContentNode + + init(controller: ViewController, sourceNode: ContextReferenceContentNode) { + self.controller = controller + self.sourceNode = sourceNode + } + + func transitionInfo() -> ContextControllerReferenceViewInfo? { + return ContextControllerReferenceViewInfo(referenceView: self.sourceNode.view, contentAreaInScreenSpace: UIScreen.main.bounds) + } +} diff --git a/submodules/TelegramUI/Sources/ContactSelectionControllerNode.swift b/submodules/TelegramUI/Sources/ContactSelectionControllerNode.swift index 685113732e..ba488f43c1 100644 --- a/submodules/TelegramUI/Sources/ContactSelectionControllerNode.swift +++ b/submodules/TelegramUI/Sources/ContactSelectionControllerNode.swift @@ -41,6 +41,7 @@ final class ContactSelectionControllerNode: ASDisplayNode { var requestMultipleAction: ((_ silent: Bool, _ scheduleTime: Int32?, _ parameters: ChatSendMessageActionSheetController.SendParameters?) -> Void)? var dismiss: (() -> Void)? var cancelSearch: (() -> Void)? + var openPeerMore: ((ContactListPeer, ASDisplayNode?, ContextGesture?) -> Void)? var presentationData: PresentationData { didSet { diff --git a/submodules/TelegramUI/Sources/SharedAccountContext.swift b/submodules/TelegramUI/Sources/SharedAccountContext.swift index 2baa737ced..bbdf81e5d1 100644 --- a/submodules/TelegramUI/Sources/SharedAccountContext.swift +++ b/submodules/TelegramUI/Sources/SharedAccountContext.swift @@ -2277,17 +2277,17 @@ public final class SharedAccountContextImpl: SharedAccountContext { let presentationData = context.sharedContext.currentPresentationData.with { $0 } var presentBirthdayPickerImpl: (() -> Void)? - var starsMode: ContactSelectionControllerMode = .generic + var mode: ContactSelectionControllerMode = .generic var currentBirthdays: [EnginePeer.Id: TelegramBirthday]? if case let .chatList(birthdays) = source, let birthdays, !birthdays.isEmpty { - starsMode = .starsGifting(birthdays: birthdays, hasActions: true) + mode = .starsGifting(birthdays: birthdays, hasActions: true) currentBirthdays = birthdays } else if case let .settings(birthdays) = source, let birthdays, !birthdays.isEmpty { - starsMode = .starsGifting(birthdays: birthdays, hasActions: true) + mode = .starsGifting(birthdays: birthdays, hasActions: true) currentBirthdays = birthdays } else { - starsMode = .starsGifting(birthdays: nil, hasActions: true) + mode = .starsGifting(birthdays: nil, hasActions: true) } let contactOptions: Signal<[ContactListAdditionalOption], NoError> @@ -2320,7 +2320,7 @@ public final class SharedAccountContextImpl: SharedAccountContext { options.set(context.engine.payments.premiumGiftCodeOptions(peerId: nil)) let controller = context.sharedContext.makeContactSelectionController(ContactSelectionControllerParams( context: context, - mode: starsMode, + mode: mode, autoDismiss: false, title: { strings in return "Gift Premium or Stars" }, options: contactOptions, @@ -2331,7 +2331,7 @@ public final class SharedAccountContextImpl: SharedAccountContext { sendMessageImpl?(peer) } )) - let _ = combineLatest(queue: Queue.mainQueue(), contactsController.result, options.get()) + let _ = combineLatest(queue: Queue.mainQueue(), controller.result, options.get()) .startStandalone(next: { [weak controller] result, options in if let (peers, _, _, _, _, _) = result, let contactPeer = peers.first, case let .peer(peer, _, _) = contactPeer, let starsContext = context.starsContext { let premiumOptions = options.filter { $0.users == 1 }.map { CachedPremiumGiftOption(months: $0.months, currency: $0.currency, amount: $0.amount, botUrl: "", storeProductId: $0.storeProductId) } @@ -2339,8 +2339,6 @@ public final class SharedAccountContextImpl: SharedAccountContext { giftController.navigationPresentation = .modal controller?.push(giftController) -// completion?([peer.id]) - if case .chatList = source, let _ = currentBirthdays { let _ = context.engine.notices.dismissServerProvidedSuggestion(suggestion: .todayBirthdays).startStandalone() } From fb60ed5678ba11ca03f44e697971736a7cbea372 Mon Sep 17 00:00:00 2001 From: Ilya Laktyushin Date: Wed, 25 Sep 2024 22:08:38 +0400 Subject: [PATCH 7/9] Add iPhone 16s --- .../MtProtoKit/Sources/MTApiEnvironment.m | 8 ++++++ .../DeviceModel/Sources/DeviceModel.swift | 25 +++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/submodules/MtProtoKit/Sources/MTApiEnvironment.m b/submodules/MtProtoKit/Sources/MTApiEnvironment.m index 1b9491b939..399acc5097 100644 --- a/submodules/MtProtoKit/Sources/MTApiEnvironment.m +++ b/submodules/MtProtoKit/Sources/MTApiEnvironment.m @@ -534,6 +534,14 @@ NSString *suffix = @""; return @"iPhone 15 Pro"; if ([platform isEqualToString:@"iPhone16,2"]) return @"iPhone 15 Pro Max"; + if ([platform isEqualToString:@"iPhone17,3"]) + return @"iPhone 16"; + if ([platform isEqualToString:@"iPhone17,4"]) + return @"iPhone 16 Plus"; + if ([platform isEqualToString:@"iPhone17,1"]) + return @"iPhone 16 Pro"; + if ([platform isEqualToString:@"iPhone17,2"]) + return @"iPhone 16 Pro Max"; if ([platform hasPrefix:@"iPod1"]) return @"iPod touch 1G"; diff --git a/submodules/Utils/DeviceModel/Sources/DeviceModel.swift b/submodules/Utils/DeviceModel/Sources/DeviceModel.swift index ba30871c3b..1969129b61 100644 --- a/submodules/Utils/DeviceModel/Sources/DeviceModel.swift +++ b/submodules/Utils/DeviceModel/Sources/DeviceModel.swift @@ -49,6 +49,10 @@ public enum DeviceModel: CaseIterable, Equatable { .iPhone15Plus, .iPhone15Pro, .iPhone15ProMax + .iPhone16, + .iPhone16Plus, + .iPhone16Pro, + .iPhone16ProMax ] } @@ -116,6 +120,11 @@ public enum DeviceModel: CaseIterable, Equatable { case iPhone15Pro case iPhone15ProMax + case iPhone16 + case iPhone16Plus + case iPhone16Pro + case iPhone16ProMax + case unknown(String) public var modelId: [String] { @@ -218,6 +227,14 @@ public enum DeviceModel: CaseIterable, Equatable { return ["iPhone16,1"] case .iPhone15ProMax: return ["iPhone16,2"] + case .iPhone16: + return ["iPhone17,3"] + case .iPhone16Plus: + return ["iPhone17,4"] + case .iPhone16Pro: + return ["iPhone17,1"] + case .iPhone16ProMax: + return ["iPhone17,2"] case let .unknown(modelId): return [modelId] } @@ -323,6 +340,14 @@ public enum DeviceModel: CaseIterable, Equatable { return "iPhone 15 Pro" case .iPhone15ProMax: return "iPhone 15 Pro Max" + case .iPhone16: + return "iPhone 16" + case .iPhone16Plus: + return "iPhone 16 Plus" + case .iPhone16Pro: + return "iPhone 16 Pro" + case .iPhone16ProMax: + return "iPhone 16 Pro Max" case let .unknown(modelId): if modelId.hasPrefix("iPhone") { return "Unknown iPhone" From cd7fc1cf9ab1e9d805856f5afb689815e618100c Mon Sep 17 00:00:00 2001 From: Ilya Laktyushin Date: Wed, 25 Sep 2024 23:19:03 +0400 Subject: [PATCH 8/9] Fix build --- submodules/Utils/DeviceModel/Sources/DeviceModel.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/submodules/Utils/DeviceModel/Sources/DeviceModel.swift b/submodules/Utils/DeviceModel/Sources/DeviceModel.swift index 1969129b61..e74039e14e 100644 --- a/submodules/Utils/DeviceModel/Sources/DeviceModel.swift +++ b/submodules/Utils/DeviceModel/Sources/DeviceModel.swift @@ -48,7 +48,7 @@ public enum DeviceModel: CaseIterable, Equatable { .iPhone15, .iPhone15Plus, .iPhone15Pro, - .iPhone15ProMax + .iPhone15ProMax, .iPhone16, .iPhone16Plus, .iPhone16Pro, From a30ab38ce41f3389fee9054eb99875382365a6b7 Mon Sep 17 00:00:00 2001 From: Ilya Laktyushin Date: Thu, 26 Sep 2024 02:32:28 +0400 Subject: [PATCH 9/9] Various improvements --- .../ChatMessageGiftBubbleContentNode/BUILD | 2 + .../ChatMessageGiftBubbleContentNode.swift | 98 +++- .../Components/Gifts/GiftSetupScreen/BUILD | 3 + .../Sources/ChatGiftPreviewItem.swift | 18 +- .../Sources/GiftSetupScreen.swift | 553 +++++++++++++++--- .../ListMultilineTextFieldItemComponent/BUILD | 3 + .../ListMultilineTextFieldItemComponent.swift | 155 ++++- versions.json | 2 +- 8 files changed, 730 insertions(+), 104 deletions(-) diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageGiftBubbleContentNode/BUILD b/submodules/TelegramUI/Components/Chat/ChatMessageGiftBubbleContentNode/BUILD index ba1ade2779..512d7f7c6d 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageGiftBubbleContentNode/BUILD +++ b/submodules/TelegramUI/Components/Chat/ChatMessageGiftBubbleContentNode/BUILD @@ -30,6 +30,8 @@ swift_library( "//submodules/Markdown", "//submodules/TelegramUI/Components/Chat/ChatMessageBubbleContentNode", "//submodules/TelegramUI/Components/Chat/ChatMessageItemCommon", + "//submodules/TelegramUI/Components/TextNodeWithEntities", + "//submodules/InvisibleInkDustNode", ], visibility = [ "//visibility:public", diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageGiftBubbleContentNode/Sources/ChatMessageGiftBubbleContentNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageGiftBubbleContentNode/Sources/ChatMessageGiftBubbleContentNode.swift index e356eab682..b168d1d48b 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageGiftBubbleContentNode/Sources/ChatMessageGiftBubbleContentNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageGiftBubbleContentNode/Sources/ChatMessageGiftBubbleContentNode.swift @@ -20,6 +20,8 @@ import ShimmerEffect import Markdown import ChatMessageBubbleContentNode import ChatMessageItemCommon +import TextNodeWithEntities +import InvisibleInkDustNode private func attributedServiceMessageString(theme: ChatPresentationThemeData, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, dateTimeFormat: PresentationDateTimeFormat, message: EngineMessage, accountPeerId: EnginePeer.Id) -> NSAttributedString? { return universalServiceMessageString(presentationData: (theme.theme, theme.wallpaper), strings: strings, nameDisplayOrder: nameDisplayOrder, dateTimeFormat: dateTimeFormat, message: message, accountPeerId: accountPeerId, forChatList: false, forForumOverview: false) @@ -34,7 +36,8 @@ public class ChatMessageGiftBubbleContentNode: ChatMessageBubbleContentNode { private let mediaBackgroundMaskNode: ASImageNode private var mediaBackgroundContent: WallpaperBubbleBackgroundNode? private let titleNode: TextNode - private let subtitleNode: TextNode + private let subtitleNode: TextNodeWithEntities + private var dustNode: InvisibleInkDustNode? private let placeholderNode: StickerShimmerEffectNode private let animationNode: AnimatedStickerNode @@ -60,6 +63,16 @@ public class ChatMessageGiftBubbleContentNode: ChatMessageBubbleContentNode { if wasVisible != isVisible { self.visibilityStatus = isVisible + + switch self.visibility { + case .none: + self.subtitleNode.visibilityRect = nil + case let .visible(_, subRect): + var subRect = subRect + subRect.origin.x = 0.0 + subRect.size.width = 10000.0 + self.subtitleNode.visibilityRect = subRect + } } } } @@ -88,9 +101,9 @@ public class ChatMessageGiftBubbleContentNode: ChatMessageBubbleContentNode { self.titleNode.isUserInteractionEnabled = false self.titleNode.displaysAsynchronously = false - self.subtitleNode = TextNode() - self.subtitleNode.isUserInteractionEnabled = false - self.subtitleNode.displaysAsynchronously = false + self.subtitleNode = TextNodeWithEntities() + self.subtitleNode.textNode.isUserInteractionEnabled = false + self.subtitleNode.textNode.displaysAsynchronously = false self.buttonNode = HighlightTrackingButtonNode() self.buttonNode.clipsToBounds = true @@ -120,8 +133,7 @@ public class ChatMessageGiftBubbleContentNode: ChatMessageBubbleContentNode { self.addSubnode(self.labelNode) self.addSubnode(self.titleNode) - self.addSubnode(self.subtitleNode) - self.addSubnode(self.subtitleNode) + self.addSubnode(self.subtitleNode.textNode) self.addSubnode(self.placeholderNode) self.addSubnode(self.animationNode) @@ -236,7 +248,7 @@ public class ChatMessageGiftBubbleContentNode: ChatMessageBubbleContentNode { override public func asyncLayoutContent() -> (_ item: ChatMessageBubbleContentItem, _ layoutConstants: ChatMessageItemLayoutConstants, _ preparePosition: ChatMessageBubblePreparePosition, _ messageSelection: Bool?, _ constrainedSize: CGSize, _ avatarInset: CGFloat) -> (ChatMessageBubbleContentProperties, unboundSize: CGSize?, maxWidth: CGFloat, layout: (CGSize, ChatMessageBubbleContentPosition) -> (CGFloat, (CGFloat) -> (CGSize, (ListViewItemUpdateAnimation, Bool, ListViewItemApply?) -> Void))) { let makeLabelLayout = TextNode.asyncLayout(self.labelNode) let makeTitleLayout = TextNode.asyncLayout(self.titleNode) - let makeSubtitleLayout = TextNode.asyncLayout(self.subtitleNode) + let makeSubtitleLayout = TextNodeWithEntities.asyncLayout(self.subtitleNode) let makeButtonTitleLayout = TextNode.asyncLayout(self.buttonTitleNode) let makeRibbonTextLayout = TextNode.asyncLayout(self.ribbonTextNode) @@ -259,6 +271,7 @@ public class ChatMessageGiftBubbleContentNode: ChatMessageBubbleContentNode { var animationFile: TelegramMediaFile? var title = item.presentationData.strings.Notification_PremiumGift_Title var text = "" + var entities: [MessageTextEntity] = [] var buttonTitle = item.presentationData.strings.Notification_PremiumGift_View var ribbonTitle = "" var hasServiceMessage = true @@ -329,8 +342,7 @@ public class ChatMessageGiftBubbleContentNode: ChatMessageBubbleContentNode { buttonTitle = item.presentationData.strings.Notification_PremiumPrize_View hasServiceMessage = false } - case let .starGift(gift, convertStars, giftText, entities, nameHidden, savedToProfile, converted): - let _ = nameHidden + case let .starGift(gift, convertStars, giftText, giftEntities, _, savedToProfile, converted): //TODO:localize if !incoming { buttonTitle = "" @@ -339,7 +351,7 @@ public class ChatMessageGiftBubbleContentNode: ChatMessageBubbleContentNode { title = "Gift from \(authorName)" if let giftText, !giftText.isEmpty { text = giftText - let _ = entities + entities = giftEntities ?? [] } else { if incoming { if converted { @@ -386,15 +398,20 @@ public class ChatMessageGiftBubbleContentNode: ChatMessageBubbleContentNode { let (titleLayout, titleApply) = makeTitleLayout(TextNodeLayoutArguments(attributedString: NSAttributedString(string: title, font: Font.semibold(15.0), textColor: primaryTextColor, paragraphAlignment: .center), backgroundColor: nil, maximumNumberOfLines: 0, truncationType: .end, constrainedSize: CGSize(width: giftSize.width - 32.0, height: CGFloat.greatestFiniteMagnitude), alignment: .center, cutout: nil, insets: UIEdgeInsets())) - let attributedText = parseMarkdownIntoAttributedString(text, attributes: MarkdownAttributes( - body: MarkdownAttributeSet(font: Font.regular(13.0), textColor: primaryTextColor), - bold: MarkdownAttributeSet(font: Font.semibold(13.0), textColor: primaryTextColor), - link: MarkdownAttributeSet(font: Font.regular(13.0), textColor: primaryTextColor), - linkAttribute: { url in - return ("URL", url) - } - ), textAlignment: .center) - + let attributedText: NSAttributedString + if let _ = animationFile { + attributedText = stringWithAppliedEntities(text, entities: entities, baseColor: primaryTextColor, linkColor: primaryTextColor, baseFont: Font.regular(13.0), linkFont: Font.regular(13.0), boldFont: Font.semibold(13.0), italicFont: Font.italic(13.0), boldItalicFont: Font.semiboldItalic(13.0), fixedFont: Font.monospace(13.0), blockQuoteFont: Font.regular(13.0), message: nil) + } else { + attributedText = parseMarkdownIntoAttributedString(text, attributes: MarkdownAttributes( + body: MarkdownAttributeSet(font: Font.regular(13.0), textColor: primaryTextColor), + bold: MarkdownAttributeSet(font: Font.semibold(13.0), textColor: primaryTextColor), + link: MarkdownAttributeSet(font: Font.regular(13.0), textColor: primaryTextColor), + linkAttribute: { url in + return ("URL", url) + } + ), textAlignment: .center) + } + let (subtitleLayout, subtitleApply) = makeSubtitleLayout(TextNodeLayoutArguments(attributedString: attributedText, backgroundColor: nil, maximumNumberOfLines: 0, truncationType: .end, constrainedSize: CGSize(width: giftSize.width - 32.0, height: CGFloat.greatestFiniteMagnitude), alignment: .center, cutout: nil, insets: UIEdgeInsets())) let (buttonTitleLayout, buttonTitleApply) = makeButtonTitleLayout(TextNodeLayoutArguments(attributedString: NSAttributedString(string: buttonTitle, font: Font.semibold(15.0), textColor: primaryTextColor, paragraphAlignment: .center), backgroundColor: nil, maximumNumberOfLines: 0, truncationType: .end, constrainedSize: CGSize(width: giftSize.width - 32.0, height: CGFloat.greatestFiniteMagnitude), alignment: .center, cutout: nil, insets: UIEdgeInsets())) @@ -511,7 +528,13 @@ public class ChatMessageGiftBubbleContentNode: ChatMessageBubbleContentNode { let _ = labelApply() let _ = titleApply() - let _ = subtitleApply() + let _ = subtitleApply(TextNodeWithEntities.Arguments( + context: item.context, + cache: item.controllerInteraction.presentationContext.animationCache, + renderer: item.controllerInteraction.presentationContext.animationRenderer, + placeholderColor: item.presentationData.theme.theme.chat.message.freeform.withWallpaper.reactionInactiveBackground, + attemptSynchronous: synchronousLoads + )) let _ = buttonTitleApply() let _ = ribbonTextApply() @@ -522,7 +545,26 @@ public class ChatMessageGiftBubbleContentNode: ChatMessageBubbleContentNode { strongSelf.titleNode.frame = titleFrame let subtitleFrame = CGRect(origin: CGPoint(x: mediaBackgroundFrame.minX + floorToScreenPixels((mediaBackgroundFrame.width - subtitleLayout.size.width) / 2.0) , y: titleFrame.maxY + textSpacing), size: subtitleLayout.size) - strongSelf.subtitleNode.frame = subtitleFrame + strongSelf.subtitleNode.textNode.frame = subtitleFrame + + if !subtitleLayout.spoilers.isEmpty { + let dustColor = serviceMessageColorComponents(theme: item.presentationData.theme.theme, wallpaper: item.presentationData.theme.wallpaper).primaryText + + let dustNode: InvisibleInkDustNode + if let current = strongSelf.dustNode { + dustNode = current + } else { + dustNode = InvisibleInkDustNode(textNode: nil, enableAnimations: item.context.sharedContext.energyUsageSettings.fullTranslucency) + dustNode.isUserInteractionEnabled = false + strongSelf.dustNode = dustNode + strongSelf.insertSubnode(dustNode, aboveSubnode: strongSelf.subtitleNode.textNode) + } + dustNode.frame = subtitleFrame.insetBy(dx: -3.0, dy: -3.0).offsetBy(dx: 0.0, dy: 1.0) + dustNode.update(size: dustNode.frame.size, color: dustColor, textColor: dustColor, rects: subtitleLayout.spoilers.map { $0.1.offsetBy(dx: 3.0, dy: 3.0).insetBy(dx: 1.0, dy: 1.0) }, wordRects: subtitleLayout.spoilerWords.map { $0.1.offsetBy(dx: 3.0, dy: 3.0).insetBy(dx: 1.0, dy: 1.0) }) + } else if let dustNode = strongSelf.dustNode { + dustNode.removeFromSupernode() + strongSelf.dustNode = nil + } let buttonTitleFrame = CGRect(origin: CGPoint(x: mediaBackgroundFrame.minX + floorToScreenPixels((mediaBackgroundFrame.width - buttonTitleLayout.size.width) / 2.0), y: subtitleFrame.maxY + 18.0), size: buttonTitleLayout.size) strongSelf.buttonTitleNode.frame = buttonTitleFrame @@ -616,6 +658,16 @@ public class ChatMessageGiftBubbleContentNode: ChatMessageBubbleContentNode { if let (rect, size) = strongSelf.absoluteRect { strongSelf.updateAbsoluteRect(rect, within: size) } + + switch strongSelf.visibility { + case .none: + strongSelf.subtitleNode.visibilityRect = nil + case let .visible(_, subRect): + var subRect = subRect + subRect.origin.x = 0.0 + subRect.size.width = 10000.0 + strongSelf.subtitleNode.visibilityRect = subRect + } } }) }) @@ -742,6 +794,7 @@ public class ChatMessageGiftBubbleContentNode: ChatMessageBubbleContentNode { self.updateVisibility() } + private var internalPlayedOnce = false private func updateVisibility() { guard let item = self.item else { return @@ -772,9 +825,10 @@ public class ChatMessageGiftBubbleContentNode: ChatMessageBubbleContentNode { } } - if !item.controllerInteraction.seenOneTimeAnimatedMedia.contains(item.message.id) { + if !item.controllerInteraction.seenOneTimeAnimatedMedia.contains(item.message.id) && !self.internalPlayedOnce { item.controllerInteraction.seenOneTimeAnimatedMedia.insert(item.message.id) self.animationNode.playOnce() + self.internalPlayedOnce = true Queue.mainQueue().after(0.05) { if let itemNode = self.itemNode, let supernode = itemNode.supernode { diff --git a/submodules/TelegramUI/Components/Gifts/GiftSetupScreen/BUILD b/submodules/TelegramUI/Components/Gifts/GiftSetupScreen/BUILD index 1c0a9254dc..a64e80c41d 100644 --- a/submodules/TelegramUI/Components/Gifts/GiftSetupScreen/BUILD +++ b/submodules/TelegramUI/Components/Gifts/GiftSetupScreen/BUILD @@ -34,10 +34,13 @@ swift_library( "//submodules/TelegramUI/Components/ButtonComponent", "//submodules/AppBundle", "//submodules/WallpaperBackgroundNode", + "//submodules/TextFormat", "//submodules/ChatPresentationInterfaceState", "//submodules/TelegramUI/Components/TextFieldComponent", "//submodules/TelegramUI/Components/ListItemComponentAdaptor", "//submodules/BotPaymentsUI", + "//submodules/TelegramUI/Components/EmojiSuggestionsComponent", + "//submodules/TelegramUI/Components/ChatEntityKeyboardInputNode", ], visibility = [ "//visibility:public", diff --git a/submodules/TelegramUI/Components/Gifts/GiftSetupScreen/Sources/ChatGiftPreviewItem.swift b/submodules/TelegramUI/Components/Gifts/GiftSetupScreen/Sources/ChatGiftPreviewItem.swift index 35f48321e7..cd02508e46 100644 --- a/submodules/TelegramUI/Components/Gifts/GiftSetupScreen/Sources/ChatGiftPreviewItem.swift +++ b/submodules/TelegramUI/Components/Gifts/GiftSetupScreen/Sources/ChatGiftPreviewItem.swift @@ -28,6 +28,7 @@ final class ChatGiftPreviewItem: ListViewItem, ItemListItem, ListItemComponentAd let accountPeer: EnginePeer? let gift: StarGift let text: String + let entities: [MessageTextEntity] init( context: AccountContext, @@ -42,7 +43,8 @@ final class ChatGiftPreviewItem: ListViewItem, ItemListItem, ListItemComponentAd nameDisplayOrder: PresentationPersonNameOrder, accountPeer: EnginePeer?, gift: StarGift, - text: String + text: String, + entities: [MessageTextEntity] ) { self.context = context self.theme = theme @@ -57,6 +59,7 @@ final class ChatGiftPreviewItem: ListViewItem, ItemListItem, ListItemComponentAd self.accountPeer = accountPeer self.gift = gift self.text = text + self.entities = entities } func nodeConfiguredForParams(async: @escaping (@escaping () -> Void) -> Void, params: ListViewItemLayoutParams, synchronousLoads: Bool, previousItem: ListViewItem?, nextItem: ListViewItem?, completion: @escaping (ListViewItemNode, @escaping () -> (Signal?, (ListViewItemApply) -> Void)) -> Void) { @@ -130,6 +133,9 @@ final class ChatGiftPreviewItem: ListViewItem, ItemListItem, ListItemComponentAd if lhs.text != rhs.text { return false } + if lhs.entities != rhs.entities { + return false + } return true } } @@ -201,7 +207,7 @@ final class ChatGiftPreviewItemNode: ListViewItemNode { peers[authorPeerId] = item.accountPeer?._asPeer() let media: [Media] = [ - TelegramMediaAction(action: .starGift(gift: item.gift, convertStars: item.gift.convertStars, text: item.text, entities: [], nameHidden: false, savedToProfile: false, converted: false)) + TelegramMediaAction(action: .starGift(gift: item.gift, convertStars: item.gift.convertStars, text: item.text, entities: item.entities, nameHidden: false, savedToProfile: false, converted: false)) ] let message = Message(stableId: 1, stableVersion: 0, id: MessageId(peerId: peerId, namespace: 0, id: 1), globallyUniqueId: nil, groupingKey: nil, groupInfo: nil, threadId: nil, timestamp: 66000, flags: [.Incoming], tags: [], globalTags: [], localTags: [], customTags: [], forwardInfo: nil, author: peers[authorPeerId], text: "", attributes: [], media: media, peers: peers, associatedMessages: messages, associatedMessageIds: [], associatedMedia: [:], associatedThreadInfo: nil, associatedStories: [:]) items.append(item.context.sharedContext.makeChatMessagePreviewItem(context: item.context, messages: [message], theme: item.componentTheme, strings: item.strings, wallpaper: item.wallpaper, fontSize: item.fontSize, chatBubbleCorners: item.chatBubbleCorners, dateTimeFormat: item.dateTimeFormat, nameOrder: item.nameDisplayOrder, forcedResourceStatus: nil, tapMessage: nil, clickThroughMessage: nil, backgroundNode: currentBackgroundNode, availableReactions: nil, accountPeer: nil, isCentered: false, isPreview: true, isStandalone: false)) @@ -221,21 +227,21 @@ final class ChatGiftPreviewItemNode: ListViewItemNode { itemNode.insets = layout.insets itemNode.frame = nodeFrame itemNode.isUserInteractionEnabled = false + itemNode.visibility = .visible(1.0, .infinite) - Queue.mainQueue().after(0.01) { - apply(ListViewItemApply(isOnScreen: true)) - } + apply(ListViewItemApply(isOnScreen: true)) }) } } else { var messageNodes: [ListViewItemNode] = [] for i in 0 ..< items.count { var itemNode: ListViewItemNode? - items[i].nodeConfiguredForParams(async: { $0() }, params: params, synchronousLoads: false, previousItem: i == 0 ? nil : items[i - 1], nextItem: i == (items.count - 1) ? nil : items[i + 1], completion: { node, apply in + items[i].nodeConfiguredForParams(async: { $0() }, params: params, synchronousLoads: true, previousItem: i == 0 ? nil : items[i - 1], nextItem: i == (items.count - 1) ? nil : items[i + 1], completion: { node, apply in itemNode = node apply().1(ListViewItemApply(isOnScreen: true)) }) itemNode!.isUserInteractionEnabled = false + itemNode?.visibility = .visible(1.0, .infinite) messageNodes.append(itemNode!) self.initialBubbleHeight = itemNode?.frame.height diff --git a/submodules/TelegramUI/Components/Gifts/GiftSetupScreen/Sources/GiftSetupScreen.swift b/submodules/TelegramUI/Components/Gifts/GiftSetupScreen/Sources/GiftSetupScreen.swift index e1f56350a5..d358dd40ea 100644 --- a/submodules/TelegramUI/Components/Gifts/GiftSetupScreen/Sources/GiftSetupScreen.swift +++ b/submodules/TelegramUI/Components/Gifts/GiftSetupScreen/Sources/GiftSetupScreen.swift @@ -22,6 +22,11 @@ import LottieComponent import TextFieldComponent import ButtonComponent import BotPaymentsUI +import ChatEntityKeyboardInputNode +import EmojiSuggestionsComponent +import ChatPresentationInterfaceState +import AudioToolbox +import TextFormat final class GiftSetupScreenComponent: Component { typealias EnvironmentType = ViewControllerComponentContainer.Environment @@ -81,9 +86,23 @@ final class GiftSetupScreenComponent: Component { private let textInputTag = NSObject() private var resetText: String? + private var currentInputMode: ListMultilineTextFieldItemComponent.InputMode = .keyboard + + private var inputMediaNodeData: ChatEntityKeyboardInputNode.InputData? + private var inputMediaNodeDataDisposable: Disposable? + private var inputMediaNodeStateContext = ChatEntityKeyboardInputNode.StateContext() + private var inputMediaInteraction: ChatEntityKeyboardInputNode.Interaction? + private var inputMediaNode: ChatEntityKeyboardInputNode? + private var inputMediaNodeBackground = SimpleLayer() + private var inputMediaNodeTargetTag: AnyObject? + private let inputMediaNodeDataPromise = Promise() + + private var currentEmojiSuggestionView: ComponentHostView? + private var hideName = false private var previousHadInputHeight: Bool = false + private var previousInputHeight: CGFloat? private var recenterOnTag: NSObject? private var peerMap: [EnginePeer.Id: EnginePeer] = [:] @@ -175,7 +194,8 @@ final class GiftSetupScreenComponent: Component { guard let self else { return } - let source: BotPaymentInvoiceSource = .starGift(hideName: self.hideName, peerId: component.peerId, giftId: component.gift.id, text: self.textInputState.text.string, entities: []) + let entities = generateChatInputTextEntities(self.textInputState.text) + let source: BotPaymentInvoiceSource = .starGift(hideName: self.hideName, peerId: component.peerId, giftId: component.gift.id, text: self.textInputState.text.string, entities: entities) let inputData = BotCheckoutController.InputData.fetch(context: component.context, source: source) |> map(Optional.init) |> `catch` { _ -> Signal in @@ -264,6 +284,108 @@ final class GiftSetupScreenComponent: Component { self.state?.updated() }) + + self.inputMediaNodeDataPromise.set( + ChatEntityKeyboardInputNode.inputData( + context: component.context, + chatPeerId: nil, + areCustomEmojiEnabled: true, + hasTrending: false, + hasSearch: true, + hasStickers: false, + hasGifs: false, + hideBackground: true, + sendGif: nil + ) + ) + self.inputMediaNodeDataDisposable = (self.inputMediaNodeDataPromise.get() + |> deliverOnMainQueue).start(next: { [weak self] value in + guard let self else { + return + } + self.inputMediaNodeData = value + }) + + self.inputMediaInteraction = ChatEntityKeyboardInputNode.Interaction( + sendSticker: { _, _, _, _, _, _, _, _, _ in + return false + }, + sendEmoji: { _, _, _ in + let _ = self + }, + sendGif: { _, _, _, _, _ in + return false + }, + sendBotContextResultAsGif: { _, _ , _, _, _, _ in + return false + }, + updateChoosingSticker: { _ in + }, + switchToTextInput: { [weak self] in + guard let self else { + return + } + self.currentInputMode = .keyboard + self.state?.updated(transition: .spring(duration: 0.4)) + }, + dismissTextInput: { + }, + insertText: { [weak self] text in + guard let self else { + return + } + if let textInputView = self.introSection.findTaggedView(tag: self.textInputTag) as? ListMultilineTextFieldItemComponent.View { + if self.textInputState.isEditing { + textInputView.insertText(text: text) + } + } + }, + backwardsDeleteText: { [weak self] in + guard let self else { + return + } + if let textInputView = self.introSection.findTaggedView(tag: self.textInputTag) as? ListMultilineTextFieldItemComponent.View { + if self.textInputState.isEditing { + textInputView.backwardsDeleteText() + } + } + }, + openStickerEditor: { + }, + presentController: { [weak self] c, a in + guard let self else { + return + } + self.environment?.controller()?.present(c, in: .window(.root), with: a) + }, + presentGlobalOverlayController: { [weak self] c, a in + guard let self else { + return + } + self.environment?.controller()?.presentInGlobalOverlay(c, with: a) + }, + getNavigationController: { [weak self] () -> NavigationController? in + guard let self else { + return nil + } + guard let controller = self.environment?.controller() as? GiftSetupScreen else { + return nil + } + + if let navigationController = controller.navigationController as? NavigationController { + return navigationController + } + return nil + }, + requestLayout: { [weak self] transition in + guard let self else { + return + } + if !self.isUpdating { + self.state?.updated(transition: ComponentTransition(transition)) + } + } + ) } let environment = environment[EnvironmentType.self].value @@ -316,16 +438,7 @@ final class GiftSetupScreenComponent: Component { contentHeight += environment.navigationHeight contentHeight += 26.0 - - self.recenterOnTag = nil - if let hint = transition.userData(TextFieldComponent.AnimationHint.self), let targetView = hint.view { - if let textView = self.introSection.findTaggedView(tag: self.textInputTag) { - if targetView.isDescendant(of: textView) { - self.recenterOnTag = self.textInputTag - } - } - } - + var introSectionItems: [AnyComponentWithIdentity] = [] introSectionItems.append(AnyComponentWithIdentity(id: introSectionItems.count, component: AnyComponent(Rectangle(color: .clear, height: 346.0, tag: self.introPlaceholderTag)))) introSectionItems.append(AnyComponentWithIdentity(id: introSectionItems.count, component: AnyComponent(ListMultilineTextFieldItemComponent( @@ -337,13 +450,14 @@ final class GiftSetupScreenComponent: Component { resetText: self.resetText.flatMap { return ListMultilineTextFieldItemComponent.ResetText(value: $0) }, - placeholder: environment.strings.Business_Intro_IntroTextPlaceholder, + placeholder: "Enter Message", autocapitalizationType: .none, autocorrectionType: .no, returnKeyType: .done, characterLimit: 255, displayCharacterLimit: true, emptyLineHandling: .notAllowed, + formatMenuAvailability: .available([.bold, .italic, .underline, .strikethrough, .spoiler]), updated: { _ in }, returnKeyAction: { [weak self] in @@ -355,10 +469,173 @@ final class GiftSetupScreenComponent: Component { } }, textUpdateTransition: .spring(duration: 0.4), + inputMode: self.currentInputMode, + toggleInputMode: { [weak self] in + guard let self else { + return + } + switch self.currentInputMode { + case .keyboard: + self.currentInputMode = .emoji + case .emoji: + self.currentInputMode = .keyboard + } + self.state?.updated(transition: .spring(duration: 0.4)) + }, tag: self.textInputTag )))) self.resetText = nil + + var inputHeight: CGFloat = 0.0 + inputHeight += self.updateInputMediaNode( + component: component, + availableSize: availableSize, + bottomInset: environment.safeInsets.bottom, + inputHeight: 0.0, + effectiveInputHeight: environment.deviceMetrics.standardInputHeight(inLandscape: false), + metrics: environment.metrics, + deviceMetrics: environment.deviceMetrics, + transition: transition + ) + if self.inputMediaNode == nil { + if environment.inputHeight.isZero && self.textInputState.isEditing, let previousInputHeight = self.previousInputHeight { + inputHeight = previousInputHeight + } else { + inputHeight = environment.inputHeight + } + } + + if self.textInputState.isEditing, let emojiSuggestion = self.textInputState.currentEmojiSuggestion, emojiSuggestion.disposable == nil { + emojiSuggestion.disposable = (EmojiSuggestionsComponent.suggestionData(context: component.context, isSavedMessages: false, query: emojiSuggestion.position.value) + |> deliverOnMainQueue).start(next: { [weak self, weak emojiSuggestion] result in + guard let self, self.textInputState.currentEmojiSuggestion === emojiSuggestion else { + return + } + + emojiSuggestion?.value = result + self.state?.updated() + }) + } + + var hasTrackingView = self.textInputState.hasTrackingView + if let currentEmojiSuggestion = self.textInputState.currentEmojiSuggestion, let value = currentEmojiSuggestion.value as? [TelegramMediaFile], value.isEmpty { + hasTrackingView = false + } + if !self.textInputState.isEditing { + hasTrackingView = false + } + + if !hasTrackingView { + if let currentEmojiSuggestion = self.textInputState.currentEmojiSuggestion { + self.textInputState.currentEmojiSuggestion = nil + currentEmojiSuggestion.disposable?.dispose() + } + + if let currentEmojiSuggestionView = self.currentEmojiSuggestionView { + self.currentEmojiSuggestionView = nil + + currentEmojiSuggestionView.alpha = 0.0 + currentEmojiSuggestionView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, removeOnCompletion: false, completion: { [weak currentEmojiSuggestionView] _ in + currentEmojiSuggestionView?.removeFromSuperview() + }) + } + } + + if self.textInputState.isEditing, let emojiSuggestion = self.textInputState.currentEmojiSuggestion, let value = emojiSuggestion.value as? [TelegramMediaFile] { + let currentEmojiSuggestionView: ComponentHostView + if let current = self.currentEmojiSuggestionView { + currentEmojiSuggestionView = current + } else { + currentEmojiSuggestionView = ComponentHostView() + self.currentEmojiSuggestionView = currentEmojiSuggestionView + self.addSubview(currentEmojiSuggestionView) + + currentEmojiSuggestionView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15) + } + + let globalPosition: CGPoint + if let textView = (self.introSection.findTaggedView(tag: self.textInputTag) as? ListMultilineTextFieldItemComponent.View)?.textFieldView { + globalPosition = textView.convert(emojiSuggestion.localPosition, to: self) + } else { + globalPosition = .zero + } + + let sideInset: CGFloat = 7.0 + + let viewSize = currentEmojiSuggestionView.update( + transition: .immediate, + component: AnyComponent(EmojiSuggestionsComponent( + context: component.context, + userLocation: .other, + theme: EmojiSuggestionsComponent.Theme(theme: environment.theme), + animationCache: component.context.animationCache, + animationRenderer: component.context.animationRenderer, + files: value, + action: { [weak self] file in + guard let self, let textView = (self.introSection.findTaggedView(tag: self.textInputTag) as? ListMultilineTextFieldItemComponent.View)?.textFieldView, let currentEmojiSuggestion = self.textInputState.currentEmojiSuggestion else { + return + } + + AudioServicesPlaySystemSound(0x450) + + let inputState = textView.getInputState() + let inputText = NSMutableAttributedString(attributedString: inputState.inputText) + + var text: String? + var emojiAttribute: ChatTextInputTextCustomEmojiAttribute? + loop: for attribute in file.attributes { + switch attribute { + case let .CustomEmoji(_, _, displayText, _): + text = displayText + emojiAttribute = ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: nil, fileId: file.fileId.id, file: file) + break loop + default: + break + } + } + + if let emojiAttribute = emojiAttribute, let text = text { + let replacementText = NSAttributedString(string: text, attributes: [ChatTextInputAttributes.customEmoji: emojiAttribute]) + + let range = currentEmojiSuggestion.position.range + let previousText = inputText.attributedSubstring(from: range) + inputText.replaceCharacters(in: range, with: replacementText) + + var replacedUpperBound = range.lowerBound + while true { + if inputText.attributedSubstring(from: NSRange(location: 0, length: replacedUpperBound)).string.hasSuffix(previousText.string) { + let replaceRange = NSRange(location: replacedUpperBound - previousText.length, length: previousText.length) + if replaceRange.location < 0 { + break + } + let adjacentString = inputText.attributedSubstring(from: replaceRange) + if adjacentString.string != previousText.string || adjacentString.attribute(ChatTextInputAttributes.customEmoji, at: 0, effectiveRange: nil) != nil { + break + } + inputText.replaceCharacters(in: replaceRange, with: NSAttributedString(string: text, attributes: [ChatTextInputAttributes.customEmoji: ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: emojiAttribute.interactivelySelectedFromPackId, fileId: emojiAttribute.fileId, file: emojiAttribute.file)])) + replacedUpperBound = replaceRange.lowerBound + } else { + break + } + } + + let selectionPosition = range.lowerBound + (replacementText.string as NSString).length + textView.updateText(inputText, selectionRange: selectionPosition ..< selectionPosition) + } + } + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 100.0) + ) + + let viewFrame = CGRect(origin: CGPoint(x: min(availableSize.width - sideInset - viewSize.width, max(sideInset, floor(globalPosition.x - viewSize.width / 2.0))), y: globalPosition.y - 4.0 - viewSize.height), size: viewSize) + currentEmojiSuggestionView.frame = viewFrame + if let componentView = currentEmojiSuggestionView.componentView as? EmojiSuggestionsComponent.View { + componentView.adjustBackground(relativePositionX: floor(globalPosition.x + 10.0)) + } + } + let introSectionSize = self.introSection.update( transition: transition, component: AnyComponent(ListSectionComponent( @@ -387,64 +664,44 @@ final class GiftSetupScreenComponent: Component { } contentHeight += introSectionSize.height contentHeight += sectionSpacing - -// let titleText: String -// if self.titleInputState.text.string.isEmpty { -// titleText = environment.strings.Conversation_EmptyPlaceholder -// } else { -// let rawTitle = self.titleInputState.text.string -// titleText = rawTitle.count <= maxTitleLength ? rawTitle : String(rawTitle[rawTitle.startIndex ..< rawTitle.index(rawTitle.startIndex, offsetBy: maxTitleLength)]) -// } - -// let textText: String -// if self.textInputState.text.string.isEmpty { -// textText = environment.strings.Conversation_GreetingText -// } else { -// let rawText = self.textInputState.text.string -// textText = rawText.count <= maxTextLength ? rawText : String(rawText[rawText.startIndex ..< rawText.index(rawText.startIndex, offsetBy: maxTextLength)]) -// } - + let listItemParams = ListViewItemLayoutParams(width: availableSize.width - sideInset * 2.0, leftInset: 0.0, rightInset: 0.0, availableHeight: 10000.0, isStandalone: true) - let introContentSize = self.introContent.update( - transition: transition, - component: AnyComponent( - ListItemComponentAdaptor( - itemGenerator: ChatGiftPreviewItem( - context: component.context, - theme: environment.theme, - componentTheme: environment.theme, - strings: environment.strings, - sectionId: 0, - fontSize: presentationData.chatFontSize, - chatBubbleCorners: presentationData.chatBubbleCorners, - wallpaper: presentationData.chatWallpaper, - dateTimeFormat: environment.dateTimeFormat, - nameDisplayOrder: presentationData.nameDisplayOrder, - accountPeer: self.peerMap[component.context.account.peerId], - gift: component.gift, - text: self.textInputState.text.string - ), - params: listItemParams - ) - ), - environment: {}, - containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 10000.0) - ) - if let introContentView = self.introContent.view { - if introContentView.superview == nil { - if let placeholderView = self.introSection.findTaggedView(tag: self.introPlaceholderTag) { - placeholderView.addSubview(introContentView) + if let accountPeer = self.peerMap[component.context.account.peerId] { + let introContentSize = self.introContent.update( + transition: transition, + component: AnyComponent( + ListItemComponentAdaptor( + itemGenerator: ChatGiftPreviewItem( + context: component.context, + theme: environment.theme, + componentTheme: environment.theme, + strings: environment.strings, + sectionId: 0, + fontSize: presentationData.chatFontSize, + chatBubbleCorners: presentationData.chatBubbleCorners, + wallpaper: presentationData.chatWallpaper, + dateTimeFormat: environment.dateTimeFormat, + nameDisplayOrder: presentationData.nameDisplayOrder, + accountPeer: accountPeer, + gift: component.gift, + text: self.textInputState.text.string, + entities: generateChatInputTextEntities(self.textInputState.text) + ), + params: listItemParams + ) + ), + environment: {}, + containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 10000.0) + ) + if let introContentView = self.introContent.view { + if introContentView.superview == nil { + if let placeholderView = self.introSection.findTaggedView(tag: self.introPlaceholderTag) { + placeholderView.addSubview(introContentView) + } } - } - transition.setFrame(view: introContentView, frame: CGRect(origin: CGPoint(), size: introContentSize)) - } - - if self.recenterOnTag == nil && self.previousHadInputHeight != (environment.inputHeight > 0.0) { - if self.textInputState.isEditing { - self.recenterOnTag = self.textInputTag + transition.setFrame(view: introContentView, frame: CGRect(origin: CGPoint(), size: introContentSize)) } } - self.previousHadInputHeight = environment.inputHeight > 0.0 let peerName = self.peerMap[component.peerId]?.compactDisplayTitle ?? "" let hideSectionSize = self.hideSection.update( @@ -498,11 +755,9 @@ final class GiftSetupScreenComponent: Component { contentHeight += bottomContentInset - let inputHeight: CGFloat = environment.inputHeight let combinedBottomInset = max(inputHeight, environment.safeInsets.bottom) contentHeight += combinedBottomInset - if self.starImage == nil || self.starImage?.1 !== environment.theme { self.starImage = (generateTintedImage(image: UIImage(bundleImageName: "Item List/PremiumIcon"), color: environment.theme.list.itemCheckColors.foregroundColor)!, environment.theme) } @@ -545,6 +800,22 @@ final class GiftSetupScreenComponent: Component { let previousBounds = self.scrollView.bounds + self.recenterOnTag = nil + if let hint = transition.userData(TextFieldComponent.AnimationHint.self), let targetView = hint.view { + if let textView = self.introSection.findTaggedView(tag: self.textInputTag) { + if targetView.isDescendant(of: textView) { + self.recenterOnTag = self.textInputTag + } + } + } + if self.recenterOnTag == nil && self.previousHadInputHeight != (environment.inputHeight > 0.0) { + if self.textInputState.isEditing { + self.recenterOnTag = self.textInputTag + } + } + self.previousHadInputHeight = inputHeight > 0.0 + self.previousInputHeight = inputHeight + self.ignoreScrolling = true let contentSize = CGSize(width: availableSize.width, height: contentHeight) if self.scrollView.frame != CGRect(origin: CGPoint(), size: availableSize) { @@ -592,6 +863,152 @@ final class GiftSetupScreenComponent: Component { return availableSize } + + private func updateInputMediaNode( + component: GiftSetupScreenComponent, + availableSize: CGSize, + bottomInset: CGFloat, + inputHeight: CGFloat, + effectiveInputHeight: CGFloat, + metrics: LayoutMetrics, + deviceMetrics: DeviceMetrics, + transition: ComponentTransition + ) -> CGFloat { + let bottomInset: CGFloat = bottomInset + 8.0 + let bottomContainerInset: CGFloat = 0.0 + let needsInputActivation: Bool = !"".isEmpty + + var height: CGFloat = 0.0 + if case .emoji = self.currentInputMode, let inputData = self.inputMediaNodeData { + let inputMediaNode: ChatEntityKeyboardInputNode + var inputMediaNodeTransition = transition + var animateIn = false + if let current = self.inputMediaNode { + inputMediaNode = current + } else { + animateIn = true + inputMediaNodeTransition = inputMediaNodeTransition.withAnimation(.none) + inputMediaNode = ChatEntityKeyboardInputNode( + context: component.context, + currentInputData: inputData, + updatedInputData: self.inputMediaNodeDataPromise.get(), + defaultToEmojiTab: true, + opaqueTopPanelBackground: false, + useOpaqueTheme: true, + interaction: self.inputMediaInteraction, + chatPeerId: nil, + stateContext: self.inputMediaNodeStateContext + ) + inputMediaNode.clipsToBounds = true + + inputMediaNode.externalTopPanelContainerImpl = nil + inputMediaNode.useExternalSearchContainer = true + if inputMediaNode.view.superview == nil { + self.inputMediaNodeBackground.removeAllAnimations() + self.layer.addSublayer(self.inputMediaNodeBackground) + self.addSubview(inputMediaNode.view) + } + self.inputMediaNode = inputMediaNode + } + + let presentationData = component.context.sharedContext.currentPresentationData.with { $0 } + let presentationInterfaceState = ChatPresentationInterfaceState( + chatWallpaper: .builtin(WallpaperSettings()), + theme: presentationData.theme, + strings: presentationData.strings, + dateTimeFormat: presentationData.dateTimeFormat, + nameDisplayOrder: presentationData.nameDisplayOrder, + limitsConfiguration: component.context.currentLimitsConfiguration.with { $0 }, + fontSize: presentationData.chatFontSize, + bubbleCorners: presentationData.chatBubbleCorners, + accountPeerId: component.context.account.peerId, + mode: .standard(.default), + chatLocation: .peer(id: component.context.account.peerId), + subject: nil, + peerNearbyData: nil, + greetingData: nil, + pendingUnpinnedAllMessages: false, + activeGroupCallInfo: nil, + hasActiveGroupCall: false, + importState: nil, + threadData: nil, + isGeneralThreadClosed: nil, + replyMessage: nil, + accountPeerColor: nil, + businessIntro: nil + ) + + self.inputMediaNodeBackground.backgroundColor = presentationData.theme.rootController.navigationBar.opaqueBackgroundColor.cgColor + + let heightAndOverflow = inputMediaNode.updateLayout(width: availableSize.width, leftInset: 0.0, rightInset: 0.0, bottomInset: bottomInset, standardInputHeight: deviceMetrics.standardInputHeight(inLandscape: false), inputHeight: inputHeight < 100.0 ? inputHeight - bottomContainerInset : inputHeight, maximumHeight: availableSize.height, inputPanelHeight: 0.0, transition: .immediate, interfaceState: presentationInterfaceState, layoutMetrics: metrics, deviceMetrics: deviceMetrics, isVisible: true, isExpanded: false) + let inputNodeHeight = heightAndOverflow.0 + let inputNodeFrame = CGRect(origin: CGPoint(x: 0.0, y: availableSize.height - inputNodeHeight), size: CGSize(width: availableSize.width, height: inputNodeHeight)) + + let inputNodeBackgroundFrame = CGRect(origin: CGPoint(x: inputNodeFrame.minX, y: inputNodeFrame.minY - 6.0), size: CGSize(width: inputNodeFrame.width, height: inputNodeFrame.height + 6.0)) + + if needsInputActivation { + let inputNodeFrame = inputNodeFrame.offsetBy(dx: 0.0, dy: inputNodeHeight) + ComponentTransition.immediate.setFrame(layer: inputMediaNode.layer, frame: inputNodeFrame) + ComponentTransition.immediate.setFrame(layer: self.inputMediaNodeBackground, frame: inputNodeBackgroundFrame) + } + + if animateIn { + var targetFrame = inputNodeFrame + targetFrame.origin.y = availableSize.height + inputMediaNodeTransition.setFrame(layer: inputMediaNode.layer, frame: targetFrame) + + let inputNodeBackgroundTargetFrame = CGRect(origin: CGPoint(x: targetFrame.minX, y: targetFrame.minY - 6.0), size: CGSize(width: targetFrame.width, height: targetFrame.height + 6.0)) + + inputMediaNodeTransition.setFrame(layer: self.inputMediaNodeBackground, frame: inputNodeBackgroundTargetFrame) + + transition.setFrame(layer: inputMediaNode.layer, frame: inputNodeFrame) + transition.setFrame(layer: self.inputMediaNodeBackground, frame: inputNodeBackgroundFrame) + } else { + inputMediaNodeTransition.setFrame(layer: inputMediaNode.layer, frame: inputNodeFrame) + inputMediaNodeTransition.setFrame(layer: self.inputMediaNodeBackground, frame: inputNodeBackgroundFrame) + } + + height = heightAndOverflow.0 + } else { + self.inputMediaNodeTargetTag = nil + + if let inputMediaNode = self.inputMediaNode { + self.inputMediaNode = nil + var targetFrame = inputMediaNode.frame + targetFrame.origin.y = availableSize.height + transition.setFrame(view: inputMediaNode.view, frame: targetFrame, completion: { [weak inputMediaNode] _ in + if let inputMediaNode { + Queue.mainQueue().after(0.3) { + inputMediaNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.35, removeOnCompletion: false, completion: { [weak inputMediaNode] _ in + inputMediaNode?.view.removeFromSuperview() + }) + } + } + }) + transition.setFrame(layer: self.inputMediaNodeBackground, frame: targetFrame, completion: { [weak self] _ in + Queue.mainQueue().after(0.3) { + guard let self else { + return + } + if self.currentInputMode == .keyboard { + self.inputMediaNodeBackground.animateAlpha(from: 1.0, to: 0.0, duration: 0.35, removeOnCompletion: false, completion: { [weak self] finished in + guard let self else { + return + } + + if finished { + self.inputMediaNodeBackground.removeFromSuperlayer() + } + self.inputMediaNodeBackground.removeAllAnimations() + }) + } + } + }) + } + } + + return height + } } func makeView() -> View { diff --git a/submodules/TelegramUI/Components/ListMultilineTextFieldItemComponent/BUILD b/submodules/TelegramUI/Components/ListMultilineTextFieldItemComponent/BUILD index cab3565215..ac6a567525 100644 --- a/submodules/TelegramUI/Components/ListMultilineTextFieldItemComponent/BUILD +++ b/submodules/TelegramUI/Components/ListMultilineTextFieldItemComponent/BUILD @@ -10,12 +10,15 @@ swift_library( "-warnings-as-errors", ], deps = [ + "//submodules/SSignalKit/SwiftSignalKit", "//submodules/Display", "//submodules/ComponentFlow", "//submodules/TelegramPresentationData", "//submodules/Components/MultilineTextComponent", "//submodules/TelegramUI/Components/ListSectionComponent", "//submodules/TelegramUI/Components/TextFieldComponent", + "//submodules/TelegramUI/Components/LottieComponent", + "//submodules/TelegramUI/Components/PlainButtonComponent", "//submodules/AccountContext", ], visibility = [ diff --git a/submodules/TelegramUI/Components/ListMultilineTextFieldItemComponent/Sources/ListMultilineTextFieldItemComponent.swift b/submodules/TelegramUI/Components/ListMultilineTextFieldItemComponent/Sources/ListMultilineTextFieldItemComponent.swift index 0cfdd563cf..fda244d1c2 100644 --- a/submodules/TelegramUI/Components/ListMultilineTextFieldItemComponent/Sources/ListMultilineTextFieldItemComponent.swift +++ b/submodules/TelegramUI/Components/ListMultilineTextFieldItemComponent/Sources/ListMultilineTextFieldItemComponent.swift @@ -2,10 +2,13 @@ import Foundation import UIKit import Display import ComponentFlow +import SwiftSignalKit import TelegramPresentationData import MultilineTextComponent import ListSectionComponent import TextFieldComponent +import LottieComponent +import PlainButtonComponent import AccountContext public final class ListMultilineTextFieldItemComponent: Component { @@ -14,6 +17,11 @@ public final class ListMultilineTextFieldItemComponent: Component { public fileprivate(set) var text: NSAttributedString = NSAttributedString() public fileprivate(set) var isEditing: Bool = false + public var hasTrackingView = false + + public var currentEmojiSuggestion: TextFieldComponent.EmojiSuggestion? + public var dismissedEmojiSuggestionPosition: TextFieldComponent.EmojiSuggestion.Position? + public init() { } } @@ -30,6 +38,11 @@ public final class ListMultilineTextFieldItemComponent: Component { } } + public enum InputMode { + case keyboard + case emoji + } + public enum EmptyLineHandling { case allowed case oneConsecutive @@ -49,10 +62,13 @@ public final class ListMultilineTextFieldItemComponent: Component { public let characterLimit: Int? public let displayCharacterLimit: Bool public let emptyLineHandling: EmptyLineHandling + public let formatMenuAvailability: TextFieldComponent.FormatMenuAvailability public let updated: ((String) -> Void)? public let returnKeyAction: (() -> Void)? public let backspaceKeyAction: (() -> Void)? public let textUpdateTransition: ComponentTransition + public let inputMode: InputMode? + public let toggleInputMode: (() -> Void)? public let tag: AnyObject? public init( @@ -69,10 +85,13 @@ public final class ListMultilineTextFieldItemComponent: Component { characterLimit: Int? = nil, displayCharacterLimit: Bool = false, emptyLineHandling: EmptyLineHandling = .allowed, + formatMenuAvailability: TextFieldComponent.FormatMenuAvailability = .none, updated: ((String) -> Void)? = nil, returnKeyAction: (() -> Void)? = nil, backspaceKeyAction: (() -> Void)? = nil, textUpdateTransition: ComponentTransition = .immediate, + inputMode: InputMode? = nil, + toggleInputMode: (() -> Void)? = nil, tag: AnyObject? = nil ) { self.externalState = externalState @@ -88,10 +107,13 @@ public final class ListMultilineTextFieldItemComponent: Component { self.characterLimit = characterLimit self.displayCharacterLimit = displayCharacterLimit self.emptyLineHandling = emptyLineHandling + self.formatMenuAvailability = formatMenuAvailability self.updated = updated self.returnKeyAction = returnKeyAction self.backspaceKeyAction = backspaceKeyAction self.textUpdateTransition = textUpdateTransition + self.inputMode = inputMode + self.toggleInputMode = toggleInputMode self.tag = tag } @@ -135,9 +157,15 @@ public final class ListMultilineTextFieldItemComponent: Component { if lhs.emptyLineHandling != rhs.emptyLineHandling { return false } + if lhs.formatMenuAvailability != rhs.formatMenuAvailability { + return false + } if (lhs.updated == nil) != (rhs.updated == nil) { return false } + if lhs.inputMode != rhs.inputMode { + return false + } return true } @@ -145,6 +173,8 @@ public final class ListMultilineTextFieldItemComponent: Component { private let textField = ComponentView() private let textFieldExternalState = TextFieldComponent.ExternalState() + private var modeSelector: ComponentView? + private let placeholder = ComponentView() private var customPlaceholder: ComponentView? @@ -203,17 +233,40 @@ public final class ListMultilineTextFieldItemComponent: Component { } } + public func insertText(text: NSAttributedString) { + if let textFieldView = self.textField.view as? TextFieldComponent.View { + textFieldView.insertText(text) + } + } + + public func backwardsDeleteText() { + if let textFieldView = self.textField.view as? TextFieldComponent.View { + textFieldView.deleteBackward() + } + } + + public var textFieldView: TextFieldComponent.View? { + return self.textField.view as? TextFieldComponent.View + } + func update(component: ListMultilineTextFieldItemComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { self.isUpdating = true defer { self.isUpdating = false } + let previousComponent = self.component self.component = component self.state = state let verticalInset: CGFloat = 12.0 - let sideInset: CGFloat = 16.0 + let leftInset: CGFloat = 16.0 + var rightInset: CGFloat = 16.0 + let modeSelectorSize = CGSize(width: 32.0, height: 32.0) + + if component.inputMode != nil { + rightInset += 34.0 + } let textLimitFont = Font.regular(15.0) var measureTextLimitInset: CGFloat = 0.0 @@ -258,8 +311,8 @@ public final class ListMultilineTextFieldItemComponent: Component { fontSize: 17.0, textColor: component.theme.list.itemPrimaryTextColor, accentColor: component.theme.list.itemPrimaryTextColor, - insets: UIEdgeInsets(top: verticalInset, left: sideInset - 8.0, bottom: verticalInset, right: sideInset - 8.0 + measureTextLimitInset), - hideKeyboard: false, + insets: UIEdgeInsets(top: verticalInset, left: leftInset - 8.0, bottom: verticalInset, right: rightInset - 8.0 + measureTextLimitInset), + hideKeyboard: component.inputMode == .emoji, customInputView: nil, resetText: component.resetText.flatMap { resetText in return NSAttributedString(string: resetText.value, font: Font.regular(17.0), textColor: component.theme.list.itemPrimaryTextColor) @@ -267,7 +320,7 @@ public final class ListMultilineTextFieldItemComponent: Component { isOneLineWhenUnfocused: false, characterLimit: component.characterLimit, emptyLineHandling: mappedEmptyLineHandling, - formatMenuAvailability: .none, + formatMenuAvailability: component.formatMenuAvailability, returnKeyType: component.returnKeyType, lockedFormatAction: { }, @@ -309,9 +362,9 @@ public final class ListMultilineTextFieldItemComponent: Component { text: .plain(NSAttributedString(string: component.placeholder.isEmpty ? " " : component.placeholder, font: Font.regular(17.0), textColor: component.theme.list.itemPlaceholderTextColor)) )), environment: {}, - containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 100.0) + containerSize: CGSize(width: availableSize.width - leftInset - rightInset, height: 100.0) ) - let placeholderFrame = CGRect(origin: CGPoint(x: sideInset, y: verticalInset), size: placeholderSize) + let placeholderFrame = CGRect(origin: CGPoint(x: leftInset, y: verticalInset), size: placeholderSize) if let placeholderView = self.placeholder.view { if placeholderView.superview == nil { placeholderView.layer.anchorPoint = CGPoint() @@ -329,6 +382,9 @@ public final class ListMultilineTextFieldItemComponent: Component { component.externalState?.hasText = self.textFieldExternalState.hasText component.externalState?.text = self.textFieldExternalState.text component.externalState?.isEditing = self.textFieldExternalState.isEditing + component.externalState?.currentEmojiSuggestion = self.textFieldExternalState.currentEmojiSuggestion + component.externalState?.dismissedEmojiSuggestionPosition = self.textFieldExternalState.dismissedEmojiSuggestionPosition + component.externalState?.hasTrackingView = self.textFieldExternalState.hasTrackingView var displayRemainingLimit: Int? if let characterLimit = component.characterLimit, component.displayCharacterLimit { @@ -357,7 +413,7 @@ public final class ListMultilineTextFieldItemComponent: Component { environment: {}, containerSize: CGSize(width: 100.0, height: 100.0) ) - let textLimitLabelFrame = CGRect(origin: CGPoint(x: availableSize.width - textLimitLabelSize.width - sideInset, y: verticalInset + 2.0), size: textLimitLabelSize) + let textLimitLabelFrame = CGRect(origin: CGPoint(x: availableSize.width - textLimitLabelSize.width - rightInset, y: verticalInset + 2.0), size: textLimitLabelSize) if let textLimitLabelView = textLimitLabel.view { if textLimitLabelView.superview == nil { textLimitLabelView.isUserInteractionEnabled = false @@ -374,6 +430,91 @@ public final class ListMultilineTextFieldItemComponent: Component { } } + if let inputMode = component.inputMode { + var modeSelectorTransition = transition + let modeSelector: ComponentView + if let current = self.modeSelector { + modeSelector = current + } else { + modeSelectorTransition = modeSelectorTransition.withAnimation(.none) + modeSelector = ComponentView() + self.modeSelector = modeSelector + } + let animationName: String + var playAnimation = false + if let previousComponent, let previousInputMode = previousComponent.inputMode { + if previousInputMode != inputMode { + playAnimation = true + } + } + switch inputMode { + case .keyboard: + animationName = "input_anim_keyToSmile" + case .emoji: + animationName = "input_anim_smileToKey" + } + + let _ = modeSelector.update( + transition: modeSelectorTransition, + component: AnyComponent(PlainButtonComponent( + content: AnyComponent(LottieComponent( + content: LottieComponent.AppBundleContent( + name: animationName + ), + color: component.theme.chat.inputPanel.inputControlColor.blitOver(component.theme.list.itemBlocksBackgroundColor, alpha: 1.0), + size: modeSelectorSize + )), + effectAlignment: .center, + action: { [weak self] in + guard let self, let component = self.component else { + return + } + component.toggleInputMode?() + }, + animateScale: false + )), + environment: {}, + containerSize: modeSelectorSize + ) + let modeSelectorFrame = CGRect(origin: CGPoint(x: size.width - 4.0 - modeSelectorSize.width, y: floor((size.height - modeSelectorSize.height) * 0.5)), size: modeSelectorSize) + if let modeSelectorView = modeSelector.view as? PlainButtonComponent.View { + let alphaTransition: ComponentTransition = .easeInOut(duration: 0.2) + + if modeSelectorView.superview == nil { + self.addSubview(modeSelectorView) + ComponentTransition.immediate.setAlpha(view: modeSelectorView, alpha: 0.0) + ComponentTransition.immediate.setScale(view: modeSelectorView, scale: 0.001) + } + + if playAnimation, let animationView = modeSelectorView.contentView as? LottieComponent.View { + animationView.playOnce() + } + + modeSelectorTransition.setPosition(view: modeSelectorView, position: modeSelectorFrame.center) + modeSelectorTransition.setBounds(view: modeSelectorView, bounds: CGRect(origin: CGPoint(), size: modeSelectorFrame.size)) + + if let externalState = component.externalState { + let displaySelector = externalState.isEditing + + alphaTransition.setAlpha(view: modeSelectorView, alpha: displaySelector ? 1.0 : 0.0) + alphaTransition.setScale(view: modeSelectorView, scale: displaySelector ? 1.0 : 0.001) + } + } + } else if let modeSelector = self.modeSelector { + self.modeSelector = nil + if let modeSelectorView = modeSelector.view { + if !transition.animation.isImmediate { + let alphaTransition: ComponentTransition = .easeInOut(duration: 0.2) + alphaTransition.setAlpha(view: modeSelectorView, alpha: 0.0, completion: { [weak modeSelectorView] _ in + modeSelectorView?.removeFromSuperview() + }) + alphaTransition.setScale(view: modeSelectorView, scale: 0.001) + } else { + modeSelectorView.removeFromSuperview() + } + } + } + return size } diff --git a/versions.json b/versions.json index bc1c16abd1..da4ab2f6e5 100644 --- a/versions.json +++ b/versions.json @@ -1,5 +1,5 @@ { - "app": "11.1.2", + "app": "11.2", "xcode": "16.0", "bazel": "7.3.1", "macos": "15.0"