Various improvements

This commit is contained in:
Ilya Laktyushin 2024-09-26 02:32:28 +04:00
parent cd7fc1cf9a
commit a30ab38ce4
8 changed files with 730 additions and 104 deletions

View File

@ -30,6 +30,8 @@ swift_library(
"//submodules/Markdown", "//submodules/Markdown",
"//submodules/TelegramUI/Components/Chat/ChatMessageBubbleContentNode", "//submodules/TelegramUI/Components/Chat/ChatMessageBubbleContentNode",
"//submodules/TelegramUI/Components/Chat/ChatMessageItemCommon", "//submodules/TelegramUI/Components/Chat/ChatMessageItemCommon",
"//submodules/TelegramUI/Components/TextNodeWithEntities",
"//submodules/InvisibleInkDustNode",
], ],
visibility = [ visibility = [
"//visibility:public", "//visibility:public",

View File

@ -20,6 +20,8 @@ import ShimmerEffect
import Markdown import Markdown
import ChatMessageBubbleContentNode import ChatMessageBubbleContentNode
import ChatMessageItemCommon import ChatMessageItemCommon
import TextNodeWithEntities
import InvisibleInkDustNode
private func attributedServiceMessageString(theme: ChatPresentationThemeData, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, dateTimeFormat: PresentationDateTimeFormat, message: EngineMessage, accountPeerId: EnginePeer.Id) -> NSAttributedString? { 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) 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 let mediaBackgroundMaskNode: ASImageNode
private var mediaBackgroundContent: WallpaperBubbleBackgroundNode? private var mediaBackgroundContent: WallpaperBubbleBackgroundNode?
private let titleNode: TextNode private let titleNode: TextNode
private let subtitleNode: TextNode private let subtitleNode: TextNodeWithEntities
private var dustNode: InvisibleInkDustNode?
private let placeholderNode: StickerShimmerEffectNode private let placeholderNode: StickerShimmerEffectNode
private let animationNode: AnimatedStickerNode private let animationNode: AnimatedStickerNode
@ -60,6 +63,16 @@ public class ChatMessageGiftBubbleContentNode: ChatMessageBubbleContentNode {
if wasVisible != isVisible { if wasVisible != isVisible {
self.visibilityStatus = 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.isUserInteractionEnabled = false
self.titleNode.displaysAsynchronously = false self.titleNode.displaysAsynchronously = false
self.subtitleNode = TextNode() self.subtitleNode = TextNodeWithEntities()
self.subtitleNode.isUserInteractionEnabled = false self.subtitleNode.textNode.isUserInteractionEnabled = false
self.subtitleNode.displaysAsynchronously = false self.subtitleNode.textNode.displaysAsynchronously = false
self.buttonNode = HighlightTrackingButtonNode() self.buttonNode = HighlightTrackingButtonNode()
self.buttonNode.clipsToBounds = true self.buttonNode.clipsToBounds = true
@ -120,8 +133,7 @@ public class ChatMessageGiftBubbleContentNode: ChatMessageBubbleContentNode {
self.addSubnode(self.labelNode) self.addSubnode(self.labelNode)
self.addSubnode(self.titleNode) self.addSubnode(self.titleNode)
self.addSubnode(self.subtitleNode) self.addSubnode(self.subtitleNode.textNode)
self.addSubnode(self.subtitleNode)
self.addSubnode(self.placeholderNode) self.addSubnode(self.placeholderNode)
self.addSubnode(self.animationNode) 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))) { 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 makeLabelLayout = TextNode.asyncLayout(self.labelNode)
let makeTitleLayout = TextNode.asyncLayout(self.titleNode) 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 makeButtonTitleLayout = TextNode.asyncLayout(self.buttonTitleNode)
let makeRibbonTextLayout = TextNode.asyncLayout(self.ribbonTextNode) let makeRibbonTextLayout = TextNode.asyncLayout(self.ribbonTextNode)
@ -259,6 +271,7 @@ public class ChatMessageGiftBubbleContentNode: ChatMessageBubbleContentNode {
var animationFile: TelegramMediaFile? var animationFile: TelegramMediaFile?
var title = item.presentationData.strings.Notification_PremiumGift_Title var title = item.presentationData.strings.Notification_PremiumGift_Title
var text = "" var text = ""
var entities: [MessageTextEntity] = []
var buttonTitle = item.presentationData.strings.Notification_PremiumGift_View var buttonTitle = item.presentationData.strings.Notification_PremiumGift_View
var ribbonTitle = "" var ribbonTitle = ""
var hasServiceMessage = true var hasServiceMessage = true
@ -329,8 +342,7 @@ public class ChatMessageGiftBubbleContentNode: ChatMessageBubbleContentNode {
buttonTitle = item.presentationData.strings.Notification_PremiumPrize_View buttonTitle = item.presentationData.strings.Notification_PremiumPrize_View
hasServiceMessage = false hasServiceMessage = false
} }
case let .starGift(gift, convertStars, giftText, entities, nameHidden, savedToProfile, converted): case let .starGift(gift, convertStars, giftText, giftEntities, _, savedToProfile, converted):
let _ = nameHidden
//TODO:localize //TODO:localize
if !incoming { if !incoming {
buttonTitle = "" buttonTitle = ""
@ -339,7 +351,7 @@ public class ChatMessageGiftBubbleContentNode: ChatMessageBubbleContentNode {
title = "Gift from \(authorName)" title = "Gift from \(authorName)"
if let giftText, !giftText.isEmpty { if let giftText, !giftText.isEmpty {
text = giftText text = giftText
let _ = entities entities = giftEntities ?? []
} else { } else {
if incoming { if incoming {
if converted { 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 (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( let attributedText: NSAttributedString
body: MarkdownAttributeSet(font: Font.regular(13.0), textColor: primaryTextColor), if let _ = animationFile {
bold: MarkdownAttributeSet(font: Font.semibold(13.0), textColor: primaryTextColor), 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)
link: MarkdownAttributeSet(font: Font.regular(13.0), textColor: primaryTextColor), } else {
linkAttribute: { url in attributedText = parseMarkdownIntoAttributedString(text, attributes: MarkdownAttributes(
return ("URL", url) body: MarkdownAttributeSet(font: Font.regular(13.0), textColor: primaryTextColor),
} bold: MarkdownAttributeSet(font: Font.semibold(13.0), textColor: primaryTextColor),
), textAlignment: .center) 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 (subtitleLayout, subtitleApply) = makeSubtitleLayout(TextNodeLayoutArguments(attributedString: attributedText, backgroundColor: nil, maximumNumberOfLines: 0, truncationType: .end, constrainedSize: CGSize(width: giftSize.width - 32.0, height: CGFloat.greatestFiniteMagnitude), alignment: .center, cutout: nil, insets: UIEdgeInsets()))
let (buttonTitleLayout, buttonTitleApply) = makeButtonTitleLayout(TextNodeLayoutArguments(attributedString: NSAttributedString(string: buttonTitle, font: Font.semibold(15.0), textColor: primaryTextColor, paragraphAlignment: .center), backgroundColor: nil, maximumNumberOfLines: 0, truncationType: .end, constrainedSize: CGSize(width: giftSize.width - 32.0, height: CGFloat.greatestFiniteMagnitude), alignment: .center, cutout: nil, insets: UIEdgeInsets())) let (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 _ = labelApply()
let _ = titleApply() 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 _ = buttonTitleApply()
let _ = ribbonTextApply() let _ = ribbonTextApply()
@ -522,7 +545,26 @@ public class ChatMessageGiftBubbleContentNode: ChatMessageBubbleContentNode {
strongSelf.titleNode.frame = titleFrame 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) 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) 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 strongSelf.buttonTitleNode.frame = buttonTitleFrame
@ -616,6 +658,16 @@ public class ChatMessageGiftBubbleContentNode: ChatMessageBubbleContentNode {
if let (rect, size) = strongSelf.absoluteRect { if let (rect, size) = strongSelf.absoluteRect {
strongSelf.updateAbsoluteRect(rect, within: size) 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() self.updateVisibility()
} }
private var internalPlayedOnce = false
private func updateVisibility() { private func updateVisibility() {
guard let item = self.item else { guard let item = self.item else {
return 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) item.controllerInteraction.seenOneTimeAnimatedMedia.insert(item.message.id)
self.animationNode.playOnce() self.animationNode.playOnce()
self.internalPlayedOnce = true
Queue.mainQueue().after(0.05) { Queue.mainQueue().after(0.05) {
if let itemNode = self.itemNode, let supernode = itemNode.supernode { if let itemNode = self.itemNode, let supernode = itemNode.supernode {

View File

@ -34,10 +34,13 @@ swift_library(
"//submodules/TelegramUI/Components/ButtonComponent", "//submodules/TelegramUI/Components/ButtonComponent",
"//submodules/AppBundle", "//submodules/AppBundle",
"//submodules/WallpaperBackgroundNode", "//submodules/WallpaperBackgroundNode",
"//submodules/TextFormat",
"//submodules/ChatPresentationInterfaceState", "//submodules/ChatPresentationInterfaceState",
"//submodules/TelegramUI/Components/TextFieldComponent", "//submodules/TelegramUI/Components/TextFieldComponent",
"//submodules/TelegramUI/Components/ListItemComponentAdaptor", "//submodules/TelegramUI/Components/ListItemComponentAdaptor",
"//submodules/BotPaymentsUI", "//submodules/BotPaymentsUI",
"//submodules/TelegramUI/Components/EmojiSuggestionsComponent",
"//submodules/TelegramUI/Components/ChatEntityKeyboardInputNode",
], ],
visibility = [ visibility = [
"//visibility:public", "//visibility:public",

View File

@ -28,6 +28,7 @@ final class ChatGiftPreviewItem: ListViewItem, ItemListItem, ListItemComponentAd
let accountPeer: EnginePeer? let accountPeer: EnginePeer?
let gift: StarGift let gift: StarGift
let text: String let text: String
let entities: [MessageTextEntity]
init( init(
context: AccountContext, context: AccountContext,
@ -42,7 +43,8 @@ final class ChatGiftPreviewItem: ListViewItem, ItemListItem, ListItemComponentAd
nameDisplayOrder: PresentationPersonNameOrder, nameDisplayOrder: PresentationPersonNameOrder,
accountPeer: EnginePeer?, accountPeer: EnginePeer?,
gift: StarGift, gift: StarGift,
text: String text: String,
entities: [MessageTextEntity]
) { ) {
self.context = context self.context = context
self.theme = theme self.theme = theme
@ -57,6 +59,7 @@ final class ChatGiftPreviewItem: ListViewItem, ItemListItem, ListItemComponentAd
self.accountPeer = accountPeer self.accountPeer = accountPeer
self.gift = gift self.gift = gift
self.text = text 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<Void, NoError>?, (ListViewItemApply) -> Void)) -> Void) { func nodeConfiguredForParams(async: @escaping (@escaping () -> Void) -> Void, params: ListViewItemLayoutParams, synchronousLoads: Bool, previousItem: ListViewItem?, nextItem: ListViewItem?, completion: @escaping (ListViewItemNode, @escaping () -> (Signal<Void, NoError>?, (ListViewItemApply) -> Void)) -> Void) {
@ -130,6 +133,9 @@ final class ChatGiftPreviewItem: ListViewItem, ItemListItem, ListItemComponentAd
if lhs.text != rhs.text { if lhs.text != rhs.text {
return false return false
} }
if lhs.entities != rhs.entities {
return false
}
return true return true
} }
} }
@ -201,7 +207,7 @@ final class ChatGiftPreviewItemNode: ListViewItemNode {
peers[authorPeerId] = item.accountPeer?._asPeer() peers[authorPeerId] = item.accountPeer?._asPeer()
let media: [Media] = [ 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: [:]) 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)) 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.insets = layout.insets
itemNode.frame = nodeFrame itemNode.frame = nodeFrame
itemNode.isUserInteractionEnabled = false itemNode.isUserInteractionEnabled = false
itemNode.visibility = .visible(1.0, .infinite)
Queue.mainQueue().after(0.01) { apply(ListViewItemApply(isOnScreen: true))
apply(ListViewItemApply(isOnScreen: true))
}
}) })
} }
} else { } else {
var messageNodes: [ListViewItemNode] = [] var messageNodes: [ListViewItemNode] = []
for i in 0 ..< items.count { for i in 0 ..< items.count {
var itemNode: ListViewItemNode? 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 itemNode = node
apply().1(ListViewItemApply(isOnScreen: true)) apply().1(ListViewItemApply(isOnScreen: true))
}) })
itemNode!.isUserInteractionEnabled = false itemNode!.isUserInteractionEnabled = false
itemNode?.visibility = .visible(1.0, .infinite)
messageNodes.append(itemNode!) messageNodes.append(itemNode!)
self.initialBubbleHeight = itemNode?.frame.height self.initialBubbleHeight = itemNode?.frame.height

View File

@ -22,6 +22,11 @@ import LottieComponent
import TextFieldComponent import TextFieldComponent
import ButtonComponent import ButtonComponent
import BotPaymentsUI import BotPaymentsUI
import ChatEntityKeyboardInputNode
import EmojiSuggestionsComponent
import ChatPresentationInterfaceState
import AudioToolbox
import TextFormat
final class GiftSetupScreenComponent: Component { final class GiftSetupScreenComponent: Component {
typealias EnvironmentType = ViewControllerComponentContainer.Environment typealias EnvironmentType = ViewControllerComponentContainer.Environment
@ -81,9 +86,23 @@ final class GiftSetupScreenComponent: Component {
private let textInputTag = NSObject() private let textInputTag = NSObject()
private var resetText: String? 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<ChatEntityKeyboardInputNode.InputData>()
private var currentEmojiSuggestionView: ComponentHostView<Empty>?
private var hideName = false private var hideName = false
private var previousHadInputHeight: Bool = false private var previousHadInputHeight: Bool = false
private var previousInputHeight: CGFloat?
private var recenterOnTag: NSObject? private var recenterOnTag: NSObject?
private var peerMap: [EnginePeer.Id: EnginePeer] = [:] private var peerMap: [EnginePeer.Id: EnginePeer] = [:]
@ -175,7 +194,8 @@ final class GiftSetupScreenComponent: Component {
guard let self else { guard let self else {
return 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) let inputData = BotCheckoutController.InputData.fetch(context: component.context, source: source)
|> map(Optional.init) |> map(Optional.init)
|> `catch` { _ -> Signal<BotCheckoutController.InputData?, NoError> in |> `catch` { _ -> Signal<BotCheckoutController.InputData?, NoError> in
@ -264,6 +284,108 @@ final class GiftSetupScreenComponent: Component {
self.state?.updated() 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 let environment = environment[EnvironmentType.self].value
@ -316,16 +438,7 @@ final class GiftSetupScreenComponent: Component {
contentHeight += environment.navigationHeight contentHeight += environment.navigationHeight
contentHeight += 26.0 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<Empty>] = [] var introSectionItems: [AnyComponentWithIdentity<Empty>] = []
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(Rectangle(color: .clear, height: 346.0, tag: self.introPlaceholderTag))))
introSectionItems.append(AnyComponentWithIdentity(id: introSectionItems.count, component: AnyComponent(ListMultilineTextFieldItemComponent( introSectionItems.append(AnyComponentWithIdentity(id: introSectionItems.count, component: AnyComponent(ListMultilineTextFieldItemComponent(
@ -337,13 +450,14 @@ final class GiftSetupScreenComponent: Component {
resetText: self.resetText.flatMap { resetText: self.resetText.flatMap {
return ListMultilineTextFieldItemComponent.ResetText(value: $0) return ListMultilineTextFieldItemComponent.ResetText(value: $0)
}, },
placeholder: environment.strings.Business_Intro_IntroTextPlaceholder, placeholder: "Enter Message",
autocapitalizationType: .none, autocapitalizationType: .none,
autocorrectionType: .no, autocorrectionType: .no,
returnKeyType: .done, returnKeyType: .done,
characterLimit: 255, characterLimit: 255,
displayCharacterLimit: true, displayCharacterLimit: true,
emptyLineHandling: .notAllowed, emptyLineHandling: .notAllowed,
formatMenuAvailability: .available([.bold, .italic, .underline, .strikethrough, .spoiler]),
updated: { _ in updated: { _ in
}, },
returnKeyAction: { [weak self] in returnKeyAction: { [weak self] in
@ -355,10 +469,173 @@ final class GiftSetupScreenComponent: Component {
} }
}, },
textUpdateTransition: .spring(duration: 0.4), 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 tag: self.textInputTag
)))) ))))
self.resetText = nil 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<Empty>
if let current = self.currentEmojiSuggestionView {
currentEmojiSuggestionView = current
} else {
currentEmojiSuggestionView = ComponentHostView<Empty>()
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( let introSectionSize = self.introSection.update(
transition: transition, transition: transition,
component: AnyComponent(ListSectionComponent( component: AnyComponent(ListSectionComponent(
@ -387,64 +664,44 @@ final class GiftSetupScreenComponent: Component {
} }
contentHeight += introSectionSize.height contentHeight += introSectionSize.height
contentHeight += sectionSpacing 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 listItemParams = ListViewItemLayoutParams(width: availableSize.width - sideInset * 2.0, leftInset: 0.0, rightInset: 0.0, availableHeight: 10000.0, isStandalone: true)
let introContentSize = self.introContent.update( if let accountPeer = self.peerMap[component.context.account.peerId] {
transition: transition, let introContentSize = self.introContent.update(
component: AnyComponent( transition: transition,
ListItemComponentAdaptor( component: AnyComponent(
itemGenerator: ChatGiftPreviewItem( ListItemComponentAdaptor(
context: component.context, itemGenerator: ChatGiftPreviewItem(
theme: environment.theme, context: component.context,
componentTheme: environment.theme, theme: environment.theme,
strings: environment.strings, componentTheme: environment.theme,
sectionId: 0, strings: environment.strings,
fontSize: presentationData.chatFontSize, sectionId: 0,
chatBubbleCorners: presentationData.chatBubbleCorners, fontSize: presentationData.chatFontSize,
wallpaper: presentationData.chatWallpaper, chatBubbleCorners: presentationData.chatBubbleCorners,
dateTimeFormat: environment.dateTimeFormat, wallpaper: presentationData.chatWallpaper,
nameDisplayOrder: presentationData.nameDisplayOrder, dateTimeFormat: environment.dateTimeFormat,
accountPeer: self.peerMap[component.context.account.peerId], nameDisplayOrder: presentationData.nameDisplayOrder,
gift: component.gift, accountPeer: accountPeer,
text: self.textInputState.text.string gift: component.gift,
), text: self.textInputState.text.string,
params: listItemParams entities: generateChatInputTextEntities(self.textInputState.text)
) ),
), params: listItemParams
environment: {}, )
containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 10000.0) ),
) environment: {},
if let introContentView = self.introContent.view { containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 10000.0)
if introContentView.superview == nil { )
if let placeholderView = self.introSection.findTaggedView(tag: self.introPlaceholderTag) { if let introContentView = self.introContent.view {
placeholderView.addSubview(introContentView) 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))
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
} }
} }
self.previousHadInputHeight = environment.inputHeight > 0.0
let peerName = self.peerMap[component.peerId]?.compactDisplayTitle ?? "" let peerName = self.peerMap[component.peerId]?.compactDisplayTitle ?? ""
let hideSectionSize = self.hideSection.update( let hideSectionSize = self.hideSection.update(
@ -498,11 +755,9 @@ final class GiftSetupScreenComponent: Component {
contentHeight += bottomContentInset contentHeight += bottomContentInset
let inputHeight: CGFloat = environment.inputHeight
let combinedBottomInset = max(inputHeight, environment.safeInsets.bottom) let combinedBottomInset = max(inputHeight, environment.safeInsets.bottom)
contentHeight += combinedBottomInset contentHeight += combinedBottomInset
if self.starImage == nil || self.starImage?.1 !== environment.theme { 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) 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 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 self.ignoreScrolling = true
let contentSize = CGSize(width: availableSize.width, height: contentHeight) let contentSize = CGSize(width: availableSize.width, height: contentHeight)
if self.scrollView.frame != CGRect(origin: CGPoint(), size: availableSize) { if self.scrollView.frame != CGRect(origin: CGPoint(), size: availableSize) {
@ -592,6 +863,152 @@ final class GiftSetupScreenComponent: Component {
return availableSize 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 { func makeView() -> View {

View File

@ -10,12 +10,15 @@ swift_library(
"-warnings-as-errors", "-warnings-as-errors",
], ],
deps = [ deps = [
"//submodules/SSignalKit/SwiftSignalKit",
"//submodules/Display", "//submodules/Display",
"//submodules/ComponentFlow", "//submodules/ComponentFlow",
"//submodules/TelegramPresentationData", "//submodules/TelegramPresentationData",
"//submodules/Components/MultilineTextComponent", "//submodules/Components/MultilineTextComponent",
"//submodules/TelegramUI/Components/ListSectionComponent", "//submodules/TelegramUI/Components/ListSectionComponent",
"//submodules/TelegramUI/Components/TextFieldComponent", "//submodules/TelegramUI/Components/TextFieldComponent",
"//submodules/TelegramUI/Components/LottieComponent",
"//submodules/TelegramUI/Components/PlainButtonComponent",
"//submodules/AccountContext", "//submodules/AccountContext",
], ],
visibility = [ visibility = [

View File

@ -2,10 +2,13 @@ import Foundation
import UIKit import UIKit
import Display import Display
import ComponentFlow import ComponentFlow
import SwiftSignalKit
import TelegramPresentationData import TelegramPresentationData
import MultilineTextComponent import MultilineTextComponent
import ListSectionComponent import ListSectionComponent
import TextFieldComponent import TextFieldComponent
import LottieComponent
import PlainButtonComponent
import AccountContext import AccountContext
public final class ListMultilineTextFieldItemComponent: Component { 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 text: NSAttributedString = NSAttributedString()
public fileprivate(set) var isEditing: Bool = false public fileprivate(set) var isEditing: Bool = false
public var hasTrackingView = false
public var currentEmojiSuggestion: TextFieldComponent.EmojiSuggestion?
public var dismissedEmojiSuggestionPosition: TextFieldComponent.EmojiSuggestion.Position?
public init() { public init() {
} }
} }
@ -30,6 +38,11 @@ public final class ListMultilineTextFieldItemComponent: Component {
} }
} }
public enum InputMode {
case keyboard
case emoji
}
public enum EmptyLineHandling { public enum EmptyLineHandling {
case allowed case allowed
case oneConsecutive case oneConsecutive
@ -49,10 +62,13 @@ public final class ListMultilineTextFieldItemComponent: Component {
public let characterLimit: Int? public let characterLimit: Int?
public let displayCharacterLimit: Bool public let displayCharacterLimit: Bool
public let emptyLineHandling: EmptyLineHandling public let emptyLineHandling: EmptyLineHandling
public let formatMenuAvailability: TextFieldComponent.FormatMenuAvailability
public let updated: ((String) -> Void)? public let updated: ((String) -> Void)?
public let returnKeyAction: (() -> Void)? public let returnKeyAction: (() -> Void)?
public let backspaceKeyAction: (() -> Void)? public let backspaceKeyAction: (() -> Void)?
public let textUpdateTransition: ComponentTransition public let textUpdateTransition: ComponentTransition
public let inputMode: InputMode?
public let toggleInputMode: (() -> Void)?
public let tag: AnyObject? public let tag: AnyObject?
public init( public init(
@ -69,10 +85,13 @@ public final class ListMultilineTextFieldItemComponent: Component {
characterLimit: Int? = nil, characterLimit: Int? = nil,
displayCharacterLimit: Bool = false, displayCharacterLimit: Bool = false,
emptyLineHandling: EmptyLineHandling = .allowed, emptyLineHandling: EmptyLineHandling = .allowed,
formatMenuAvailability: TextFieldComponent.FormatMenuAvailability = .none,
updated: ((String) -> Void)? = nil, updated: ((String) -> Void)? = nil,
returnKeyAction: (() -> Void)? = nil, returnKeyAction: (() -> Void)? = nil,
backspaceKeyAction: (() -> Void)? = nil, backspaceKeyAction: (() -> Void)? = nil,
textUpdateTransition: ComponentTransition = .immediate, textUpdateTransition: ComponentTransition = .immediate,
inputMode: InputMode? = nil,
toggleInputMode: (() -> Void)? = nil,
tag: AnyObject? = nil tag: AnyObject? = nil
) { ) {
self.externalState = externalState self.externalState = externalState
@ -88,10 +107,13 @@ public final class ListMultilineTextFieldItemComponent: Component {
self.characterLimit = characterLimit self.characterLimit = characterLimit
self.displayCharacterLimit = displayCharacterLimit self.displayCharacterLimit = displayCharacterLimit
self.emptyLineHandling = emptyLineHandling self.emptyLineHandling = emptyLineHandling
self.formatMenuAvailability = formatMenuAvailability
self.updated = updated self.updated = updated
self.returnKeyAction = returnKeyAction self.returnKeyAction = returnKeyAction
self.backspaceKeyAction = backspaceKeyAction self.backspaceKeyAction = backspaceKeyAction
self.textUpdateTransition = textUpdateTransition self.textUpdateTransition = textUpdateTransition
self.inputMode = inputMode
self.toggleInputMode = toggleInputMode
self.tag = tag self.tag = tag
} }
@ -135,9 +157,15 @@ public final class ListMultilineTextFieldItemComponent: Component {
if lhs.emptyLineHandling != rhs.emptyLineHandling { if lhs.emptyLineHandling != rhs.emptyLineHandling {
return false return false
} }
if lhs.formatMenuAvailability != rhs.formatMenuAvailability {
return false
}
if (lhs.updated == nil) != (rhs.updated == nil) { if (lhs.updated == nil) != (rhs.updated == nil) {
return false return false
} }
if lhs.inputMode != rhs.inputMode {
return false
}
return true return true
} }
@ -145,6 +173,8 @@ public final class ListMultilineTextFieldItemComponent: Component {
private let textField = ComponentView<Empty>() private let textField = ComponentView<Empty>()
private let textFieldExternalState = TextFieldComponent.ExternalState() private let textFieldExternalState = TextFieldComponent.ExternalState()
private var modeSelector: ComponentView<Empty>?
private let placeholder = ComponentView<Empty>() private let placeholder = ComponentView<Empty>()
private var customPlaceholder: ComponentView<Empty>? private var customPlaceholder: ComponentView<Empty>?
@ -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<Empty>, transition: ComponentTransition) -> CGSize { func update(component: ListMultilineTextFieldItemComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
self.isUpdating = true self.isUpdating = true
defer { defer {
self.isUpdating = false self.isUpdating = false
} }
let previousComponent = self.component
self.component = component self.component = component
self.state = state self.state = state
let verticalInset: CGFloat = 12.0 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) let textLimitFont = Font.regular(15.0)
var measureTextLimitInset: CGFloat = 0.0 var measureTextLimitInset: CGFloat = 0.0
@ -258,8 +311,8 @@ public final class ListMultilineTextFieldItemComponent: Component {
fontSize: 17.0, fontSize: 17.0,
textColor: component.theme.list.itemPrimaryTextColor, textColor: component.theme.list.itemPrimaryTextColor,
accentColor: component.theme.list.itemPrimaryTextColor, accentColor: component.theme.list.itemPrimaryTextColor,
insets: UIEdgeInsets(top: verticalInset, left: sideInset - 8.0, bottom: verticalInset, right: sideInset - 8.0 + measureTextLimitInset), insets: UIEdgeInsets(top: verticalInset, left: leftInset - 8.0, bottom: verticalInset, right: rightInset - 8.0 + measureTextLimitInset),
hideKeyboard: false, hideKeyboard: component.inputMode == .emoji,
customInputView: nil, customInputView: nil,
resetText: component.resetText.flatMap { resetText in resetText: component.resetText.flatMap { resetText in
return NSAttributedString(string: resetText.value, font: Font.regular(17.0), textColor: component.theme.list.itemPrimaryTextColor) 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, isOneLineWhenUnfocused: false,
characterLimit: component.characterLimit, characterLimit: component.characterLimit,
emptyLineHandling: mappedEmptyLineHandling, emptyLineHandling: mappedEmptyLineHandling,
formatMenuAvailability: .none, formatMenuAvailability: component.formatMenuAvailability,
returnKeyType: component.returnKeyType, returnKeyType: component.returnKeyType,
lockedFormatAction: { 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)) text: .plain(NSAttributedString(string: component.placeholder.isEmpty ? " " : component.placeholder, font: Font.regular(17.0), textColor: component.theme.list.itemPlaceholderTextColor))
)), )),
environment: {}, 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 let placeholderView = self.placeholder.view {
if placeholderView.superview == nil { if placeholderView.superview == nil {
placeholderView.layer.anchorPoint = CGPoint() placeholderView.layer.anchorPoint = CGPoint()
@ -329,6 +382,9 @@ public final class ListMultilineTextFieldItemComponent: Component {
component.externalState?.hasText = self.textFieldExternalState.hasText component.externalState?.hasText = self.textFieldExternalState.hasText
component.externalState?.text = self.textFieldExternalState.text component.externalState?.text = self.textFieldExternalState.text
component.externalState?.isEditing = self.textFieldExternalState.isEditing 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? var displayRemainingLimit: Int?
if let characterLimit = component.characterLimit, component.displayCharacterLimit { if let characterLimit = component.characterLimit, component.displayCharacterLimit {
@ -357,7 +413,7 @@ public final class ListMultilineTextFieldItemComponent: Component {
environment: {}, environment: {},
containerSize: CGSize(width: 100.0, height: 100.0) 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 let textLimitLabelView = textLimitLabel.view {
if textLimitLabelView.superview == nil { if textLimitLabelView.superview == nil {
textLimitLabelView.isUserInteractionEnabled = false textLimitLabelView.isUserInteractionEnabled = false
@ -374,6 +430,91 @@ public final class ListMultilineTextFieldItemComponent: Component {
} }
} }
if let inputMode = component.inputMode {
var modeSelectorTransition = transition
let modeSelector: ComponentView<Empty>
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 return size
} }

View File

@ -1,5 +1,5 @@
{ {
"app": "11.1.2", "app": "11.2",
"xcode": "16.0", "xcode": "16.0",
"bazel": "7.3.1", "bazel": "7.3.1",
"macos": "15.0" "macos": "15.0"