Various improvements

This commit is contained in:
isaac 2026-04-24 22:53:55 +04:00
parent 6c2f370a8f
commit 68a95f53f2
10 changed files with 259 additions and 75 deletions

View file

@ -220,6 +220,10 @@ public final class ResizableSheetComponent<ChildEnvironmentType: Sendable & Equa
}
return false
}
public var contentViewValue: UIView? {
return self.contentView.view
}
private let dimView: UIView
public let containerView: UIView

View file

@ -3319,7 +3319,7 @@ open class ListViewImpl: ASDisplayNode, ListView, ASScrollViewDelegate, ASGestur
animation.completion = { [weak self] _ in
self?.updateItemNodesVisibilities(onlyPositive: false)
}
self.layer.add(animation, forKey: nil)
self.layer.add(animation, forKey: "animation-\(ObjectIdentifier(animation))")
if !completeOffset.isZero {
for itemNode in self.itemNodes {
itemNode.applyAbsoluteOffset(value: CGPoint(x: 0.0, y: -completeOffset), animationCurve: animationCurve, duration: animationDuration)
@ -3747,7 +3747,7 @@ open class ListViewImpl: ASDisplayNode, ListView, ASScrollViewDelegate, ASGestur
headerNode.removeFromSupernode()
}
}
self.layer.add(animation, forKey: nil)
self.layer.add(animation, forKey: "animation-\(ObjectIdentifier(animation))")
}
for itemNode in self.itemNodes {

View file

@ -6078,12 +6078,16 @@ func replayFinalState(
if let current, current.id > update.id {
return current
}
var timestamp = update.timestamp
if let current, current.id == update.id {
timestamp = current.timestamp
}
return (
update.id,
Namespaces.Message.Cloud,
update.threadId,
update.authorId,
update.timestamp,
timestamp,
update.text,
[
TypingDraftMessageAttribute(),

View file

@ -30,6 +30,7 @@ public struct UserLimitsConfiguration: Equatable {
public var maxChannelRecommendationsCount: Int32
public var maxConferenceParticipantCount: Int32
public var maxBotsCreated: Int32
public var maxOwnedAITextStyles: Int32
public static var defaultValue: UserLimitsConfiguration {
return UserLimitsConfiguration(
@ -60,7 +61,8 @@ public struct UserLimitsConfiguration: Equatable {
maxGiveawayPeriodSeconds: 86400 * 31,
maxChannelRecommendationsCount: 10,
maxConferenceParticipantCount: 100,
maxBotsCreated: 20
maxBotsCreated: 20,
maxOwnedAITextStyles: 5
)
}
@ -92,7 +94,8 @@ public struct UserLimitsConfiguration: Equatable {
maxGiveawayPeriodSeconds: Int32,
maxChannelRecommendationsCount: Int32,
maxConferenceParticipantCount: Int32,
maxBotsCreated: Int32
maxBotsCreated: Int32,
maxOwnedAITextStyles: Int32
) {
self.maxPinnedChatCount = maxPinnedChatCount
self.maxPinnedSavedChatCount = maxPinnedSavedChatCount
@ -122,6 +125,7 @@ public struct UserLimitsConfiguration: Equatable {
self.maxChannelRecommendationsCount = maxChannelRecommendationsCount
self.maxConferenceParticipantCount = maxConferenceParticipantCount
self.maxBotsCreated = maxBotsCreated
self.maxOwnedAITextStyles = maxOwnedAITextStyles
}
}
@ -177,5 +181,6 @@ extension UserLimitsConfiguration {
self.maxChannelRecommendationsCount = getValue("recommended_channels_limit", orElse: defaultValue.maxChannelRecommendationsCount)
self.maxConferenceParticipantCount = getGeneralValue("conference_call_size_limit", orElse: defaultValue.maxConferenceParticipantCount)
self.maxBotsCreated = getValue("bots_create_limit", orElse: defaultValue.maxBotsCreated)
self.maxOwnedAITextStyles = getValue("aicompose_tone_saved_limit", orElse: defaultValue.maxBotsCreated)
}
}

View file

@ -64,6 +64,7 @@ public enum EngineConfiguration {
public let maxChannelRecommendationsCount: Int32
public let maxConferenceParticipantCount: Int32
public let maxBotsCreated: Int32
public let maxOwnedAITextStyles: Int32
public static var defaultValue: UserLimits {
return UserLimits(UserLimitsConfiguration.defaultValue)
@ -97,7 +98,8 @@ public enum EngineConfiguration {
maxGiveawayPeriodSeconds: Int32,
maxChannelRecommendationsCount: Int32,
maxConferenceParticipantCount: Int32,
maxBotsCreated: Int32
maxBotsCreated: Int32,
maxOwnedAITextStyles: Int32
) {
self.maxPinnedChatCount = maxPinnedChatCount
self.maxPinnedSavedChatCount = maxPinnedSavedChatCount
@ -127,6 +129,7 @@ public enum EngineConfiguration {
self.maxChannelRecommendationsCount = maxChannelRecommendationsCount
self.maxConferenceParticipantCount = maxConferenceParticipantCount
self.maxBotsCreated = maxBotsCreated
self.maxOwnedAITextStyles = maxOwnedAITextStyles
}
}
}
@ -191,7 +194,8 @@ public extension EngineConfiguration.UserLimits {
maxGiveawayPeriodSeconds: userLimitsConfiguration.maxGiveawayPeriodSeconds,
maxChannelRecommendationsCount: userLimitsConfiguration.maxChannelRecommendationsCount,
maxConferenceParticipantCount: userLimitsConfiguration.maxConferenceParticipantCount,
maxBotsCreated: userLimitsConfiguration.maxBotsCreated
maxBotsCreated: userLimitsConfiguration.maxBotsCreated,
maxOwnedAITextStyles: userLimitsConfiguration.maxOwnedAITextStyles
)
}
}

View file

@ -798,7 +798,7 @@ public class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode {
textFrameWithoutInsets = textFrameWithoutInsets.offsetBy(dx: layoutConstants.text.bubbleInsets.left, dy: topInset)
var suggestedBoundingWidth: CGFloat = textFrameWithoutInsets.width
if let statusSuggestedWidthAndContinue = statusSuggestedWidthAndContinue {
if let statusSuggestedWidthAndContinue = statusSuggestedWidthAndContinue, !hasDraft {
suggestedBoundingWidth = max(suggestedBoundingWidth, statusSuggestedWidthAndContinue.0)
}
let sideInsets = layoutConstants.text.bubbleInsets.left + layoutConstants.text.bubbleInsets.right
@ -810,7 +810,7 @@ public class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode {
let statusSizeAndApply = statusSuggestedWidthAndContinue?.1(boundingWidth - sideInsets)
boundingSize = textFrameWithoutInsets.size
if let statusSizeAndApply = statusSizeAndApply {
if let statusSizeAndApply = statusSizeAndApply, !hasDraft {
boundingSize.height += statusSizeAndApply.0.height
}

View file

@ -1106,6 +1106,9 @@ public final class NavigationBarImpl: ASDisplayNode, NavigationBar {
titleViewTransition.updateFrame(view: titleView, frame: titleFrame)
} else {
leftTitleInset = max(leftTitleInset, rightTitleInset)
rightTitleInset = leftTitleInset
let titleSize = CGSize(width: max(1.0, size.width - leftTitleInset - rightTitleInset), height: nominalHeight)
var titleFrame = CGRect(origin: CGPoint(x: floor((size.width - titleSize.width) / 2.0), y: contentVerticalOrigin + floorToScreenPixels((nominalHeight - titleSize.height) / 2.0)), size: titleSize)
if titleFrame.origin.x + titleFrame.width > size.width - rightTitleInset {

View file

@ -285,21 +285,39 @@ final class TextProcessingContentComponent: Component {
return
}
environment.controller()?.push(textAlertController(
context: component.context,
title: "Delete Style",
text: "Are you sure you want to delete this style? It will be removed for everyone who installed it.",
actions: [
TextAlertAction(type: .genericAction, title: environment.strings.Common_Cancel, action: {}),
TextAlertAction(type: .destructiveAction, title: environment.strings.Common_Delete, action: { [weak self] in
guard let self, let component = self.component else {
return
}
let _ = component.context.engine.messages.deleteAITextStyle(id: style.id, accessHash: style.accessHash).startStandalone()
component.styleDeleted(id.id)
}),
]
))
if style.isCreator {
environment.controller()?.push(textAlertController(
context: component.context,
title: "Delete Style",
text: "Are you sure you want to delete this style? It will be removed for everyone who installed it.",
actions: [
TextAlertAction(type: .genericAction, title: environment.strings.Common_Cancel, action: {}),
TextAlertAction(type: .destructiveAction, title: environment.strings.Common_Delete, action: { [weak self] in
guard let self, let component = self.component else {
return
}
let _ = component.context.engine.messages.deleteAITextStyle(id: style.id, accessHash: style.accessHash).startStandalone()
component.styleDeleted(id.id)
}),
]
))
} else {
environment.controller()?.push(textAlertController(
context: component.context,
title: "Delete Style",
text: "Are you sure you want to delete this style?",
actions: [
TextAlertAction(type: .genericAction, title: environment.strings.Common_Cancel, action: {}),
TextAlertAction(type: .destructiveAction, title: environment.strings.Common_Delete, action: { [weak self] in
guard let self, let component = self.component else {
return
}
let _ = component.context.engine.messages.unsaveAITextStyle(id: style.id, accessHash: style.accessHash).startStandalone()
component.styleDeleted(id.id)
}),
]
))
}
}
private func openStyleContextMenu(id: TelegramComposeAIMessageMode.StyleReference, gesture: ContextGesture, sourceView: ContextExtractedContentContainingView) {
@ -749,6 +767,53 @@ final class TextProcessingContentComponent: Component {
guard let component = self.component, let environment = self.environment else {
return
}
let hasPremium = await (component.context.engine.data.get(
TelegramEngine.EngineData.Item.Peer.Peer(id: component.context.account.peerId)
)
|> map { peer -> Bool in
guard case let .user(user) = peer else {
return false
}
return user.isPremium
}).get()
let userLimits = await component.context.engine.data.get(
TelegramEngine.EngineData.Item.Configuration.UserLimits(isPremium: hasPremium)
).get()
let maxStyles = Int(userLimits.maxOwnedAITextStyles)
if component.styles.filter({ $0.isAuthor }).count >= maxStyles {
if !hasPremium {
//TODO:localize
let context = component.context
var replaceImpl: ((ViewController) -> Void)?
let controller = context.sharedContext.makePremiumDemoController(context: context, subject: .aiTools, forceDark: false, action: {
let controller = context.sharedContext.makePremiumIntroController(context: context, source: .storiesStealthMode, forceDark: false, dismissed: nil)
replaceImpl?(controller)
}, dismissed: nil)
replaceImpl = { [weak self, weak controller] c in
controller?.dismiss(animated: true, completion: {
guard let self else {
return
}
self.environment?.controller()?.push(c)
})
}
environment.controller()?.push(controller)
} else {
environment.controller()?.push(textAlertController(
context: component.context,
title: "Too Many Styles",
text: "Please delete some of your saved styles to create a new one.",
actions: [
TextAlertAction(type: .defaultAction, title: environment.strings.Common_OK, action: {}),
]
))
}
return
}
environment.controller()?.push(await TextStyleEditScreen(
context: component.context,
mode: .create,
@ -1101,7 +1166,6 @@ private final class TextProcessingSheetComponent: Component {
final class View: UIView {
private let sheet = ComponentView<(ViewControllerComponentContainer.Environment, ResizableSheetComponentEnvironment)>()
private var toast: ComponentView<Empty>?
private var languageSelectionMenu: ComponentView<Empty>?
private let animateOut = ActionSlot<Action<Void>>()
private let contentExternalState = TextProcessingContentComponent.ExternalState()
@ -1123,6 +1187,10 @@ private final class TextProcessingSheetComponent: Component {
}
}
private var languageSelectionMenuData: LanguageSelectionMenuData?
private var languageSelectionMenu: ComponentView<Empty>?
private var styleCreatedToastData: (timer: Foundation.Timer, emojiFile: TelegramMediaFile, style: TelegramComposeAIMessageMode.CloudStyle.Custom)?
private var styleCreatedToast: ComponentView<Empty>?
private var component: TextProcessingSheetComponent?
private var environment: ViewControllerComponentContainer.Environment?
@ -1142,6 +1210,7 @@ private final class TextProcessingSheetComponent: Component {
deinit {
self.actionDisposable?.dispose()
self.styleCreatedToastData?.timer.invalidate()
}
private func displayLongPressSendMenu(sourceSendButton: UIView) {
@ -1433,6 +1502,21 @@ private final class TextProcessingSheetComponent: Component {
TelegramEngine.EngineData.Item.Peer.Peer(id: authorId)
).get()
}
if case let .custom(style) = style.content, let emojiFileId = style.emojiFileId {
let emojiFile = await component.context.engine.stickers.resolveInlineStickersLocal(fileIds: [emojiFileId]).get().first?.value
if let emojiFile {
self.styleCreatedToastData = (Foundation.Timer.scheduledTimer(withTimeInterval: 5.0, repeats: false, block: { [weak self] _ in
guard let self else {
return
}
self.styleCreatedToastData = nil
self.state?.updated(transition: .spring(duration: 0.4))
}), emojiFile, style)
}
}
self.styles.insert(TextProcessingScreen.Style(cloudStyle: style, authorPeer: authorPeer), at: 0)
self.state?.updated(transition: .spring(duration: 0.4))
}
@ -1636,6 +1720,73 @@ private final class TextProcessingSheetComponent: Component {
}
}
}
if let styleCreatedToastData = self.styleCreatedToastData, !self.contentExternalState.nonPremiumFloodTriggered {
let sideInset: CGFloat = 8.0
let styleCreatedToast: ComponentView<Empty>
var styleCreatedToastTransition = transition
if let current = self.styleCreatedToast {
styleCreatedToast = current
} else {
styleCreatedToastTransition = styleCreatedToastTransition.withAnimation(.none)
styleCreatedToast = ComponentView()
self.styleCreatedToast = styleCreatedToast
}
let body = MarkdownAttributeSet(font: Font.regular(14.0), textColor: .white)
let bold = MarkdownAttributeSet(font: Font.semibold(14.0), textColor: .white)
let styleCreatedToastSize = styleCreatedToast.update(
transition: styleCreatedToastTransition,
component: AnyComponent(ToastContentComponent(
icon: AnyComponent(EmojiStatusComponent(
context: component.context,
animationCache: component.context.animationCache,
animationRenderer: component.context.animationRenderer,
content: .animation(
content: .file(file: styleCreatedToastData.emojiFile),
size: CGSize(width: 32.0, height: 32.0),
placeholderColor: environmentValue.theme.list.mediaPlaceholderColor,
themeColor: environmentValue.theme.list.itemPrimaryTextColor,
loopMode: .count(1)
),
size: CGSize(width: 32.0, height: 32.0),
isVisibleForAnimations: true,
action: nil
)),
content: AnyComponent(VStack([
AnyComponentWithIdentity(id: 0, component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(string: "\(styleCreatedToastData.style.title) style created!", font: Font.semibold(14.0), textColor: .white)),
))),
AnyComponentWithIdentity(id: 1, component: AnyComponent(MultilineTextComponent(
text: .markdown(text: "Press and hold a style to edit or share the link.", attributes: MarkdownAttributes(body: body, bold: bold, link: body, linkAttribute: { _ in nil })),
maximumNumberOfLines: 0
)))
], alignment: .left, spacing: 6.0)),
insets: UIEdgeInsets(top: 10.0, left: 12.0, bottom: 10.0, right: 10.0),
iconSpacing: 12.0
)),
environment: {},
containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: availableSize.height)
)
if let styleCreatedToastView = styleCreatedToast.view {
if styleCreatedToastView.superview == nil, let sheetView = self.sheet.view as? ResizableSheetComponent<ViewControllerComponentContainer.Environment>.View {
sheetView.containerView.addSubview(styleCreatedToastView)
if !transition.animation.isImmediate {
styleCreatedToastView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25)
}
}
styleCreatedToastTransition.setFrame(view: styleCreatedToastView, frame: CGRect(origin: CGPoint(x: sideInset, y: availableSize.height - 94.0 - styleCreatedToastSize.height), size: styleCreatedToastSize))
}
} else {
if let styleCreatedToast = self.styleCreatedToast {
self.styleCreatedToast = nil
if let styleCreatedToastView = styleCreatedToast.view {
styleCreatedToastView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, removeOnCompletion: false, completion: { [weak styleCreatedToastView] _ in
styleCreatedToastView?.removeFromSuperview()
})
}
}
}
if let languageSelectionMenuDataValue = self.languageSelectionMenuData {
let languageSelectionMenu: ComponentView<Empty>

View file

@ -152,6 +152,53 @@ final class TextStyleEditContentComponent: Component {
}
}
}
func activateEmojiSelection() {
guard let component = self.component else {
return
}
guard let iconBackgroundView = self.iconBackground.view else {
return
}
self.environment?.controller()?.present(component.context.sharedContext.makeEmojiStatusSelectionController(
context: component.context,
mode: .backgroundSelection(completion: { [weak self] file in
guard let self, let component = self.component else {
return
}
component.externalState.emojiFile = file
self.state?.updated(transition: .immediate)
DispatchQueue.main.async { [weak self] in
guard let self else {
return
}
self.state?.updated(transition: .immediate)
}
}),
sourceView: iconBackgroundView,
emojiContent: EmojiPagerContentComponent.emojiInputData(
context: component.context,
animationCache: component.context.animationCache,
animationRenderer: component.context.animationRenderer,
isStandalone: false,
subject: .emoji,
hasTrending: false,
topReactionItems: [],
areUnicodeEmojiEnabled: false,
areCustomEmojiEnabled: true,
chatPeerId: component.context.account.peerId,
selectedItems: Set()
) |> map { $0 },
currentSelection: nil,
color: nil,
destinationItemView: { [weak self] in
guard let self else {
return nil
}
return self.emojiIcon?.view
}
), in: .window(.root))
}
func update(component: TextStyleEditContentComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<ViewControllerComponentContainer.Environment>, transition: ComponentTransition) -> CGSize {
self.isUpdating = true
@ -195,50 +242,10 @@ final class TextStyleEditContentComponent: Component {
smoothCorners: false
)),
action: { [weak self] in
guard let self, let component = self.component else {
guard let self else {
return
}
guard let iconBackgroundView = self.iconBackground.view else {
return
}
self.environment?.controller()?.present(component.context.sharedContext.makeEmojiStatusSelectionController(
context: component.context,
mode: .backgroundSelection(completion: { [weak self] file in
guard let self, let component = self.component else {
return
}
component.externalState.emojiFile = file
self.state?.updated(transition: .immediate)
DispatchQueue.main.async { [weak self] in
guard let self else {
return
}
self.state?.updated(transition: .immediate)
}
}),
sourceView: iconBackgroundView,
emojiContent: EmojiPagerContentComponent.emojiInputData(
context: component.context,
animationCache: component.context.animationCache,
animationRenderer: component.context.animationRenderer,
isStandalone: false,
subject: .emoji,
hasTrending: false,
topReactionItems: [],
areUnicodeEmojiEnabled: false,
areCustomEmojiEnabled: true,
chatPeerId: component.context.account.peerId,
selectedItems: Set()
) |> map { $0 },
currentSelection: nil,
color: nil,
destinationItemView: { [weak self] in
guard let self else {
return nil
}
return self.emojiIcon?.view
}
), in: .window(.root))
self.activateEmojiSelection()
},
animateAlpha: false,
animateScale: false,
@ -338,9 +345,9 @@ final class TextStyleEditContentComponent: Component {
return ListMultilineTextFieldItemComponent.ResetText(value: resetTitle)
},
placeholder: "Style Name (for example, \"Pirate\")",
autocapitalizationType: .none,
autocorrectionType: .no,
characterLimit: 256,
autocapitalizationType: .sentences,
autocorrectionType: .default,
characterLimit: 12,
emptyLineHandling: .notAllowed,
updated: nil,
textUpdateTransition: .spring(duration: 0.4),
@ -383,9 +390,9 @@ final class TextStyleEditContentComponent: Component {
},
placeholder: "Instructions (for example, \"Write like a swashbuckling pirate. Use arr, ye, matey, and talk about treasure, the sea, and rum\")",
placeholderDefinesMinHeight: true,
autocapitalizationType: .none,
autocorrectionType: .no,
characterLimit: 4096,
autocapitalizationType: .sentences,
autocorrectionType: .default,
characterLimit: 1024,
emptyLineHandling: .allowed,
updated: nil,
textUpdateTransition: .spring(duration: 0.4),
@ -615,6 +622,11 @@ private final class TextStyleEditSheetComponent: Component {
return
}
guard let emojiFile = self.contentState.emojiFile else {
if let sheetView = self.sheet.view as? ResizableSheetComponent<ViewControllerComponentContainer.Environment>.View {
if let contentView = sheetView.contentViewValue as? TextStyleEditContentComponent.View {
contentView.activateEmojiSelection()
}
}
return
}
@ -731,7 +743,7 @@ private final class TextStyleEditSheetComponent: Component {
}
self.performCreateStyle()
}
let isMainActionEnabled = !self.contentState.titleInputState.text.string.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !self.contentState.textInputState.text.string.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && self.contentState.emojiFile != nil && !self.isActionInProgress
let isMainActionEnabled = !self.contentState.titleInputState.text.string.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !self.contentState.textInputState.text.string.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !self.isActionInProgress
let actionButtonTitle: String
let titleString: String
switch component.mode {

View file

@ -1177,6 +1177,7 @@ public final class ChatHistoryListNodeImpl: ListViewImpl, ChatHistoryNode, ChatH
}
self.isInteractivelyScrollingValue = true
self.isInteractivelyScrollingPromise.set(true)
//self.pinToTopStableId = nil
self.beganDragging?()
}
@ -2150,7 +2151,7 @@ public final class ChatHistoryListNodeImpl: ListViewImpl, ChatHistoryNode, ChatH
if message.attributes.contains(where: { $0 is TypingDraftMessageAttribute }) {
for j in (0 ..< i).reversed() {
let otherMessage = view.entries[j].message
if !otherMessage.effectivelyIncoming(context.account.peerId) {
if !otherMessage.effectivelyIncoming(context.account.peerId) && otherMessage.id.namespace == Namespaces.Message.Cloud {
if strongSelf.pinToTopStableId != otherMessage.stableId {
strongSelf.pinToTopStableId = otherMessage.stableId
scrollToPinToTopStableId = true