Various improvements

This commit is contained in:
Ilya Laktyushin 2025-06-08 18:28:49 +02:00
parent bc04d3c8e9
commit 41ae916106
60 changed files with 3715 additions and 2865 deletions

View File

@ -14412,3 +14412,19 @@ Sorry for the inconvenience.";
"Stars.SendMessage.AdjustmentAction" = "OK";
"Stars.SendMessage.PriceFree" = "Free";
"Notification.TodoTasks_1" = "%@ task";
"Notification.TodoTasks_any" = "%@ tasks";
"Notification.TodoCompleted" = "%1$@ marked \"%2$@\" as done.";
"Notification.TodoIncompleted" = "%1$@ marked \"%2$@\" as undone.";
"Notification.TodoAddedTask" = "%1$@ added a new task \"%2$@\" to \"%3$@\".";
"Notification.TodoAddedMultipleTasks" = "%1$@ added %2$@ to \"%3$@\".";
"Notification.TodoCompletedYou" = "You marked \"%1$@\" as done.";
"Notification.TodoIncompletedYou" = "You marked \"%1$@\" as not done.";
"Notification.TodoAddedTaskYou" = "You added a new task \"%1$@\" to \"%2$@\".";
"Notification.TodoAddedMultipleTasksYou" = "You added %1$@ to \"%2$@\".";
"Stars.Transaction.GiftTransfer" = "Gift Transfer";
"Stars.Intro.Transaction.GiftTransfer" = "Gift Transfer";

View File

@ -43,6 +43,7 @@ public enum PremiumIntroSource {
case animatedEmoji
case messageEffects
case paidMessages
case todo
case auth(String)
}
@ -82,6 +83,7 @@ public enum PremiumDemoSubject {
case business
case messageEffects
case paidMessages
case todo
case businessLocation
case businessHours

View File

@ -21,6 +21,7 @@ public enum AttachmentButtonType: Equatable {
case gallery
case file
case location
case todo
case quickReply
case contact
case poll
@ -36,6 +37,8 @@ public enum AttachmentButtonType: Equatable {
return "file"
case .location:
return "location"
case .todo:
return "todo"
case .quickReply:
return "quickReply"
case .contact:
@ -71,6 +74,12 @@ public enum AttachmentButtonType: Equatable {
} else {
return false
}
case .todo:
if case .todo = rhs {
return true
} else {
return false
}
case .quickReply:
if case .quickReply = rhs {
return true

View File

@ -195,6 +195,9 @@ private final class AttachButtonComponent: CombinedComponent {
case .location:
name = strings.Attachment_Location
imageName = "Chat/Attach Menu/Location"
case .todo:
name = "To Do List"
imageName = "Chat/Attach Menu/Todo"
case .contact:
name = strings.Attachment_Contact
imageName = "Chat/Attach Menu/Contact"
@ -1267,6 +1270,7 @@ final class AttachmentPanel: ASDisplayNode, ASScrollViewDelegate {
}, openBoostToUnrestrict: {
}, updateRecordingTrimRange: { _, _, _, _ in
}, dismissAllTooltips: {
}, editTodoMessage: { _, _ in
}, updateHistoryFilter: { _ in
}, updateChatLocationThread: { _, _ in
}, toggleChatSidebarMode: {
@ -1490,6 +1494,9 @@ final class AttachmentPanel: ASDisplayNode, ASScrollViewDelegate {
accessibilityTitle = self.presentationData.strings.Attachment_File
case .location:
accessibilityTitle = self.presentationData.strings.Attachment_Location
case .todo:
//TODO:localize
accessibilityTitle = "To Do List"
case .contact:
accessibilityTitle = self.presentationData.strings.Attachment_Contact
case .poll:

View File

@ -333,7 +333,8 @@ public func peerAvatarImage(postbox: Postbox, network: Network, peerReference: P
if let cutoutRect {
context.setBlendMode(.copy)
context.setFillColor(UIColor.clear.cgColor)
context.fillEllipse(in: cutoutRect.offsetBy(dx: 0.0, dy: size.height - cutoutRect.maxY - cutoutRect.height))
//TODO:fix
context.fillEllipse(in: cutoutRect)
}
})
let unroundedImage: UIImage?

View File

@ -176,6 +176,8 @@ public final class BrowserBookmarksScreen: ViewController {
}, playShakeAnimation: {
}, displayQuickShare: { _, _ ,_ in
}, updateChatLocationThread: { _, _ in
}, requestToggleTodoMessageItem: { _, _, _ in
}, displayTodoToggleUnavailable: { _ in
}, automaticMediaDownloadSettings: MediaAutoDownloadSettings.defaultSettings, pollActionState: ChatInterfacePollActionState(), stickerSettings: ChatInterfaceStickerSettings(), presentationContext: ChatPresentationContext(context: context, backgroundNode: nil))

View File

@ -108,7 +108,7 @@ swift_library(
"//submodules/TelegramUI/Components/PeerManagement/OldChannelsController",
"//submodules/TelegramUI/Components/TextFieldComponent",
"//submodules/TelegramUI/Components/ChatEntityKeyboardInputNode",
"//submodules/ComposePollUI",
"//submodules/TelegramUI/Components/ListComposePollOptionComponent",
"//submodules/ChatPresentationInterfaceState",
"//submodules/ShimmerEffect:ShimmerEffect",
"//submodules/TelegramUI/Components/LottieComponent",

View File

@ -20,7 +20,7 @@ import AsyncDisplayKit
import UndoUI
import PeerNameColorItem
import EntityKeyboard
import ComposePollUI
import ListComposePollOptionComponent
import ChatEntityKeyboardInputNode
import ComponentFlow
import ChatPresentationInterfaceState

View File

@ -8,7 +8,7 @@ import TextNodeWithEntities
import AccountContext
import ItemListUI
import ComponentFlow
import ComposePollUI
import ListComposePollOptionComponent
import TextFieldComponent
public class ItemListFilterTitleInputItem: ListViewItem, ItemListItem {

View File

@ -423,6 +423,20 @@ public func chatListItemStrings(strings: PresentationStrings, nameDisplayOrder:
if messageText.isEmpty, case let .Loaded(content) = webpage.content {
messageText = content.displayUrl
}
case let todo as TelegramMediaTodo:
let pollPrefix = "☑️ "
let entityOffset = (pollPrefix as NSString).length
messageText = "\(pollPrefix)\(todo.text)"
for entity in todo.textEntities {
if case let .CustomEmoji(_, fileId) = entity.type {
if customEmojiRanges == nil {
customEmojiRanges = []
}
let range = NSRange(location: entityOffset + entity.range.lowerBound, length: entity.range.upperBound - entity.range.lowerBound)
let attribute = ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: nil, fileId: fileId, file: message.associatedMedia[EngineMedia.Id(namespace: Namespaces.Media.CloudFile, id: fileId)] as? TelegramMediaFile)
customEmojiRanges?.append((range, attribute))
}
}
default:
break
}

View File

@ -177,6 +177,7 @@ public final class ChatPanelInterfaceInteraction {
public let openBoostToUnrestrict: () -> Void
public let updateRecordingTrimRange: (Double, Double, Bool, Bool) -> Void
public let dismissAllTooltips: () -> Void
public let editTodoMessage: (MessageId, Bool) -> Void
public let requestLayout: (ContainedViewLayoutTransition) -> Void
public let chatController: () -> ViewController?
public let statuses: ChatPanelInterfaceInteractionStatuses?
@ -293,6 +294,7 @@ public final class ChatPanelInterfaceInteraction {
openBoostToUnrestrict: @escaping () -> Void,
updateRecordingTrimRange: @escaping (Double, Double, Bool, Bool) -> Void,
dismissAllTooltips: @escaping () -> Void,
editTodoMessage: @escaping (MessageId, Bool) -> Void,
updateHistoryFilter: @escaping ((ChatPresentationInterfaceState.HistoryFilter?) -> ChatPresentationInterfaceState.HistoryFilter?) -> Void,
updateChatLocationThread: @escaping (Int64?, ChatControllerAnimateInnerChatSwitchDirection?) -> Void,
toggleChatSidebarMode: @escaping () -> Void,
@ -412,6 +414,7 @@ public final class ChatPanelInterfaceInteraction {
self.openBoostToUnrestrict = openBoostToUnrestrict
self.updateRecordingTrimRange = updateRecordingTrimRange
self.dismissAllTooltips = dismissAllTooltips
self.editTodoMessage = editTodoMessage
self.updateHistoryFilter = updateHistoryFilter
self.updateChatLocationThread = updateChatLocationThread
self.toggleChatSidebarMode = toggleChatSidebarMode
@ -540,6 +543,7 @@ public final class ChatPanelInterfaceInteraction {
}, openBoostToUnrestrict: {
}, updateRecordingTrimRange: { _, _, _, _ in
}, dismissAllTooltips: {
}, editTodoMessage: { _, _ in
}, updateHistoryFilter: { _ in
}, updateChatLocationThread: { _, _ in
}, toggleChatSidebarMode: {

View File

@ -41,6 +41,7 @@ swift_library(
"//submodules/TelegramUI/Components/PlainButtonComponent",
"//submodules/ChatPresentationInterfaceState",
"//submodules/TelegramUI/Components/EmojiSuggestionsComponent",
"//submodules/TelegramUI/Components/ListComposePollOptionComponent",
],
visibility = [
"//visibility:public",

View File

@ -26,6 +26,49 @@ import ChatPresentationInterfaceState
import EmojiSuggestionsComponent
import TextFormat
import TextFieldComponent
import ListComposePollOptionComponent
public final class ComposedPoll {
public struct Text {
public let string: String
public let entities: [MessageTextEntity]
public init(string: String, entities: [MessageTextEntity]) {
self.string = string
self.entities = entities
}
}
public let publicity: TelegramMediaPollPublicity
public let kind: TelegramMediaPollKind
public let text: Text
public let options: [TelegramMediaPollOption]
public let correctAnswers: [Data]?
public let results: TelegramMediaPollResults
public let deadlineTimeout: Int32?
public let usedCustomEmojiFiles: [Int64: TelegramMediaFile]
public init(
publicity: TelegramMediaPollPublicity,
kind: TelegramMediaPollKind,
text: Text,
options: [TelegramMediaPollOption],
correctAnswers: [Data]?,
results: TelegramMediaPollResults,
deadlineTimeout: Int32?,
usedCustomEmojiFiles: [Int64: TelegramMediaFile]
) {
self.publicity = publicity
self.kind = kind
self.text = text
self.options = options
self.correctAnswers = correctAnswers
self.results = results
self.deadlineTimeout = deadlineTimeout
self.usedCustomEmojiFiles = usedCustomEmojiFiles
}
}
final class ComposePollScreenComponent: Component {
typealias EnvironmentType = ViewControllerComponentContainer.Environment
@ -1585,7 +1628,7 @@ public class ComposePollScreen: ViewControllerComponentContainer, AttachmentCont
maxPollAnwsersCount = Int(value)
}
return InitialData(
maxPollTextLength: Int(200),
maxPollTextLength: 200,
maxPollOptionLength: 100,
maxPollAnwsersCount: maxPollAnwsersCount
)

File diff suppressed because it is too large Load Diff

View File

@ -1,274 +0,0 @@
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import SwiftSignalKit
import TelegramPresentationData
import ItemListUI
import PresentationDataUtils
class CreatePollOptionActionItem: ListViewItem, ItemListItem {
let theme: PresentationTheme
let title: String
let enabled: Bool
let tag: ItemListItemTag?
let sectionId: ItemListSectionId
let action: () -> Void
init(theme: PresentationTheme, title: String, enabled: Bool, tag: ItemListItemTag?, sectionId: ItemListSectionId, action: @escaping () -> Void) {
self.theme = theme
self.title = title
self.enabled = enabled
self.tag = tag
self.sectionId = sectionId
self.action = action
}
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) {
async {
let node = CreatePollOptionActionItemNode()
let (layout, apply) = node.asyncLayout()(self, params, itemListNeighbors(item: self, topItem: previousItem as? ItemListItem, bottomItem: nextItem as? ItemListItem))
node.contentSize = layout.contentSize
node.insets = layout.insets
Queue.mainQueue().async {
completion(node, {
return (nil, { _ in apply(false) })
})
}
}
}
func updateNode(async: @escaping (@escaping () -> Void) -> Void, node: @escaping () -> ListViewItemNode, params: ListViewItemLayoutParams, previousItem: ListViewItem?, nextItem: ListViewItem?, animation: ListViewItemUpdateAnimation, completion: @escaping (ListViewItemNodeLayout, @escaping (ListViewItemApply) -> Void) -> Void) {
Queue.mainQueue().async {
if let nodeValue = node() as? CreatePollOptionActionItemNode {
let makeLayout = nodeValue.asyncLayout()
var animated = true
if case .None = animation {
animated = false
}
async {
let (layout, apply) = makeLayout(self, params, itemListNeighbors(item: self, topItem: previousItem as? ItemListItem, bottomItem: nextItem as? ItemListItem))
Queue.mainQueue().async {
completion(layout, { _ in
apply(animated)
})
}
}
}
}
}
var selectable: Bool {
return self.enabled
}
func selected(listView: ListView){
listView.clearHighlightAnimated(true)
self.action()
}
}
private let titleFont = Font.regular(17.0)
class CreatePollOptionActionItemNode: ListViewItemNode, ItemListItemNode {
private let backgroundNode: ASDisplayNode
private let topStripeNode: ASDisplayNode
private let bottomStripeNode: ASDisplayNode
private let highlightedBackgroundNode: ASDisplayNode
private let maskNode: ASImageNode
private let iconNode: ASImageNode
private let titleNode: TextNode
private var item: CreatePollOptionActionItem?
var tag: ItemListItemTag? {
return self.item?.tag
}
init() {
self.backgroundNode = ASDisplayNode()
self.backgroundNode.isLayerBacked = true
self.topStripeNode = ASDisplayNode()
self.topStripeNode.isLayerBacked = true
self.bottomStripeNode = ASDisplayNode()
self.bottomStripeNode.isLayerBacked = true
self.maskNode = ASImageNode()
self.iconNode = ASImageNode()
self.iconNode.isLayerBacked = true
self.iconNode.displayWithoutProcessing = true
self.iconNode.displaysAsynchronously = false
self.titleNode = TextNode()
self.titleNode.isUserInteractionEnabled = false
self.titleNode.contentMode = .left
self.titleNode.contentsScale = UIScreen.main.scale
self.highlightedBackgroundNode = ASDisplayNode()
self.highlightedBackgroundNode.isLayerBacked = true
super.init(layerBacked: false, dynamicBounce: false)
self.addSubnode(self.iconNode)
self.addSubnode(self.titleNode)
}
func asyncLayout() -> (_ item: CreatePollOptionActionItem, _ params: ListViewItemLayoutParams, _ neighbors: ItemListNeighbors) -> (ListViewItemNodeLayout, (Bool) -> Void) {
let makeTitleLayout = TextNode.asyncLayout(self.titleNode)
let currentItem = self.item
return { item, params, neighbors in
var updatedTheme: PresentationTheme?
var updatedIcon: UIImage?
if currentItem?.theme !== item.theme {
updatedTheme = item.theme
updatedIcon = PresentationResourcesItemList.addPhoneIcon(item.theme)
}
let leftInset: CGFloat = 60.0 + params.leftInset
let (titleLayout, titleApply) = makeTitleLayout(TextNodeLayoutArguments(attributedString: NSAttributedString(string: item.title, font: titleFont, textColor: item.enabled ? item.theme.list.itemAccentColor : item.theme.list.itemDisabledTextColor), backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: params.width - leftInset - 8.0, height: CGFloat.greatestFiniteMagnitude), alignment: .natural, cutout: nil, insets: UIEdgeInsets()))
let separatorHeight = UIScreenPixel
let insets = itemListNeighborsGroupedInsets(neighbors, params)
let contentSize = CGSize(width: params.width, height: 44.0)
let layout = ListViewItemNodeLayout(contentSize: contentSize, insets: insets)
let layoutSize = layout.size
return (layout, { [weak self] animated in
if let strongSelf = self {
strongSelf.item = item
if let _ = updatedTheme {
strongSelf.topStripeNode.backgroundColor = item.theme.list.itemBlocksSeparatorColor
strongSelf.bottomStripeNode.backgroundColor = item.theme.list.itemBlocksSeparatorColor
strongSelf.backgroundNode.backgroundColor = item.theme.list.itemBlocksBackgroundColor
strongSelf.highlightedBackgroundNode.backgroundColor = item.theme.list.itemHighlightedBackgroundColor
}
let _ = titleApply()
let transition: ContainedViewLayoutTransition
if animated {
transition = ContainedViewLayoutTransition.animated(duration: 0.4, curve: .spring)
} else {
transition = .immediate
}
if let updatedIcon = updatedIcon {
strongSelf.iconNode.image = updatedIcon
}
if let image = strongSelf.iconNode.image {
transition.updateFrame(node: strongSelf.iconNode, frame: CGRect(origin: CGPoint(x: params.leftInset + floor((leftInset - params.leftInset - image.size.width) / 2.0 - 3.0), y: floor((contentSize.height - image.size.height) / 2.0)), size: image.size))
}
if strongSelf.backgroundNode.supernode == nil {
strongSelf.insertSubnode(strongSelf.backgroundNode, at: 0)
}
if strongSelf.topStripeNode.supernode == nil {
strongSelf.insertSubnode(strongSelf.topStripeNode, at: 1)
}
if strongSelf.bottomStripeNode.supernode == nil {
strongSelf.insertSubnode(strongSelf.bottomStripeNode, at: 2)
}
if strongSelf.maskNode.supernode == nil {
strongSelf.insertSubnode(strongSelf.maskNode, at: 3)
}
let hasCorners = itemListHasRoundedBlockLayout(params)
var hasTopCorners = false
var hasBottomCorners = false
switch neighbors.top {
case .sameSection(false):
strongSelf.topStripeNode.isHidden = true
default:
hasTopCorners = true
strongSelf.topStripeNode.isHidden = hasCorners
}
let bottomStripeInset: CGFloat
let bottomStripeOffset: CGFloat
switch neighbors.bottom {
case .sameSection(false):
bottomStripeInset = leftInset
bottomStripeOffset = -separatorHeight
strongSelf.bottomStripeNode.isHidden = false
default:
bottomStripeInset = 0.0
bottomStripeOffset = 0.0
hasBottomCorners = true
strongSelf.bottomStripeNode.isHidden = hasCorners
}
strongSelf.maskNode.image = hasCorners ? PresentationResourcesItemList.cornersImage(item.theme, top: hasTopCorners, bottom: hasBottomCorners) : nil
strongSelf.backgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -min(insets.top, separatorHeight)), size: CGSize(width: params.width, height: contentSize.height + min(insets.top, separatorHeight) + min(insets.bottom, separatorHeight)))
strongSelf.maskNode.frame = strongSelf.backgroundNode.frame.insetBy(dx: params.leftInset, dy: 0.0)
strongSelf.topStripeNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -min(insets.top, separatorHeight)), size: CGSize(width: layoutSize.width, height: separatorHeight))
transition.updateFrame(node: strongSelf.bottomStripeNode, frame: CGRect(origin: CGPoint(x: bottomStripeInset, y: contentSize.height + bottomStripeOffset), size: CGSize(width: layoutSize.width - bottomStripeInset, height: separatorHeight)))
transition.updateFrame(node: strongSelf.titleNode, frame: CGRect(origin: CGPoint(x: leftInset, y: 11.0), size: titleLayout.size))
strongSelf.highlightedBackgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -UIScreenPixel), size: CGSize(width: params.width, height: strongSelf.backgroundNode.frame.height + UIScreenPixel + UIScreenPixel))
}
})
}
}
override func setHighlighted(_ highlighted: Bool, at point: CGPoint, animated: Bool) {
super.setHighlighted(highlighted, at: point, animated: animated)
if highlighted {
self.highlightedBackgroundNode.alpha = 1.0
if self.highlightedBackgroundNode.supernode == nil {
var anchorNode: ASDisplayNode?
if self.bottomStripeNode.supernode != nil {
anchorNode = self.bottomStripeNode
} else if self.topStripeNode.supernode != nil {
anchorNode = self.topStripeNode
} else if self.backgroundNode.supernode != nil {
anchorNode = self.backgroundNode
}
if let anchorNode = anchorNode {
self.insertSubnode(self.highlightedBackgroundNode, aboveSubnode: anchorNode)
} else {
self.addSubnode(self.highlightedBackgroundNode)
}
}
} else {
if self.highlightedBackgroundNode.supernode != nil {
if animated {
self.highlightedBackgroundNode.layer.animateAlpha(from: self.highlightedBackgroundNode.alpha, to: 0.0, duration: 0.4, completion: { [weak self] completed in
if let strongSelf = self {
if completed {
strongSelf.highlightedBackgroundNode.removeFromSupernode()
}
}
})
self.highlightedBackgroundNode.alpha = 0.0
} else {
self.highlightedBackgroundNode.removeFromSupernode()
}
}
}
}
override func animateInsertion(_ currentTimestamp: Double, duration: Double, options: ListViewItemAnimationOptions) {
self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.4)
}
override func animateRemoved(_ currentTimestamp: Double, duration: Double) {
self.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false)
}
}

View File

@ -1,539 +0,0 @@
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import SwiftSignalKit
import TelegramPresentationData
import ItemListUI
import PresentationDataUtils
import CheckNode
struct CreatePollOptionItemEditing {
let editable: Bool
let hasActiveRevealControls: Bool
}
class CreatePollOptionItem: ListViewItem, ItemListItem {
let presentationData: ItemListPresentationData
let id: Int
let placeholder: String
let value: String
let isSelected: Bool?
let maxLength: Int
let editing: CreatePollOptionItemEditing
let sectionId: ItemListSectionId
let setItemIdWithRevealedOptions: (Int?, Int?) -> Void
let updated: (String, Bool) -> Void
let next: (() -> Void)?
let delete: (Bool) -> Void
let canDelete: Bool
let canMove: Bool
let focused: (Bool) -> Void
let toggleSelected: () -> Void
let tag: ItemListItemTag?
init(presentationData: ItemListPresentationData, id: Int, placeholder: String, value: String, isSelected: Bool?, maxLength: Int, editing: CreatePollOptionItemEditing, sectionId: ItemListSectionId, setItemIdWithRevealedOptions: @escaping (Int?, Int?) -> Void, updated: @escaping (String, Bool) -> Void, next: (() -> Void)?, delete: @escaping (Bool) -> Void, canDelete: Bool, canMove: Bool, focused: @escaping (Bool) -> Void, toggleSelected: @escaping () -> Void, tag: ItemListItemTag?) {
self.presentationData = presentationData
self.id = id
self.placeholder = placeholder
self.value = value
self.isSelected = isSelected
self.maxLength = maxLength
self.editing = editing
self.sectionId = sectionId
self.setItemIdWithRevealedOptions = setItemIdWithRevealedOptions
self.updated = updated
self.next = next
self.delete = delete
self.canDelete = canDelete
self.canMove = canMove
self.focused = focused
self.toggleSelected = toggleSelected
self.tag = tag
}
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) {
async {
let node = CreatePollOptionItemNode()
let (layout, apply) = node.asyncLayout()(self, params, itemListNeighbors(item: self, topItem: previousItem as? ItemListItem, bottomItem: nextItem as? ItemListItem))
node.contentSize = layout.contentSize
node.insets = layout.insets
Queue.mainQueue().async {
completion(node, {
return (nil, { _ in apply(.None) })
})
}
}
}
func updateNode(async: @escaping (@escaping () -> Void) -> Void, node: @escaping () -> ListViewItemNode, params: ListViewItemLayoutParams, previousItem: ListViewItem?, nextItem: ListViewItem?, animation: ListViewItemUpdateAnimation, completion: @escaping (ListViewItemNodeLayout, @escaping (ListViewItemApply) -> Void) -> Void) {
Queue.mainQueue().async {
if let nodeValue = node() as? CreatePollOptionItemNode {
let makeLayout = nodeValue.asyncLayout()
async {
let (layout, apply) = makeLayout(self, params, itemListNeighbors(item: self, topItem: previousItem as? ItemListItem, bottomItem: nextItem as? ItemListItem))
Queue.mainQueue().async {
completion(layout, { _ in
apply(animation)
})
}
}
}
}
}
var selectable: Bool = false
}
private let titleFont = Font.regular(15.0)
class CreatePollOptionItemNode: ItemListRevealOptionsItemNode, ItemListItemNode, ItemListItemFocusableNode, ASEditableTextNodeDelegate {
private let containerNode: ASDisplayNode
private let backgroundNode: ASDisplayNode
private let topStripeNode: ASDisplayNode
private let bottomStripeNode: ASDisplayNode
private let maskNode: ASImageNode
private var checkNode: InteractiveCheckNode?
private let textClippingNode: ASDisplayNode
private let textNode: EditableTextNode
private let measureTextNode: TextNode
private let textLimitNode: TextNode
private let reorderControlNode: ItemListEditableReorderControlNode
private var item: CreatePollOptionItem?
private var layoutParams: ListViewItemLayoutParams?
var tag: ItemListItemTag? {
return self.item?.tag
}
override var controlsContainer: ASDisplayNode {
return self.containerNode
}
var checkNodeFrame: CGRect? {
guard let _ = self.layoutParams, let checkNode = self.checkNode else {
return nil
}
return checkNode.frame
}
init() {
self.containerNode = ASDisplayNode()
self.containerNode.clipsToBounds = true
self.backgroundNode = ASDisplayNode()
self.backgroundNode.isLayerBacked = true
self.backgroundNode.backgroundColor = .white
self.topStripeNode = ASDisplayNode()
self.topStripeNode.isLayerBacked = true
self.bottomStripeNode = ASDisplayNode()
self.bottomStripeNode.isLayerBacked = true
self.maskNode = ASImageNode()
self.reorderControlNode = ItemListEditableReorderControlNode()
self.textClippingNode = ASDisplayNode()
self.textClippingNode.clipsToBounds = true
self.textNode = EditableTextNode()
self.measureTextNode = TextNode()
self.textLimitNode = TextNode()
self.textLimitNode.isUserInteractionEnabled = false
super.init(layerBacked: false, dynamicBounce: false, rotated: false, seeThrough: false)
self.addSubnode(self.containerNode)
self.textClippingNode.addSubnode(self.textNode)
self.containerNode.addSubnode(self.textClippingNode)
self.containerNode.addSubnode(self.reorderControlNode)
self.containerNode.addSubnode(self.textLimitNode)
}
override func didLoad() {
super.didLoad()
var textColor: UIColor = .black
if let item = self.item {
textColor = item.presentationData.theme.list.itemPrimaryTextColor
}
self.textNode.typingAttributes = [NSAttributedString.Key.font.rawValue: Font.regular(17.0), NSAttributedString.Key.foregroundColor.rawValue: textColor]
self.textNode.clipsToBounds = true
self.textNode.delegate = self
self.textNode.hitTestSlop = UIEdgeInsets(top: -5.0, left: -5.0, bottom: -5.0, right: -5.0)
}
func editableTextNodeDidBeginEditing(_ editableTextNode: ASEditableTextNode) {
self.item?.focused(true)
}
func editableTextNodeDidFinishEditing(_ editableTextNode: ASEditableTextNode) {
self.internalEditableTextNodeDidUpdateText(editableTextNode, isLosingFocus: true)
self.item?.focused(false)
}
func editableTextNode(_ editableTextNode: ASEditableTextNode, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
guard let item = self.item else {
return false
}
if text.firstIndex(of: "\n") != nil {
if text != "\n" {
let currentText = editableTextNode.attributedText?.string ?? ""
var updatedText = (currentText as NSString).replacingCharacters(in: range, with: text)
updatedText = updatedText.replacingOccurrences(of: "\n", with: " ")
if updatedText.count == 1 {
updatedText = ""
}
let updatedAttributedText = NSAttributedString(string: updatedText, font: Font.regular(17.0), textColor: item.presentationData.theme.list.itemPrimaryTextColor)
self.textNode.attributedText = updatedAttributedText
self.editableTextNodeDidUpdateText(editableTextNode)
}
if let next = item.next {
next()
} else {
editableTextNode.resignFirstResponder()
}
return false
}
return true
}
func editableTextNodeDidUpdateText(_ editableTextNode: ASEditableTextNode) {
self.internalEditableTextNodeDidUpdateText(editableTextNode, isLosingFocus: false)
}
private func internalEditableTextNodeDidUpdateText(_ editableTextNode: ASEditableTextNode, isLosingFocus: Bool) {
if let item = self.item {
let text = self.textNode.attributedText ?? NSAttributedString()
var updatedText = text.string
var hadReturn = false
if updatedText.firstIndex(of: "\n") != nil {
hadReturn = true
updatedText = updatedText.replacingOccurrences(of: "\n", with: " ")
}
let updatedAttributedText = NSAttributedString(string: updatedText, font: Font.regular(17.0), textColor: item.presentationData.theme.list.itemPrimaryTextColor)
if text.string != updatedAttributedText.string {
self.textNode.attributedText = updatedAttributedText
}
item.updated(updatedText, !isLosingFocus && editableTextNode.isFirstResponder())
if hadReturn {
if let next = item.next {
next()
} else if !isLosingFocus {
editableTextNode.resignFirstResponder()
}
}
}
}
func editableTextNodeBackspaceWhileEmpty(_ editableTextNode: ASEditableTextNode) {
self.item?.delete(editableTextNode.isFirstResponder())
}
func asyncLayout() -> (_ item: CreatePollOptionItem, _ params: ListViewItemLayoutParams, _ neighbors: ItemListNeighbors) -> (ListViewItemNodeLayout, (ListViewItemUpdateAnimation) -> Void) {
let reorderControlLayout = ItemListEditableReorderControlNode.asyncLayout(self.reorderControlNode)
let makeTextLayout = TextNode.asyncLayout(self.measureTextNode)
let makeTextLimitLayout = TextNode.asyncLayout(self.textLimitNode)
let currentItem = self.item
return { item, params, neighbors in
var updatedTheme: PresentationTheme?
if currentItem?.presentationData.theme !== item.presentationData.theme {
updatedTheme = item.presentationData.theme
}
let reorderSizeAndApply = reorderControlLayout(item.presentationData.theme)
let separatorHeight = UIScreenPixel
let insets = itemListNeighborsGroupedInsets(neighbors, params)
let leftInset: CGFloat = params.leftInset + (item.isSelected != nil ? 60.0 : 16.0)
let rightInset: CGFloat = 44.0 + params.rightInset
let textLength = item.value.count
let displayTextLimit = textLength > item.maxLength * 70 / 100
let remainingCount = item.maxLength - textLength
let (textLimitLayout, textLimitApply) = makeTextLimitLayout(TextNodeLayoutArguments(attributedString: NSAttributedString(string: "\(remainingCount)", font: Font.regular(13.0), textColor: remainingCount < 0 ? item.presentationData.theme.list.itemDestructiveColor : item.presentationData.theme.list.itemSecondaryTextColor), backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: 100.0, height: .greatestFiniteMagnitude), alignment: .left, lineSpacing: 0.0, cutout: nil, insets: UIEdgeInsets()))
var measureText = item.value
if measureText.hasSuffix("\n") || measureText.isEmpty {
measureText += "|"
}
let attributedMeasureText = NSAttributedString(string: measureText, font: Font.regular(17.0), textColor: .black)
let attributedText = NSAttributedString(string: item.value, font: Font.regular(17.0), textColor: item.presentationData.theme.list.itemPrimaryTextColor)
let (textLayout, textApply) = makeTextLayout(TextNodeLayoutArguments(attributedString: attributedMeasureText, backgroundColor: nil, maximumNumberOfLines: 0, truncationType: .end, constrainedSize: CGSize(width: params.width - leftInset - rightInset, height: CGFloat.greatestFiniteMagnitude), alignment: .natural, lineSpacing: 0.05, cutout: nil, insets: UIEdgeInsets()))
let textTopInset: CGFloat = 11.0
let textBottomInset: CGFloat = 11.0
let contentSize = CGSize(width: params.width, height: textLayout.size.height + textTopInset + textBottomInset)
let layout = ListViewItemNodeLayout(contentSize: contentSize, insets: insets)
let attributedPlaceholderText = NSAttributedString(string: item.placeholder, font: Font.regular(17.0), textColor: item.presentationData.theme.list.itemPlaceholderTextColor)
return (layout, { [weak self] animation in
if let strongSelf = self {
let transition: ContainedViewLayoutTransition
switch animation {
case .System:
transition = .animated(duration: 0.3, curve: .spring)
default:
transition = .immediate
}
strongSelf.item = item
strongSelf.layoutParams = params
if let _ = updatedTheme {
strongSelf.topStripeNode.backgroundColor = item.presentationData.theme.list.itemBlocksSeparatorColor
strongSelf.bottomStripeNode.backgroundColor = item.presentationData.theme.list.itemBlocksSeparatorColor
strongSelf.backgroundNode.backgroundColor = item.presentationData.theme.list.itemBlocksBackgroundColor
if strongSelf.isNodeLoaded {
strongSelf.textNode.typingAttributes = [NSAttributedString.Key.font.rawValue: Font.regular(17.0), NSAttributedString.Key.foregroundColor.rawValue: item.presentationData.theme.list.itemPrimaryTextColor]
strongSelf.textNode.tintColor = item.presentationData.theme.list.itemAccentColor
}
}
let revealOffset = strongSelf.revealOffset
let capitalizationType: UITextAutocapitalizationType
let autocorrectionType: UITextAutocorrectionType
let keyboardType: UIKeyboardType
capitalizationType = .sentences
autocorrectionType = .default
keyboardType = UIKeyboardType.default
let _ = textApply()
if let currentText = strongSelf.textNode.attributedText {
if currentText.string != attributedText.string || updatedTheme != nil {
strongSelf.textNode.attributedText = attributedText
}
} else {
strongSelf.textNode.attributedText = attributedText
}
if strongSelf.textNode.keyboardType != keyboardType {
strongSelf.textNode.keyboardType = keyboardType
}
if strongSelf.textNode.autocapitalizationType != capitalizationType {
strongSelf.textNode.autocapitalizationType = capitalizationType
}
if strongSelf.textNode.autocorrectionType != autocorrectionType {
strongSelf.textNode.autocorrectionType = autocorrectionType
}
let returnKeyType: UIReturnKeyType
if let _ = item.next {
returnKeyType = .next
} else {
returnKeyType = .done
}
if strongSelf.textNode.returnKeyType != returnKeyType {
strongSelf.textNode.returnKeyType = returnKeyType
}
if strongSelf.textNode.attributedPlaceholderText == nil || !strongSelf.textNode.attributedPlaceholderText!.isEqual(to: attributedPlaceholderText) {
strongSelf.textNode.attributedPlaceholderText = attributedPlaceholderText
}
strongSelf.textNode.keyboardAppearance = item.presentationData.theme.rootController.keyboardColor.keyboardAppearance
let checkSize = CGSize(width: 22.0, height: 22.0)
let checkFrame = CGRect(origin: CGPoint(x: params.leftInset + revealOffset + 16.0, y: floor((layout.contentSize.height - checkSize.height) / 2.0)), size: checkSize)
if let isSelected = item.isSelected {
if let checkNode = strongSelf.checkNode {
transition.updateFrame(node: checkNode, frame: checkFrame)
checkNode.setSelected(isSelected, animated: true)
} else {
let checkNode = InteractiveCheckNode(theme: CheckNodeTheme(backgroundColor: item.presentationData.theme.list.itemSwitchColors.positiveColor, strokeColor: item.presentationData.theme.list.itemCheckColors.foregroundColor, borderColor: item.presentationData.theme.list.itemCheckColors.strokeColor, overlayBorder: false, hasInset: false, hasShadow: false))
checkNode.setSelected(isSelected, animated: false)
checkNode.valueChanged = { [weak self] value in
self?.item?.toggleSelected()
}
strongSelf.checkNode = checkNode
strongSelf.containerNode.addSubnode(checkNode)
checkNode.frame = checkFrame
transition.animatePositionAdditive(node: checkNode, offset: CGPoint(x: -checkFrame.maxX, y: 0.0))
}
if let checkNode = strongSelf.checkNode {
transition.updateAlpha(node: checkNode, alpha: strongSelf.textNode.textView.text.isEmpty && item.placeholder == item.presentationData.strings.CreatePoll_AddOption ? 0.0 : 1.0)
}
} else if let checkNode = strongSelf.checkNode {
strongSelf.checkNode = nil
transition.updateFrame(node: checkNode, frame: checkFrame.offsetBy(dx: -checkFrame.maxX, dy: 0.0), completion: { [weak checkNode] _ in
checkNode?.removeFromSupernode()
})
}
transition.updateFrame(node: strongSelf.textClippingNode, frame: CGRect(origin: CGPoint(x: revealOffset + leftInset, y: textTopInset), size: CGSize(width: params.width - leftInset - params.rightInset, height: textLayout.size.height)))
transition.updateFrame(node: strongSelf.textNode, frame: CGRect(origin: CGPoint(), size: CGSize(width: params.width - leftInset - rightInset, height: textLayout.size.height + 1.0)))
if strongSelf.backgroundNode.supernode == nil {
strongSelf.containerNode.insertSubnode(strongSelf.backgroundNode, at: 0)
}
if strongSelf.topStripeNode.supernode == nil {
strongSelf.containerNode.insertSubnode(strongSelf.topStripeNode, at: 1)
}
if strongSelf.bottomStripeNode.supernode == nil {
strongSelf.containerNode.insertSubnode(strongSelf.bottomStripeNode, at: 2)
}
if strongSelf.maskNode.supernode == nil {
strongSelf.containerNode.insertSubnode(strongSelf.maskNode, at: 3)
}
let bottomStripeWasHidden = strongSelf.bottomStripeNode.isHidden
let hasCorners = itemListHasRoundedBlockLayout(params)
var hasTopCorners = false
var hasBottomCorners = false
switch neighbors.top {
case .sameSection(false):
strongSelf.topStripeNode.isHidden = true
default:
hasTopCorners = true
strongSelf.topStripeNode.isHidden = hasCorners
}
let bottomStripeInset: CGFloat
switch neighbors.bottom {
case .sameSection(false):
bottomStripeInset = leftInset
strongSelf.bottomStripeNode.isHidden = false
default:
bottomStripeInset = 0.0
hasBottomCorners = true
strongSelf.bottomStripeNode.isHidden = hasCorners
}
strongSelf.maskNode.image = hasCorners ? PresentationResourcesItemList.cornersImage(item.presentationData.theme, top: hasTopCorners, bottom: hasBottomCorners) : nil
strongSelf.topStripeNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -min(insets.top, separatorHeight)), size: CGSize(width: layout.contentSize.width, height: separatorHeight))
if strongSelf.animationForKey("apparentHeight") == nil {
strongSelf.containerNode.frame = CGRect(origin: CGPoint(), size: layout.contentSize)
strongSelf.backgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -min(insets.top, separatorHeight)), size: CGSize(width: params.width, height: contentSize.height + min(insets.top, separatorHeight) + min(insets.bottom, separatorHeight)))
strongSelf.maskNode.frame = strongSelf.backgroundNode.frame.insetBy(dx: params.leftInset, dy: 0.0)
let previousX = strongSelf.bottomStripeNode.frame.minX
strongSelf.bottomStripeNode.frame = CGRect(origin: CGPoint(x: bottomStripeInset, y: contentSize.height - UIScreenPixel), size: CGSize(width: layout.contentSize.width, height: separatorHeight))
if !bottomStripeWasHidden {
transition.animatePositionAdditive(node: strongSelf.bottomStripeNode, offset: CGPoint(x: previousX - strongSelf.bottomStripeNode.frame.minX, y: 0.0))
}
} else {
let previousX = strongSelf.bottomStripeNode.frame.minX
strongSelf.bottomStripeNode.frame = CGRect(origin: CGPoint(x: bottomStripeInset, y: strongSelf.bottomStripeNode.frame.minY), size: CGSize(width: layout.contentSize.width, height: separatorHeight))
if !bottomStripeWasHidden {
transition.animatePositionAdditive(node: strongSelf.bottomStripeNode, offset: CGPoint(x: previousX - strongSelf.bottomStripeNode.frame.minX, y: 0.0))
}
}
let _ = reorderSizeAndApply.1(layout.contentSize.height, displayTextLimit, transition)
let reorderControlFrame = CGRect(origin: CGPoint(x: params.width + revealOffset - params.rightInset - reorderSizeAndApply.0 + (!item.canMove ? 44.0 + params.rightInset : 0.0), y: 0.0), size: CGSize(width: reorderSizeAndApply.0, height: layout.contentSize.height))
transition.updateFrameAdditive(node: strongSelf.reorderControlNode, frame: reorderControlFrame)
strongSelf.reorderControlNode.isUserInteractionEnabled = item.canMove
let _ = textLimitApply()
strongSelf.textLimitNode.frame = CGRect(origin: CGPoint(x: reorderControlFrame.minX + floor((reorderControlFrame.width - textLimitLayout.size.width) / 2.0) - 4.0 - UIScreenPixel, y: max(floor(reorderControlFrame.midY + 2.0), layout.contentSize.height - 15.0 - textLimitLayout.size.height)), size: textLimitLayout.size)
strongSelf.textLimitNode.isHidden = !displayTextLimit
strongSelf.updateLayout(size: layout.contentSize, leftInset: params.leftInset, rightInset: params.rightInset)
strongSelf.setRevealOptions((left: [], right: item.canDelete ? [ItemListRevealOption(key: 0, title: item.presentationData.strings.Common_Delete, icon: .none, color: item.presentationData.theme.list.itemDisclosureActions.destructive.fillColor, textColor: item.presentationData.theme.list.itemDisclosureActions.destructive.foregroundColor)] : []))
}
})
}
}
override func updateRevealOffset(offset: CGFloat, transition: ContainedViewLayoutTransition) {
super.updateRevealOffset(offset: offset, transition: transition)
guard let params = self.layoutParams, let item = self.item else {
return
}
let revealOffset = offset
let leftInset: CGFloat
leftInset = params.leftInset + (item.isSelected != nil ? 60.0 : 16.0)
if let checkNode = self.checkNode {
var checkNodeFrame = checkNode.frame
checkNodeFrame.origin.x = params.leftInset + 11.0 + revealOffset
transition.updateFrame(node: checkNode, frame: checkNodeFrame)
}
var reorderFrame = self.reorderControlNode.frame
reorderFrame.origin.x = params.width + revealOffset - params.rightInset - reorderFrame.width
transition.updateFrame(node: self.reorderControlNode, frame: reorderFrame)
var textClippingNodeFrame = self.textClippingNode.frame
textClippingNodeFrame.origin.x = revealOffset + leftInset
transition.updateFrame(node: self.textClippingNode, frame: textClippingNodeFrame)
}
override func revealOptionSelected(_ option: ItemListRevealOption, animated: Bool) {
self.layer.allowsGroupOpacity = true
self.updateRevealOffsetInternal(offset: -self.bounds.width - 74.0, transition: .animated(duration: 0.2, curve: .spring), completion: { [weak self] in
self?.layer.allowsGroupOpacity = false
})
self.item?.delete(self.textNode.isFirstResponder())
}
override func animateInsertion(_ currentTimestamp: Double, duration: Double, options: ListViewItemAnimationOptions) {
//self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.4)
}
override func animateRemoved(_ currentTimestamp: Double, duration: Double) {
//self.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false)
}
func focus() {
self.textNode.becomeFirstResponder()
}
func selectAll() {
self.textNode.textView.selectAll(nil)
}
override func isReorderable(at point: CGPoint) -> Bool {
if self.reorderControlNode.frame.contains(point), !self.reorderControlNode.isHidden, !self.isDisplayingRevealedOptions {
return true
}
return false
}
override func animateFrameTransition(_ progress: CGFloat, _ currentValue: CGFloat) {
super.animateFrameTransition(progress, currentValue)
var separatorFrame = self.bottomStripeNode.frame
separatorFrame.origin.y = currentValue - UIScreenPixel
self.bottomStripeNode.frame = separatorFrame
self.containerNode.frame = CGRect(origin: CGPoint(), size: CGSize(width: self.containerNode.bounds.width, height: currentValue))
let insets = self.insets
let separatorHeight = UIScreenPixel
guard let params = self.layoutParams else {
return
}
self.backgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -min(insets.top, separatorHeight)), size: CGSize(width: self.containerNode.bounds.width, height: currentValue + min(insets.top, separatorHeight) + min(insets.bottom, separatorHeight)))
self.maskNode.frame = self.backgroundNode.frame.insetBy(dx: params.leftInset, dy: 0.0)
}
}

View File

@ -1,683 +0,0 @@
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import SwiftSignalKit
import TelegramPresentationData
import ItemListUI
import TextFormat
import ObjCRuntimeUtils
import TextInputMenu
import AccountContext
public enum CreatePollTextInputItemTextLimitMode {
case characters
case bytes
}
public struct CreatePollTextInputItemTextLimit {
public let value: Int
public let display: Bool
public let mode: CreatePollTextInputItemTextLimitMode
public init(value: Int, display: Bool, mode: CreatePollTextInputItemTextLimitMode = .characters) {
self.value = value
self.display = display
self.mode = mode
}
}
public struct ItemListMultilineInputInlineAction {
public let icon: UIImage
public let action: (() -> Void)?
public init(icon: UIImage, action: (() -> Void)?) {
self.icon = icon
self.action = action
}
}
public class CreatePollTextInputItem: ListViewItem, ItemListItem {
let context: AccountContext
let presentationData: ItemListPresentationData
let text: NSAttributedString
let placeholder: String
public let sectionId: ItemListSectionId
let style: ItemListStyle
let capitalization: Bool
let autocorrection: Bool
let returnKeyType: UIReturnKeyType
let action: (() -> Void)?
let textUpdated: (NSAttributedString) -> Void
let shouldUpdateText: (String) -> Bool
let processPaste: ((String) -> Void)?
let updatedFocus: ((Bool) -> Void)?
let maxLength: CreatePollTextInputItemTextLimit?
let minimalHeight: CGFloat?
let inlineAction: ItemListMultilineInputInlineAction?
public let tag: ItemListItemTag?
public init(context: AccountContext, presentationData: ItemListPresentationData, text: NSAttributedString, placeholder: String, maxLength: CreatePollTextInputItemTextLimit?, sectionId: ItemListSectionId, style: ItemListStyle, capitalization: Bool = true, autocorrection: Bool = true, returnKeyType: UIReturnKeyType = .default, minimalHeight: CGFloat? = nil, textUpdated: @escaping (NSAttributedString) -> Void, shouldUpdateText: @escaping (String) -> Bool = { _ in return true }, processPaste: ((String) -> Void)? = nil, updatedFocus: ((Bool) -> Void)? = nil, tag: ItemListItemTag? = nil, action: (() -> Void)? = nil, inlineAction: ItemListMultilineInputInlineAction? = nil) {
self.context = context
self.presentationData = presentationData
self.text = text
self.placeholder = placeholder
self.maxLength = maxLength
self.sectionId = sectionId
self.style = style
self.capitalization = capitalization
self.autocorrection = autocorrection
self.returnKeyType = returnKeyType
self.minimalHeight = minimalHeight
self.textUpdated = textUpdated
self.shouldUpdateText = shouldUpdateText
self.processPaste = processPaste
self.updatedFocus = updatedFocus
self.tag = tag
self.action = action
self.inlineAction = inlineAction
}
public 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) {
async {
let node = CreatePollTextInputItemNode()
let (layout, apply) = node.asyncLayout()(self, params, itemListNeighbors(item: self, topItem: previousItem as? ItemListItem, bottomItem: nextItem as? ItemListItem))
node.contentSize = layout.contentSize
node.insets = layout.insets
Queue.mainQueue().async {
completion(node, {
return (nil, { _ in apply() })
})
}
}
}
public func updateNode(async: @escaping (@escaping () -> Void) -> Void, node: @escaping () -> ListViewItemNode, params: ListViewItemLayoutParams, previousItem: ListViewItem?, nextItem: ListViewItem?, animation: ListViewItemUpdateAnimation, completion: @escaping (ListViewItemNodeLayout, @escaping (ListViewItemApply) -> Void) -> Void) {
Queue.mainQueue().async {
if let nodeValue = node() as? CreatePollTextInputItemNode {
let makeLayout = nodeValue.asyncLayout()
async {
let (layout, apply) = makeLayout(self, params, itemListNeighbors(item: self, topItem: previousItem as? ItemListItem, bottomItem: nextItem as? ItemListItem))
Queue.mainQueue().async {
completion(layout, { _ in
apply()
})
}
}
}
}
}
}
public class CreatePollTextInputItemNode: ListViewItemNode, ASEditableTextNodeDelegate, ItemListItemNode, ItemListItemFocusableNode {
private let backgroundNode: ASDisplayNode
private let topStripeNode: ASDisplayNode
private let bottomStripeNode: ASDisplayNode
private let maskNode: ASImageNode
private let textClippingNode: ASDisplayNode
private let textNode: EditableTextNode
private let measureTextNode: TextNode
private let limitTextNode: TextNode
private var inlineActionButtonNode: HighlightableButtonNode?
private var item: CreatePollTextInputItem?
private var layoutParams: ListViewItemLayoutParams?
private let inputMenu = TextInputMenu()
public var tag: ItemListItemTag? {
return self.item?.tag
}
public init() {
self.backgroundNode = ASDisplayNode()
self.backgroundNode.isLayerBacked = true
self.topStripeNode = ASDisplayNode()
self.topStripeNode.isLayerBacked = true
self.bottomStripeNode = ASDisplayNode()
self.bottomStripeNode.isLayerBacked = true
self.maskNode = ASImageNode()
self.textClippingNode = ASDisplayNode()
self.textClippingNode.clipsToBounds = true
self.textNode = EditableTextNode()
self.measureTextNode = TextNode()
self.limitTextNode = TextNode()
super.init(layerBacked: false, dynamicBounce: false)
self.textClippingNode.addSubnode(self.textNode)
self.addSubnode(self.textClippingNode)
}
override public func didLoad() {
super.didLoad()
var textColor: UIColor = .black
if let item = self.item {
textColor = item.presentationData.theme.list.itemPrimaryTextColor
self.textNode.typingAttributes = [NSAttributedString.Key.font.rawValue: Font.regular(item.presentationData.fontSize.itemListBaseFontSize), NSAttributedString.Key.foregroundColor.rawValue: textColor]
} else {
self.textNode.typingAttributes = [NSAttributedString.Key.font.rawValue: Font.regular(17.0), NSAttributedString.Key.foregroundColor.rawValue: textColor]
}
self.textNode.clipsToBounds = true
self.textNode.delegate = self
self.textNode.hitTestSlop = UIEdgeInsets(top: -5.0, left: -5.0, bottom: -5.0, right: -5.0)
}
public func asyncLayout() -> (_ item: CreatePollTextInputItem, _ params: ListViewItemLayoutParams, _ neighbors: ItemListNeighbors) -> (ListViewItemNodeLayout, () -> Void) {
let makeTextLayout = TextNode.asyncLayout(self.measureTextNode)
let makeLimitTextLayout = TextNode.asyncLayout(self.limitTextNode)
let currentItem = self.item
return { item, params, neighbors in
var updatedTheme: PresentationTheme?
if currentItem?.presentationData.theme !== item.presentationData.theme {
updatedTheme = item.presentationData.theme
}
let itemBackgroundColor: UIColor
let itemSeparatorColor: UIColor
let leftInset = 16.0 + params.rightInset
switch item.style {
case .plain:
itemBackgroundColor = item.presentationData.theme.list.plainBackgroundColor
itemSeparatorColor = item.presentationData.theme.list.itemPlainSeparatorColor
case .blocks:
itemBackgroundColor = item.presentationData.theme.list.itemBlocksBackgroundColor
itemSeparatorColor = item.presentationData.theme.list.itemBlocksSeparatorColor
}
var limitTextString: NSAttributedString?
var rightInset: CGFloat = params.rightInset
if let maxLength = item.maxLength, maxLength.display {
let textLength: Int
switch maxLength.mode {
case .characters:
textLength = item.text.string.count
case .bytes:
textLength = item.text.string.data(using: .utf8, allowLossyConversion: true)?.count ?? 0
}
let displayTextLimit = textLength > maxLength.value * 70 / 100
let remainingCount = maxLength.value - textLength
if displayTextLimit {
limitTextString = NSAttributedString(string: "\(remainingCount)", font: Font.regular(13.0), textColor: remainingCount < 0 ? item.presentationData.theme.list.itemDestructiveColor : item.presentationData.theme.list.itemSecondaryTextColor)
}
rightInset += 30.0 + 4.0
}
let (limitTextLayout, limitTextApply) = makeLimitTextLayout(TextNodeLayoutArguments(attributedString: limitTextString, backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: 100.0, height: 100.0), alignment: .left, cutout: nil, insets: UIEdgeInsets()))
if limitTextLayout.size.width > 30.0 {
rightInset += 30.0
}
if let inlineAction = item.inlineAction {
rightInset += inlineAction.icon.size.width + 8.0
}
let itemText = textAttributedStringForStateText(context: item.context, stateText: item.text, fontSize: 17.0, textColor: item.presentationData.theme.chat.inputPanel.primaryTextColor, accentTextColor: item.presentationData.theme.chat.inputPanel.panelControlAccentColor, writingDirection: nil, spoilersRevealed: false, availableEmojis: Set(), emojiViewProvider: nil, makeCollapsedQuoteAttachment: nil)
let measureText = NSMutableAttributedString(attributedString: itemText)
let measureRawString = measureText.string
if measureRawString.hasSuffix("\n") || measureRawString.isEmpty {
measureText.append(NSAttributedString(string: "|", font: Font.regular(item.presentationData.fontSize.itemListBaseFontSize), textColor: .black))
}
let attributedText = itemText
let (textLayout, textApply) = makeTextLayout(TextNodeLayoutArguments(attributedString: measureText, backgroundColor: nil, maximumNumberOfLines: 0, truncationType: .end, constrainedSize: CGSize(width: params.width - 16.0 - leftInset - rightInset, height: CGFloat.greatestFiniteMagnitude), alignment: .natural, lineSpacing: 0.0, cutout: nil, insets: UIEdgeInsets()))
let separatorHeight = UIScreenPixel
let textTopInset: CGFloat = 11.0
let textBottomInset: CGFloat = 11.0
var contentHeight: CGFloat = textLayout.size.height + textTopInset + textBottomInset
if let minimalHeight = item.minimalHeight {
contentHeight = max(minimalHeight, contentHeight)
}
let contentSize = CGSize(width: params.width, height: contentHeight)
let insets = itemListNeighborsGroupedInsets(neighbors, params)
let layout = ListViewItemNodeLayout(contentSize: contentSize, insets: insets)
let layoutSize = layout.size
let attributedPlaceholderText = NSAttributedString(string: item.placeholder, font: Font.regular(item.presentationData.fontSize.itemListBaseFontSize), textColor: item.presentationData.theme.list.itemPlaceholderTextColor)
return (layout, { [weak self] in
if let strongSelf = self {
strongSelf.item = item
strongSelf.layoutParams = params
if let _ = updatedTheme {
strongSelf.topStripeNode.backgroundColor = itemSeparatorColor
strongSelf.bottomStripeNode.backgroundColor = itemSeparatorColor
strongSelf.backgroundNode.backgroundColor = itemBackgroundColor
if strongSelf.isNodeLoaded {
strongSelf.textNode.typingAttributes = [NSAttributedString.Key.font.rawValue: Font.regular(item.presentationData.fontSize.itemListBaseFontSize), NSAttributedString.Key.foregroundColor.rawValue: item.presentationData.theme.list.itemPrimaryTextColor]
strongSelf.textNode.tintColor = item.presentationData.theme.list.itemAccentColor
}
if let inlineAction = item.inlineAction {
strongSelf.inlineActionButtonNode?.setImage(generateTintedImage(image: inlineAction.icon, color: item.presentationData.theme.list.itemAccentColor), for: .normal)
}
strongSelf.inputMenu.updateStrings(item.presentationData.strings)
}
let capitalizationType: UITextAutocapitalizationType = item.capitalization ? .sentences : .none
let autocorrectionType: UITextAutocorrectionType = item.autocorrection ? .default : .no
if strongSelf.textNode.textView.autocapitalizationType != capitalizationType {
strongSelf.textNode.textView.autocapitalizationType = capitalizationType
}
if strongSelf.textNode.textView.autocorrectionType != autocorrectionType {
strongSelf.textNode.textView.autocorrectionType = autocorrectionType
}
if strongSelf.textNode.textView.returnKeyType != item.returnKeyType {
strongSelf.textNode.textView.returnKeyType = item.returnKeyType
}
let _ = textApply()
if let currentText = strongSelf.textNode.attributedText {
if currentText.string != attributedText.string || updatedTheme != nil {
strongSelf.textNode.attributedText = attributedText
refreshGenericTextInputAttributes(context: item.context, textView: strongSelf.textNode.textView, theme: item.presentationData.theme, baseFontSize: 17.0, availableEmojis: Set(), emojiViewProvider: nil, makeCollapsedQuoteAttachment: nil)
}
} else {
strongSelf.textNode.attributedText = attributedText
refreshGenericTextInputAttributes(context: item.context, textView: strongSelf.textNode.textView, theme: item.presentationData.theme, baseFontSize: 17.0, availableEmojis: Set(), emojiViewProvider: nil, makeCollapsedQuoteAttachment: nil)
}
if strongSelf.backgroundNode.supernode == nil {
strongSelf.insertSubnode(strongSelf.backgroundNode, at: 0)
}
if strongSelf.topStripeNode.supernode == nil {
strongSelf.insertSubnode(strongSelf.topStripeNode, at: 1)
}
if strongSelf.bottomStripeNode.supernode == nil {
strongSelf.insertSubnode(strongSelf.bottomStripeNode, at: 2)
}
if strongSelf.maskNode.supernode == nil {
strongSelf.insertSubnode(strongSelf.maskNode, at: 3)
}
let hasCorners = itemListHasRoundedBlockLayout(params)
var hasTopCorners = false
var hasBottomCorners = false
switch neighbors.top {
case .sameSection(false):
strongSelf.topStripeNode.isHidden = true
default:
hasTopCorners = true
strongSelf.topStripeNode.isHidden = hasCorners
}
let bottomStripeInset: CGFloat
switch neighbors.bottom {
case .sameSection(false):
bottomStripeInset = leftInset
strongSelf.bottomStripeNode.isHidden = false
default:
bottomStripeInset = 0.0
hasBottomCorners = true
strongSelf.bottomStripeNode.isHidden = hasCorners
}
strongSelf.maskNode.image = hasCorners ? PresentationResourcesItemList.cornersImage(item.presentationData.theme, top: hasTopCorners, bottom: hasBottomCorners) : nil
strongSelf.topStripeNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -min(insets.top, separatorHeight)), size: CGSize(width: layoutSize.width, height: separatorHeight))
strongSelf.bottomStripeNode.frame = CGRect(origin: CGPoint(x: bottomStripeInset, y: contentSize.height), size: CGSize(width: layoutSize.width - bottomStripeInset, height: separatorHeight))
if strongSelf.textNode.attributedPlaceholderText == nil || !strongSelf.textNode.attributedPlaceholderText!.isEqual(to: attributedPlaceholderText) {
strongSelf.textNode.attributedPlaceholderText = attributedPlaceholderText
}
strongSelf.textNode.keyboardAppearance = item.presentationData.theme.rootController.keyboardColor.keyboardAppearance
if strongSelf.animationForKey("apparentHeight") == nil {
strongSelf.backgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -min(insets.top, separatorHeight)), size: CGSize(width: params.width, height: contentSize.height + min(insets.top, separatorHeight) + min(insets.bottom, separatorHeight)))
strongSelf.maskNode.frame = strongSelf.backgroundNode.frame.insetBy(dx: params.leftInset, dy: 0.0)
strongSelf.textClippingNode.frame = CGRect(origin: CGPoint(x: leftInset, y: textTopInset), size: CGSize(width: params.width - leftInset - params.rightInset, height: textLayout.size.height))
}
strongSelf.textNode.frame = CGRect(origin: CGPoint(), size: CGSize(width: params.width - leftInset - 16.0 - rightInset, height: textLayout.size.height + 1.0))
let _ = limitTextApply()
strongSelf.limitTextNode.frame = CGRect(origin: CGPoint(x: params.width - params.rightInset - 16.0 - limitTextLayout.size.width, y: layout.contentSize.height - 15.0 - limitTextLayout.size.height), size: limitTextLayout.size)
if limitTextString != nil {
if strongSelf.limitTextNode.supernode == nil {
strongSelf.addSubnode(strongSelf.limitTextNode)
}
} else if strongSelf.limitTextNode.supernode != nil {
strongSelf.limitTextNode.removeFromSupernode()
}
if let inlineAction = item.inlineAction {
let inlineActionButtonNode: HighlightableButtonNode
if let currentInlineActionButtonNode = strongSelf.inlineActionButtonNode {
inlineActionButtonNode = currentInlineActionButtonNode
} else {
inlineActionButtonNode = HighlightableButtonNode()
inlineActionButtonNode.setImage(generateTintedImage(image: inlineAction.icon, color: item.presentationData.theme.list.itemAccentColor), for: .normal)
inlineActionButtonNode.addTarget(strongSelf, action: #selector(strongSelf.inlineActionPressed), forControlEvents: .touchUpInside)
strongSelf.addSubnode(inlineActionButtonNode)
strongSelf.inlineActionButtonNode = inlineActionButtonNode
}
inlineActionButtonNode.frame = CGRect(origin: CGPoint(x: params.width - params.rightInset - inlineAction.icon.size.width - 11.0, y: 7.0), size: inlineAction.icon.size)
} else if let inlineActionButtonNode = strongSelf.inlineActionButtonNode {
inlineActionButtonNode.removeFromSupernode()
strongSelf.inlineActionButtonNode = nil
}
}
})
}
}
override public func animateInsertion(_ currentTimestamp: Double, duration: Double, options: ListViewItemAnimationOptions) {
self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.4)
}
override public func animateRemoved(_ currentTimestamp: Double, duration: Double) {
self.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false)
}
override public func animateFrameTransition(_ progress: CGFloat, _ currentValue: CGFloat) {
super.animateFrameTransition(progress, currentValue)
guard let params = self.layoutParams else {
return
}
let separatorHeight = UIScreenPixel
let insets = self.insets
let contentSize = CGSize(width: params.width, height: max(1.0, currentValue - insets.top - insets.bottom))
let leftInset = 16.0 + params.leftInset
let textTopInset: CGFloat = 11.0
let textBottomInset: CGFloat = 11.0
self.backgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -min(insets.top, separatorHeight)), size: CGSize(width: params.width, height: contentSize.height + min(insets.top, separatorHeight) + min(insets.bottom, separatorHeight)))
self.maskNode.frame = self.backgroundNode.frame.insetBy(dx: params.leftInset, dy: 0.0)
self.bottomStripeNode.frame = CGRect(origin: CGPoint(x: self.bottomStripeNode.frame.minX, y: contentSize.height), size: CGSize(width: self.bottomStripeNode.frame.size.width, height: separatorHeight))
self.textClippingNode.frame = CGRect(origin: CGPoint(x: leftInset, y: textTopInset), size: CGSize(width: max(0.0, params.width - leftInset - params.rightInset), height: max(0.0, contentSize.height - textTopInset - textBottomInset)))
}
public func editableTextNodeDidBeginEditing(_ editableTextNode: ASEditableTextNode) {
self.item?.updatedFocus?(true)
self.inputMenu.activate()
}
public func editableTextNodeDidFinishEditing(_ editableTextNode: ASEditableTextNode) {
self.item?.updatedFocus?(false)
self.inputMenu.deactivate()
}
public func editableTextNodeTarget(forAction action: Selector) -> ASEditableTextNodeTargetForAction? {
if action == makeSelectorFromString("_showTextStyleOptions:") {
if #available(iOS 16.0, *) {
return ASEditableTextNodeTargetForAction(target: nil)
} else {
if case .general = self.inputMenu.state {
if self.textNode.attributedText == nil || self.textNode.attributedText!.length == 0 || self.textNode.selectedRange.length == 0 {
return ASEditableTextNodeTargetForAction(target: nil)
}
return ASEditableTextNodeTargetForAction(target: self)
} else {
return ASEditableTextNodeTargetForAction(target: nil)
}
}
} else if action == #selector(self.formatAttributesBold(_:)) || action == #selector(self.formatAttributesItalic(_:)) || action == #selector(self.formatAttributesMonospace(_:)) || action == #selector(self.formatAttributesLink(_:)) || action == #selector(self.formatAttributesStrikethrough(_:)) || action == #selector(self.formatAttributesUnderline(_:)) {
if case .format = self.inputMenu.state {
return ASEditableTextNodeTargetForAction(target: self)
} else {
return ASEditableTextNodeTargetForAction(target: nil)
}
}
if case .format = self.inputMenu.state {
return ASEditableTextNodeTargetForAction(target: nil)
}
return nil
}
@objc func _showTextStyleOptions(_ sender: Any) {
self.inputMenu.format(view: self.textNode.view, rect: self.textNode.selectionRect.offsetBy(dx: 0.0, dy: -self.textNode.textView.contentOffset.y).insetBy(dx: 0.0, dy: -1.0))
}
@available(iOS 16.0, *)
public func editableTextNodeMenu(_ editableTextNode: ASEditableTextNode, forTextRange textRange: NSRange, suggestedActions: [UIMenuElement]) -> UIMenu {
var actions = suggestedActions
if editableTextNode.attributedText == nil || editableTextNode.attributedText!.length == 0 || editableTextNode.selectedRange.length == 0 {
} else if let strings = self.item?.presentationData.strings {
let children: [UIAction] = [
UIAction(title: strings.TextFormat_Bold, image: nil) { [weak self] (action) in
if let strongSelf = self {
strongSelf.formatAttributesBold(strongSelf)
}
},
UIAction(title: strings.TextFormat_Italic, image: nil) { [weak self] (action) in
if let strongSelf = self {
strongSelf.formatAttributesItalic(strongSelf)
}
},
UIAction(title: strings.TextFormat_Monospace, image: nil) { [weak self] (action) in
if let strongSelf = self {
strongSelf.formatAttributesMonospace(strongSelf)
}
},
UIAction(title: strings.TextFormat_Link, image: nil) { [weak self] (action) in
if let strongSelf = self {
strongSelf.formatAttributesLink(strongSelf)
}
},
UIAction(title: strings.TextFormat_Strikethrough, image: nil) { [weak self] (action) in
if let strongSelf = self {
strongSelf.formatAttributesStrikethrough(strongSelf)
}
},
UIAction(title: strings.TextFormat_Underline, image: nil) { [weak self] (action) in
if let strongSelf = self {
strongSelf.formatAttributesUnderline(strongSelf)
}
},
UIAction(title: strings.TextFormat_Spoiler, image: nil) { [weak self] (action) in
if let strongSelf = self {
strongSelf.formatAttributesSpoiler(strongSelf)
}
}
]
let formatMenu = UIMenu(title: strings.TextFormat_Format, image: nil, children: children)
actions.insert(formatMenu, at: 3)
}
return UIMenu(children: actions)
}
@objc func formatAttributesBold(_ sender: Any) {
self.inputMenu.back()
if let item = self.item {
chatTextInputAddFormattingAttribute(item: item, textNode: self.textNode, theme: item.presentationData.theme, attribute: ChatTextInputAttributes.bold, value: nil)
}
}
@objc func formatAttributesItalic(_ sender: Any) {
self.inputMenu.back()
if let item = self.item {
chatTextInputAddFormattingAttribute(item: item, textNode: self.textNode, theme: item.presentationData.theme, attribute: ChatTextInputAttributes.italic, value: nil)
}
}
@objc func formatAttributesMonospace(_ sender: Any) {
self.inputMenu.back()
if let item = self.item {
chatTextInputAddFormattingAttribute(item: item, textNode: self.textNode, theme: item.presentationData.theme, attribute: ChatTextInputAttributes.monospace, value: nil)
}
}
@objc func formatAttributesLink(_ sender: Any) {
self.inputMenu.back()
//self.interfaceInteraction?.openLinkEditing()
}
@objc func formatAttributesStrikethrough(_ sender: Any) {
self.inputMenu.back()
if let item = self.item {
chatTextInputAddFormattingAttribute(item: item, textNode: self.textNode, theme: item.presentationData.theme, attribute: ChatTextInputAttributes.strikethrough, value: nil)
}
}
@objc func formatAttributesUnderline(_ sender: Any) {
self.inputMenu.back()
if let item = self.item {
chatTextInputAddFormattingAttribute(item: item, textNode: self.textNode, theme: item.presentationData.theme, attribute: ChatTextInputAttributes.underline, value: nil)
}
}
@objc func formatAttributesSpoiler(_ sender: Any) {
self.inputMenu.back()
if let item = self.item {
chatTextInputAddFormattingAttribute(item: item, textNode: self.textNode, theme: item.presentationData.theme, attribute: ChatTextInputAttributes.spoiler, value: nil)
}
}
@objc func formatAttributesQuote(_ sender: Any) {
self.inputMenu.back()
if let item = self.item {
chatTextInputAddFormattingAttribute(item: item, textNode: self.textNode, theme: item.presentationData.theme, attribute: ChatTextInputAttributes.block, value: ChatTextInputTextQuoteAttribute(kind: .quote, isCollapsed: false))
}
}
@objc func formatAttributesCodeBlock(_ sender: Any) {
self.inputMenu.back()
if let item = self.item {
chatTextInputAddFormattingAttribute(item: item, textNode: self.textNode, theme: item.presentationData.theme, attribute: ChatTextInputAttributes.block, value: ChatTextInputTextQuoteAttribute(kind: .code(language: nil), isCollapsed: false))
}
}
public func editableTextNode(_ editableTextNode: ASEditableTextNode, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
if let item = self.item {
if text.count > 1, let processPaste = item.processPaste {
processPaste(text)
return false
}
if let action = item.action, text == "\n" {
action()
return false
}
let newText = (editableTextNode.textView.text as NSString).replacingCharacters(in: range, with: text)
if !item.shouldUpdateText(newText) {
return false
}
}
return true
}
public func editableTextNodeDidUpdateText(_ editableTextNode: ASEditableTextNode) {
if let item = self.item {
if let _ = self.textNode.attributedText {
refreshGenericTextInputAttributes(context: item.context, textView: editableTextNode.textView, theme: item.presentationData.theme, baseFontSize: 17.0, availableEmojis: Set(), emojiViewProvider: nil, makeCollapsedQuoteAttachment: nil)
let updatedText = stateAttributedStringForText(self.textNode.attributedText!)
item.textUpdated(updatedText)
} else {
item.textUpdated(NSAttributedString(string: ""))
}
}
}
public func editableTextNodeShouldPaste(_ editableTextNode: ASEditableTextNode) -> Bool {
if let _ = self.item {
let text: String? = UIPasteboard.general.string
if let _ = text {
return true
}
}
return false
}
public func editableTextNodeDidChangeSelection(_ editableTextNode: ASEditableTextNode, fromSelectedRange: NSRange, toSelectedRange: NSRange, dueToEditing: Bool) {
/*if !dueToEditing && !self.updatingInputState {
}*/
if let item = self.item {
if case .format = self.inputMenu.state {
self.inputMenu.deactivate()
UIMenuController.shared.update()
}
refreshChatTextInputTypingAttributes(editableTextNode.textView, theme: item.presentationData.theme, baseFontSize: 17.0)
refreshGenericTextInputAttributes(context: item.context, textView: editableTextNode.textView, theme: item.presentationData.theme, baseFontSize: 17.0, availableEmojis: Set(), emojiViewProvider: nil, makeCollapsedQuoteAttachment: nil)
}
}
public func focus() {
if !self.textNode.textView.isFirstResponder {
self.textNode.textView.becomeFirstResponder()
}
}
public func selectAll() {
self.textNode.textView.selectAll(nil)
}
public func animateError() {
self.textNode.layer.addShakeAnimation()
}
@objc private func inlineActionPressed() {
if let action = self.item?.inlineAction?.action {
action()
}
}
}
private func chatTextInputAddFormattingAttribute(item: CreatePollTextInputItem, textNode: EditableTextNode, theme: PresentationTheme, attribute: NSAttributedString.Key, value: Any?) {
if let currentText = textNode.attributedText, textNode.selectedRange.length > 0 {
let nsRange = NSRange(location: textNode.selectedRange.location, length: textNode.selectedRange.length)
var addAttribute = true
var attributesToRemove: [NSAttributedString.Key] = []
currentText.enumerateAttributes(in: nsRange, options: .longestEffectiveRangeNotRequired) { attributes, range, stop in
for (key, _) in attributes {
if key == attribute && range == nsRange {
addAttribute = false
attributesToRemove.append(key)
}
}
}
let result = NSMutableAttributedString(attributedString: currentText)
for attribute in attributesToRemove {
result.removeAttribute(attribute, range: nsRange)
}
if addAttribute {
result.addAttribute(attribute, value: true as Bool, range: nsRange)
}
textNode.attributedText = result
textNode.selectedRange = nsRange
refreshChatTextInputTypingAttributes(textNode.textView, theme: theme, baseFontSize: 17.0)
refreshGenericTextInputAttributes(context: item.context, textView: textNode.textView, theme: theme, baseFontSize: 17.0, availableEmojis: Set(), emojiViewProvider: nil, makeCollapsedQuoteAttachment: nil)
let updatedText = stateAttributedStringForText(textNode.attributedText!)
item.textUpdated(updatedText)
}
}

View File

@ -60,9 +60,12 @@ swift_library(
"//submodules/TelegramUI/Components/AnimatedTextComponent",
"//submodules/TelegramUI/Components/LottieComponent",
"//submodules/Components/MultilineTextComponent",
"//submodules/Components/BundleIconComponent",
"//submodules/Components/BalancedTextComponent",
"//submodules/Components/ComponentDisplayAdapters",
"//submodules/ComponentFlow",
"//submodules/TelegramUI/Components/ToastComponent",
"//submodules/SemanticStatusNode",
],
visibility = [
"//visibility:public",

View File

@ -33,6 +33,9 @@ import RasterizedCompositionComponent
import BadgeComponent
import ComponentFlow
import ComponentDisplayAdapters
import ToastComponent
import MultilineTextComponent
import BundleIconComponent
public enum UniversalVideoGalleryItemContentInfo {
case message(Message, Int?)
@ -1390,8 +1393,94 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode {
if dismiss {
self.dismiss()
}
if self.adDisposable == nil, let contentInfo = self.item?.contentInfo, case let .message(message, _) = contentInfo {
let adContext = self.context.engine.messages.adMessages(peerId: message.id.peerId, messageId: message.id)
self.adContext = adContext
self.adDisposable = (adContext.state
|> deliverOnMainQueue).start(next: { [weak self] state in
guard let self else {
return
}
if let message = state.messages.first {
Queue.mainQueue().after(2.0, {
self.adMessage = message
if let validLayout = self.validLayout {
self.containerLayoutUpdated(validLayout.layout, navigationBarHeight: validLayout.navigationBarHeight, transition: .immediate)
}
})
}
})
}
if let adMessage = self.adMessage {
let sideInset: CGFloat = 16.0
let title = adMessage.author.flatMap { EnginePeer($0) }?.compactDisplayTitle ?? ""
let adSize = self.adView.update(
transition: .immediate,
component: AnyComponent(
ToastContentComponent(
icon: AnyComponent(
BundleIconComponent(name: "Components/AdMock", tintColor: nil, maxSize: CGSize(width: 30.0, height: 30.0))
),
content: AnyComponent(
HStack([
AnyComponentWithIdentity(id: 0, component: AnyComponent(
VStack([
AnyComponentWithIdentity(id: 0, component: AnyComponent(
HStack([
AnyComponentWithIdentity(id: 0, component: AnyComponent(MultilineTextComponent(text: .plain(NSAttributedString(string: title, font: Font.semibold(14.0), textColor: .white))))),
AnyComponentWithIdentity(id: 1, component: AnyComponent(Image(image: PresentationResourcesChatList.searchAdIcon(presentationData.theme, strings: presentationData.strings), size: CGSize(width: 31.0, height: 15.0))))
], spacing: 5.0)
)),
AnyComponentWithIdentity(id: 1, component: AnyComponent(
MultilineTextComponent(text: .plain(NSAttributedString(string: adMessage.text, font: Font.regular(14.0), textColor: .white)))
))
], alignment: .left, spacing: 3.0, fillWidth: false)
)),
AnyComponentWithIdentity(id: 1, component: AnyComponent(
AdRemainingProgressComponent(action: { [weak self] in
guard let self else {
return
}
self.adMessage = nil
if let validLayout = self.validLayout {
self.containerLayoutUpdated(validLayout.layout, navigationBarHeight: validLayout.navigationBarHeight, transition: .immediate)
}
})
))
], spacing: 16.0, alignment: .alternatingLeftRight)
), action: { [weak self] in
if let self, let item = self.item, let ad = adMessage.adAttribute {
item.performAction(.url(url: ad.url, concealed: false))
}
}
)
),
environment: {},
containerSize: CGSize(width: layout.size.width - sideInset * 2.0, height: 70.0)
)
if let adView = self.adView.view {
if adView.superview == nil {
self.view.addSubview(adView)
adView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25)
adView.layer.animatePosition(from: CGPoint(x: 0.0, y: 64.0), to: .zero, duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, additive: true)
}
adView.frame = CGRect(origin: CGPoint(x: sideInset, y: layout.size.height - adSize.height - 145.0), size: adSize)
}
} else if let adView = self.adView.view {
adView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, removeOnCompletion: false)
adView.layer.animatePosition(from: .zero, to: CGPoint(x: 0.0, y: 64.0), duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, additive: true)
}
}
private var adView = ComponentView<Empty>()
private var adContext: AdMessagesHistoryContext?
private var adDisposable: Disposable?
private var adMessage: Message?
func setupItem(_ item: UniversalVideoGalleryItem) {
if self.item?.content.id != item.content.id {
var chapters = parseMediaPlayerChapters(item.caption)

View File

@ -1118,6 +1118,26 @@ private final class DemoSheetContent: CombinedComponent {
)
)
//TODO:localize
availableItems[.todo] = DemoPagerComponent.Item(
AnyComponentWithIdentity(
id: PremiumDemoScreen.Subject.todo,
component: AnyComponent(
PageComponent(
content: AnyComponent(PhoneDemoComponent(
context: component.context,
position: .top,
videoFile: configuration.videos["todo"],
decoration: .badgeStars
)),
title: "To-Do Lists",
text: "Plan, assign and complete tasks seamlessly and efficiently.",
textColor: textColor
)
)
)
)
let index: Int = 0
var items: [DemoPagerComponent.Item] = []
if let item = availableItems.first(where: { $0.value.content.id == component.subject as AnyHashable }) {
@ -1216,6 +1236,9 @@ private final class DemoSheetContent: CombinedComponent {
text = strings.Premium_MessageEffectsInfo
case .paidMessages:
text = strings.Premium_PaidMessagesInfo
case .todo:
//TODO:localize
text = "Plan, assign and complete tasks seamlessly and efficiently."
default:
text = ""
}
@ -1302,6 +1325,8 @@ private final class DemoSheetContent: CombinedComponent {
buttonAnimationName = "premium_unlock"
case .paidMessages:
buttonText = strings.Premium_PaidMessages_Proceed
case .todo:
buttonText = strings.Premium_PaidMessages_Proceed
default:
buttonText = strings.Common_OK
}
@ -1492,6 +1517,7 @@ public class PremiumDemoScreen: ViewControllerComponentContainer {
case folderTags
case messageEffects
case paidMessages
case todo
case businessLocation
case businessHours
@ -1552,6 +1578,8 @@ public class PremiumDemoScreen: ViewControllerComponentContainer {
return .messageEffects
case .paidMessages:
return .paidMessages
case .todo:
return .todo
case .businessLocation:
return .businessLocation
case .businessHours:

View File

@ -309,6 +309,12 @@ public enum PremiumSource: Equatable {
} else {
return false
}
case .todo:
if case .todo = rhs {
return true
} else {
return false
}
case let .auth(lhsPrice):
if case let .auth(rhsPrice) = rhs, lhsPrice == rhsPrice {
return true
@ -363,6 +369,7 @@ public enum PremiumSource: Equatable {
case folderTags
case messageEffects
case paidMessages
case todo
case auth(String)
var identifier: String? {
@ -459,6 +466,8 @@ public enum PremiumSource: Equatable {
return "effects"
case .paidMessages:
return "paid_messages"
case .todo:
return "todo"
case .auth:
return "auth"
}
@ -490,6 +499,7 @@ public enum PremiumPerk: CaseIterable {
case folderTags
case messageEffects
case paidMessages
case todo
case businessLocation
case businessHours
@ -601,6 +611,8 @@ public enum PremiumPerk: CaseIterable {
return "effects"
case .paidMessages:
return "paid_messages"
case .todo:
return "todo"
case .business:
return "business"
case .businessLocation:
@ -672,6 +684,9 @@ public enum PremiumPerk: CaseIterable {
return strings.Premium_MessageEffects
case .paidMessages:
return strings.Premium_PaidMessages
case .todo:
//TODO:localize
return "To-Do Lists"
case .businessLocation:
return strings.Business_Location
case .businessHours:
@ -741,6 +756,9 @@ public enum PremiumPerk: CaseIterable {
return strings.Premium_MessageEffectsInfo
case .paidMessages:
return strings.Premium_PaidMessagesInfo
case .todo:
//TODO:localize
return "Plan, assign and complete tasks seamlessly and efficiently."
case .businessLocation:
return strings.Business_LocationInfo
case .businessHours:
@ -810,6 +828,8 @@ public enum PremiumPerk: CaseIterable {
return "Premium/Perk/MessageEffects"
case .paidMessages:
return "Premium/Perk/PaidMessages"
case .todo:
return "Premium/Perk/PaidMessages"
case .businessLocation:
return "Premium/BusinessPerk/Location"
case .businessHours:

View File

@ -131,4 +131,24 @@ public final class TelegramMediaTodo: Media, Equatable {
}
return true
}
func withUpdated(items: [TelegramMediaTodo.Item]) -> TelegramMediaTodo {
return TelegramMediaTodo(
flags: self.flags,
text: self.text,
textEntities: self.textEntities,
items: items,
completions: self.completions
)
}
func withUpdated(completions: [TelegramMediaTodo.Completion]) -> TelegramMediaTodo {
return TelegramMediaTodo(
flags: self.flags,
text: self.text,
textEntities: self.textEntities,
items: self.items,
completions: completions
)
}
}

View File

@ -10,26 +10,36 @@ public enum RequestUpdateTodoMessageError {
}
func _internal_requestUpdateTodoMessageItems(account: Account, messageId: MessageId, completedIds: [Int32], incompletedIds: [Int32]) -> Signal<Never, RequestUpdateTodoMessageError> {
return account.postbox.loadedPeerWithId(messageId.peerId)
|> take(1)
|> castError(RequestUpdateTodoMessageError.self)
|> mapToSignal { peer -> Signal<Never, RequestUpdateTodoMessageError> in
if let inputPeer = apiInputPeer(peer) {
return account.network.request(Api.functions.messages.toggleTodoCompleted(peer: inputPeer, msgId: messageId.id, completed: completedIds, incompleted: incompletedIds))
|> mapError { _ -> RequestUpdateTodoMessageError in
return .generic
}
|> mapToSignal { result -> Signal<Void, RequestUpdateTodoMessageError> in
return account.postbox.transaction { transaction in
account.stateManager.addUpdates(result)
}
|> castError(RequestUpdateTodoMessageError.self)
}
|> ignoreValues
} else {
return account.postbox.transaction { transaction -> Signal<Never, RequestUpdateTodoMessageError> in
guard let peer = transaction.getPeer(messageId.peerId), let inputPeer = apiInputPeer(peer) else {
return .complete()
}
transaction.updateMessage(messageId, update: { currentMessage in
let storeForwardInfo = currentMessage.forwardInfo.flatMap(StoreMessageForwardInfo.init)
var media: [Media] = []
if let todo = currentMessage.media.first(where: { $0 is TelegramMediaTodo }) as? TelegramMediaTodo {
let timestamp = Int32(CFAbsoluteTimeGetCurrent() + NSTimeIntervalSince1970)
var updatedCompletions = todo.completions
for id in completedIds {
updatedCompletions.append(TelegramMediaTodo.Completion(id: id, date: timestamp, completedBy: account.peerId))
}
updatedCompletions.removeAll(where: { incompletedIds.contains($0.id) })
media = [todo.withUpdated(completions: updatedCompletions)]
}
return .update(StoreMessage(id: currentMessage.id, globallyUniqueId: currentMessage.globallyUniqueId, groupingKey: currentMessage.groupingKey, threadId: currentMessage.threadId, timestamp: currentMessage.timestamp, flags: StoreMessageFlags(currentMessage.flags), tags: currentMessage.tags, globalTags: currentMessage.globalTags, localTags: currentMessage.localTags, forwardInfo: storeForwardInfo, authorId: currentMessage.author?.id, text: currentMessage.text, attributes: currentMessage.attributes, media: media))
})
return account.network.request(Api.functions.messages.toggleTodoCompleted(peer: inputPeer, msgId: messageId.id, completed: completedIds, incompleted: incompletedIds))
|> mapError { _ -> RequestUpdateTodoMessageError in
return .generic
}
|> map { result in
account.stateManager.addUpdates(result)
}
|> ignoreValues
}
|> castError(RequestUpdateTodoMessageError.self)
|> switchToLatest
|> ignoreValues
}
public enum AppendTodoMessageError {
@ -37,30 +47,30 @@ public enum AppendTodoMessageError {
}
func _internal_appendTodoMessageItems(account: Account, messageId: MessageId, items: [TelegramMediaTodo.Item]) -> Signal<Never, AppendTodoMessageError> {
return account.postbox.loadedPeerWithId(messageId.peerId)
|> take(1)
|> castError(AppendTodoMessageError.self)
|> mapToSignal { peer -> Signal<TelegramMediaTodo?, AppendTodoMessageError> in
guard let inputPeer = apiInputPeer(peer) else {
return .single(nil)
return account.postbox.transaction { transaction -> Signal<Never, AppendTodoMessageError> in
guard let peer = transaction.getPeer(messageId.peerId), let inputPeer = apiInputPeer(peer) else {
return .complete()
}
transaction.updateMessage(messageId, update: { currentMessage in
let storeForwardInfo = currentMessage.forwardInfo.flatMap(StoreMessageForwardInfo.init)
var media: [Media] = []
if let todo = currentMessage.media.first(where: { $0 is TelegramMediaTodo }) as? TelegramMediaTodo {
var updatedItems = todo.items
updatedItems.append(contentsOf: items)
media = [todo.withUpdated(items: updatedItems)]
}
return .update(StoreMessage(id: currentMessage.id, globallyUniqueId: currentMessage.globallyUniqueId, groupingKey: currentMessage.groupingKey, threadId: currentMessage.threadId, timestamp: currentMessage.timestamp, flags: StoreMessageFlags(currentMessage.flags), tags: currentMessage.tags, globalTags: currentMessage.globalTags, localTags: currentMessage.localTags, forwardInfo: storeForwardInfo, authorId: currentMessage.author?.id, text: currentMessage.text, attributes: currentMessage.attributes, media: media))
})
return account.network.request(Api.functions.messages.appendTodoList(peer: inputPeer, msgId: messageId.id, list: items.map { $0.apiItem }))
|> mapError { _ -> AppendTodoMessageError in
return .generic
}
|> mapToSignal { result -> Signal<TelegramMediaTodo?, AppendTodoMessageError> in
return account.postbox.transaction { transaction -> TelegramMediaTodo? in
switch result {
case let .updates(updates, _, _, _, _):
let _ = updates
default:
break
}
account.stateManager.addUpdates(result)
return nil
}
|> castError(AppendTodoMessageError.self)
|> map { result in
account.stateManager.addUpdates(result)
}
|> ignoreValues
}
|> castError(AppendTodoMessageError.self)
|> switchToLatest
|> ignoreValues
}

View File

@ -168,6 +168,11 @@ public enum PresentationResourceKey: Int32 {
case chatBubbleFileCloudFetchedIncomingIcon
case chatBubbleFileCloudFetchedOutgoingIcon
case chatBubbleTodoDotIncomingIcon
case chatBubbleTodoDotOutgoingIcon
case chatBubbleTodoCheckIncomingIcon
case chatBubbleTodoCheckOutgoingIcon
case chatBubbleReplyThumbnailPlayImage
case chatBubbleDeliveryFailedIcon

View File

@ -1402,4 +1402,28 @@ public struct PresentationResourcesChat {
return nil
})
}
public static func chatBubbleTodoDotIncomingIcon(_ theme: PresentationTheme) -> UIImage? {
return theme.image(PresentationResourceKey.chatBubbleTodoDotIncomingIcon.rawValue, { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Message/TodoDot"), color: theme.chat.message.incoming.accentTextColor)
})
}
public static func chatBubbleTodoDotOutgoingIcon(_ theme: PresentationTheme) -> UIImage? {
return theme.image(PresentationResourceKey.chatBubbleTodoDotOutgoingIcon.rawValue, { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Message/TodoDot"), color: theme.chat.message.outgoing.accentTextColor)
})
}
public static func chatBubbleTodoCheckIncomingIcon(_ theme: PresentationTheme) -> UIImage? {
return theme.image(PresentationResourceKey.chatBubbleTodoCheckIncomingIcon.rawValue, { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Message/TodoCheck"), color: theme.chat.message.incoming.accentTextColor)
})
}
public static func chatBubbleTodoCheckOutgoingIcon(_ theme: PresentationTheme) -> UIImage? {
return theme.image(PresentationResourceKey.chatBubbleTodoCheckOutgoingIcon.rawValue, { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Message/TodoCheck"), color: theme.chat.message.outgoing.accentTextColor)
})
}
}

View File

@ -1286,12 +1286,81 @@ public func universalServiceMessageString(presentationData: (PresentationTheme,
}
attributedString = addAttributesToStringWithRanges(resultString._tuple, body: bodyAttributes, argumentAttributes: attributes)
}
case .unknown:
attributedString = nil
case let .todoCompletions(completed, incompleted):
//TODO:release
let _ = completed
let _ = incompleted
var todo: TelegramMediaTodo?
for attribute in message.attributes {
if let attribute = attribute as? ReplyMessageAttribute, let message = message.associatedMessages[attribute.messageId] {
for media in message.media {
if let media = media as? TelegramMediaTodo {
todo = media
}
}
}
}
if let todo {
if message.author?.id == accountPeerId {
let resultString: PresentationStrings.FormattedString
if let completedTaskId = completed.first, let completedTask = todo.items.first(where: { $0.id == completedTaskId }) {
resultString = strings.Notification_TodoCompletedYou(completedTask.text)
} else if let incompletedTaskId = incompleted.first, let incompletedTask = todo.items.first(where: { $0.id == incompletedTaskId }) {
resultString = strings.Notification_TodoIncompletedYou(incompletedTask.text)
} else {
fatalError()
}
attributedString = addAttributesToStringWithRanges(resultString._tuple, body: bodyAttributes, argumentAttributes: [0: boldAttributes])
} else {
let peerName = message.author?.compactDisplayTitle ?? ""
var attributes = peerMentionsAttributes(primaryTextColor: primaryTextColor, peerIds: [(0, message.author?.id)])
attributes[1] = boldAttributes
let resultString: PresentationStrings.FormattedString
if let completedTaskId = completed.first, let completedTask = todo.items.first(where: { $0.id == completedTaskId }) {
resultString = strings.Notification_TodoCompleted(peerName, completedTask.text)
} else if let incompletedTaskId = incompleted.first, let incompletedTask = todo.items.first(where: { $0.id == incompletedTaskId }) {
resultString = strings.Notification_TodoIncompleted(peerName, incompletedTask.text)
} else {
fatalError()
}
attributedString = addAttributesToStringWithRanges(resultString._tuple, body: bodyAttributes, argumentAttributes: attributes)
}
} else {
attributedString = NSAttributedString(string: ".")
}
case let .todoAppendTasks(tasks):
var todoTitle = ""
for attribute in message.attributes {
if let attribute = attribute as? ReplyMessageAttribute, let message = message.associatedMessages[attribute.messageId] {
for media in message.media {
if let todo = media as? TelegramMediaTodo {
todoTitle = todo.text
}
}
}
}
if message.author?.id == accountPeerId {
let resultString: PresentationStrings.FormattedString
if tasks.count == 1, let task = tasks.first {
resultString = strings.Notification_TodoAddedTaskYou(task.text, todoTitle)
} else {
resultString = strings.Notification_TodoAddedMultipleTasksYou(strings.Notification_TodoTasks(Int32(tasks.count)), todoTitle)
}
attributedString = addAttributesToStringWithRanges(resultString._tuple, body: bodyAttributes, argumentAttributes: [0: boldAttributes, 1: boldAttributes])
} else {
let peerName = message.author?.compactDisplayTitle ?? ""
var attributes = peerMentionsAttributes(primaryTextColor: primaryTextColor, peerIds: [(0, message.author?.id)])
attributes[1] = boldAttributes
attributes[2] = boldAttributes
let resultString: PresentationStrings.FormattedString
if tasks.count == 1, let task = tasks.first {
resultString = strings.Notification_TodoAddedTask(peerName, task.text, todoTitle)
} else {
resultString = strings.Notification_TodoAddedMultipleTasks(peerName, strings.Notification_TodoTasks(Int32(tasks.count)), todoTitle)
}
attributedString = addAttributesToStringWithRanges(resultString._tuple, body: bodyAttributes, argumentAttributes: attributes)
}
case .unknown:
attributedString = nil
}
break

View File

@ -481,6 +481,7 @@ swift_library(
"//submodules/TelegramUI/Components/Chat/ChatSideTopicsPanel",
"//submodules/TelegramUI/Components/GifVideoLayer",
"//submodules/TelegramUI/Components/BatchVideoRendering",
"//submodules/TelegramUI/Components/ComposeTodoScreen",
] + select({
"@build_bazel_rules_apple//apple:ios_arm64": appcenter_targets,
"//build-system:ios_sim_arm64": [],

View File

@ -51,6 +51,7 @@ swift_library(
"//submodules/TelegramUI/Components/Chat/ChatMessageFileBubbleContentNode",
"//submodules/TelegramUI/Components/Chat/ChatMessageWebpageBubbleContentNode",
"//submodules/TelegramUI/Components/Chat/ChatMessagePollBubbleContentNode",
"//submodules/TelegramUI/Components/Chat/ChatMessageTodoBubbleContentNode",
"//submodules/TelegramUI/Components/Chat/ChatMessageItem",
"//submodules/TelegramUI/Components/Chat/ChatMessageItemView",
"//submodules/TelegramUI/Components/Chat/ChatMessageSwipeToReplyNode",

View File

@ -41,6 +41,7 @@ import ChatMessageInteractiveFileNode
import ChatMessageFileBubbleContentNode
import ChatMessageWebpageBubbleContentNode
import ChatMessagePollBubbleContentNode
import ChatMessageTodoBubbleContentNode
import ChatMessageItem
import ChatMessageItemView
import ChatMessageSwipeToReplyNode
@ -269,6 +270,9 @@ private func contentNodeMessagesAndClassesForItem(_ item: ChatMessageItem) -> ([
} else if let _ = media as? TelegramMediaPoll {
result.append((message, ChatMessagePollBubbleContentNode.self, itemAttributes, BubbleItemAttributes(isAttachment: false, neighborType: .text, neighborSpacing: .default)))
needReactions = false
} else if let _ = media as? TelegramMediaTodo {
result.append((message, ChatMessageTodoBubbleContentNode.self, itemAttributes, BubbleItemAttributes(isAttachment: false, neighborType: .text, neighborSpacing: .default)))
needReactions = false
} else if let _ = media as? TelegramMediaGiveaway {
result.append((message, ChatMessageGiveawayBubbleContentNode.self, itemAttributes, BubbleItemAttributes(isAttachment: false, neighborType: .text, neighborSpacing: .default)))
needReactions = false

View File

@ -449,10 +449,12 @@ private final class ChatMessagePollOptionNode: ASDisplayNode {
self.buttonNode.highligthedChanged = { [weak self] highlighted in
if let strongSelf = self {
if highlighted {
if "".isEmpty, let contentNode = strongSelf.supernode as? ChatMessagePollBubbleContentNode, let backdropNode = contentNode.bubbleBackgroundNode?.backdropNode {
if let theme = strongSelf.theme, theme.overallDarkAppearance, let contentNode = strongSelf.supernode as? ChatMessagePollBubbleContentNode, let backdropNode = contentNode.bubbleBackgroundNode?.backdropNode {
strongSelf.highlightedBackgroundNode.layer.compositingFilter = "overlayBlendMode"
strongSelf.highlightedBackgroundNode.frame = strongSelf.view.convert(strongSelf.highlightedBackgroundNode.frame, to: backdropNode.view)
backdropNode.addSubnode(strongSelf.highlightedBackgroundNode)
} else {
strongSelf.insertSubnode(strongSelf.highlightedBackgroundNode, at: 0)
}
strongSelf.highlightedBackgroundNode.layer.removeAnimation(forKey: "opacity")

View File

@ -0,0 +1,35 @@
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
swift_library(
name = "ChatMessageTodoBubbleContentNode",
module_name = "ChatMessageTodoBubbleContentNode",
srcs = glob([
"Sources/**/*.swift",
]),
copts = [
"-warnings-as-errors",
],
deps = [
"//submodules/AsyncDisplayKit",
"//submodules/Display",
"//submodules/TelegramCore",
"//submodules/Postbox",
"//submodules/TextFormat",
"//submodules/UrlEscaping",
"//submodules/SSignalKit/SwiftSignalKit",
"//submodules/AccountContext",
"//submodules/AvatarNode",
"//submodules/TelegramPresentationData",
"//submodules/ChatMessageBackground",
"//submodules/TelegramUI/Components/Chat/ChatMessageDateAndStatusNode",
"//submodules/TelegramUI/Components/Chat/ChatMessageBubbleContentNode",
"//submodules/TelegramUI/Components/Chat/ChatMessageItemCommon",
"//submodules/TelegramUI/Components/Chat/PollBubbleTimerNode",
"//submodules/TelegramUI/Components/Chat/MergedAvatarsNode",
"//submodules/TelegramUI/Components/TextNodeWithEntities",
"//submodules/TelegramUI/Components/Chat/ShimmeringLinkNode",
],
visibility = [
"//visibility:public",
],
)

View File

@ -170,6 +170,7 @@ public final class ChatRecentActionsController: TelegramBaseController {
}, openBoostToUnrestrict: {
}, updateRecordingTrimRange: { _, _, _, _ in
}, dismissAllTooltips: {
}, editTodoMessage: { _, _ in
}, updateHistoryFilter: { _ in
}, updateChatLocationThread: { _, _ in
}, toggleChatSidebarMode: {

View File

@ -647,6 +647,8 @@ final class ChatRecentActionsControllerNode: ViewControllerTracingNode {
}, playShakeAnimation: {
}, displayQuickShare: { _, _ ,_ in
}, updateChatLocationThread: { _, _ in
}, requestToggleTodoMessageItem: { _, _, _ in
}, displayTodoToggleUnavailable: { _ in
}, automaticMediaDownloadSettings: self.automaticMediaDownloadSettings,
pollActionState: ChatInterfacePollActionState(), stickerSettings: ChatInterfaceStickerSettings(), presentationContext: ChatPresentationContext(context: context, backgroundNode: self.backgroundNode))
self.controllerInteraction = controllerInteraction

View File

@ -502,6 +502,8 @@ public final class ChatSendGroupMediaMessageContextPreview: UIView, ChatSendMess
}, playShakeAnimation: {
}, displayQuickShare: { _, _ ,_ in
}, updateChatLocationThread: { _, _ in
}, requestToggleTodoMessageItem: { _, _, _ in
}, displayTodoToggleUnavailable: { _ in
}, automaticMediaDownloadSettings: MediaAutoDownloadSettings.defaultSettings,
pollActionState: ChatInterfacePollActionState(), stickerSettings: ChatInterfaceStickerSettings(), presentationContext: ChatPresentationContext(context: self.context, backgroundNode: self.wallpaperBackgroundNode))

View File

@ -281,7 +281,8 @@ public final class ChatControllerInteraction: ChatControllerInteractionProtocol
public let playShakeAnimation: () -> Void
public let displayQuickShare: (MessageId, ASDisplayNode, ContextGesture) -> Void
public let updateChatLocationThread: (Int64?, ChatControllerAnimateInnerChatSwitchDirection?) -> Void
public let requestToggleTodoMessageItem: (MessageId, Int32, Bool) -> Void
public let displayTodoToggleUnavailable: (MessageId) -> Void
public var canPlayMedia: Bool = false
public var hiddenMedia: [MessageId: [Media]] = [:]
public var expandedTranslationMessageStableIds: Set<UInt32> = Set()
@ -444,6 +445,8 @@ public final class ChatControllerInteraction: ChatControllerInteractionProtocol
playShakeAnimation: @escaping () -> Void,
displayQuickShare: @escaping (MessageId, ASDisplayNode, ContextGesture) -> Void,
updateChatLocationThread: @escaping (Int64?, ChatControllerAnimateInnerChatSwitchDirection?) -> Void,
requestToggleTodoMessageItem: @escaping (MessageId, Int32, Bool) -> Void,
displayTodoToggleUnavailable: @escaping (MessageId) -> Void,
automaticMediaDownloadSettings: MediaAutoDownloadSettings,
pollActionState: ChatInterfacePollActionState,
stickerSettings: ChatInterfaceStickerSettings,
@ -563,6 +566,8 @@ public final class ChatControllerInteraction: ChatControllerInteractionProtocol
self.playShakeAnimation = playShakeAnimation
self.displayQuickShare = displayQuickShare
self.updateChatLocationThread = updateChatLocationThread
self.requestToggleTodoMessageItem = requestToggleTodoMessageItem
self.displayTodoToggleUnavailable = displayTodoToggleUnavailable
self.automaticMediaDownloadSettings = automaticMediaDownloadSettings

View File

@ -0,0 +1,51 @@
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
swift_library(
name = "ComposeTodoScreen",
module_name = "ComposeTodoScreen",
srcs = glob([
"Sources/**/*.swift",
]),
copts = [
"-warnings-as-errors",
],
deps = [
"//submodules/SSignalKit/SwiftSignalKit",
"//submodules/AsyncDisplayKit",
"//submodules/Display",
"//submodules/TelegramCore",
"//submodules/TelegramPresentationData",
"//submodules/ItemListUI",
"//submodules/AccountContext",
"//submodules/AlertUI",
"//submodules/PresentationDataUtils",
"//submodules/TextFormat",
"//submodules/ObjCRuntimeUtils",
"//submodules/AttachmentUI",
"//submodules/TextInputMenu",
"//submodules/ComponentFlow",
"//submodules/Components/ComponentDisplayAdapters",
"//submodules/Components/ViewControllerComponent",
"//submodules/Components/MultilineTextComponent",
"//submodules/TelegramUI/Components/ListSectionComponent",
"//submodules/AppBundle",
"//submodules/TelegramUI/Components/EntityKeyboard",
"//submodules/UndoUI",
"//submodules/Components/BundleIconComponent",
"//submodules/TelegramUI/Components/AnimatedTextComponent",
"//submodules/TelegramUI/Components/PeerAllowedReactionsScreen",
"//submodules/TelegramUI/Components/ListMultilineTextFieldItemComponent",
"//submodules/TelegramUI/Components/ListActionItemComponent",
"//submodules/TelegramUI/Components/TextFieldComponent",
"//submodules/TelegramUI/Components/LottieComponent",
"//submodules/TelegramUI/Components/PlainButtonComponent",
"//submodules/ChatPresentationInterfaceState",
"//submodules/TelegramUI/Components/EmojiSuggestionsComponent",
"//submodules/TelegramUI/Components/ListComposePollOptionComponent",
"//submodules/ComposePollUI",
"//submodules/Markdown",
],
visibility = [
"//visibility:public",
],
)

View File

@ -0,0 +1,30 @@
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
swift_library(
name = "ListComposePollOptionComponent",
module_name = "ListComposePollOptionComponent",
srcs = glob([
"Sources/**/*.swift",
]),
copts = [
"-warnings-as-errors",
],
deps = [
"//submodules/Display",
"//submodules/TelegramPresentationData",
"//submodules/ComponentFlow",
"//submodules/Components/MultilineTextComponent",
"//submodules/TelegramUI/Components/SliderComponent",
"//submodules/TelegramUI/Components/ListSectionComponent",
"//submodules/TelegramUI/Components/TextFieldComponent",
"//submodules/TelegramUI/Components/LottieComponent",
"//submodules/TelegramUI/Components/PlainButtonComponent",
"//submodules/CheckNode",
"//submodules/AccountContext",
"//submodules/PresentationDataUtils",
],
visibility = [
"//visibility:public",
],
)

View File

@ -73,6 +73,7 @@ public final class ListComposePollOptionComponent: Component {
public let theme: PresentationTheme
public let strings: PresentationStrings
public let placeholder: NSAttributedString?
public let isEnabled: Bool
public let resetText: ResetText?
public let assumeIsEditing: Bool
public let characterLimit: Int?
@ -92,6 +93,7 @@ public final class ListComposePollOptionComponent: Component {
theme: PresentationTheme,
strings: PresentationStrings,
placeholder: NSAttributedString? = nil,
isEnabled: Bool = true,
resetText: ResetText? = nil,
assumeIsEditing: Bool = false,
characterLimit: Int,
@ -110,6 +112,7 @@ public final class ListComposePollOptionComponent: Component {
self.theme = theme
self.strings = strings
self.placeholder = placeholder
self.isEnabled = isEnabled
self.resetText = resetText
self.assumeIsEditing = assumeIsEditing
self.characterLimit = characterLimit
@ -140,6 +143,9 @@ public final class ListComposePollOptionComponent: Component {
if lhs.placeholder != rhs.placeholder {
return false
}
if lhs.isEnabled != rhs.isEnabled {
return false
}
if lhs.resetText != rhs.resetText {
return false
}
@ -400,6 +406,9 @@ public final class ListComposePollOptionComponent: Component {
self.textField.parentState = state
}
transition.setFrame(view: textFieldView, frame: textFieldFrame)
transition.setAlpha(view: textFieldView, alpha: component.isEnabled ? 1.0 : 0.3)
textFieldView.isUserInteractionEnabled = component.isEnabled
}
if let selection = component.selection {

View File

@ -76,6 +76,7 @@ public final class ListSectionContentView: UIView {
private let contentItemContainerView: UIView
public let externalContentBackgroundView: DynamicCornerRadiusView
public var automaticallyLayoutExternalContentBackgroundView = true
public var itemViews: [AnyHashable: ItemView] = [:]
private var highlightedItemId: AnyHashable?
@ -283,7 +284,9 @@ public final class ListSectionContentView: UIView {
}
self.externalContentBackgroundView.update(size: backgroundFrame.size, corners: corners, transition: transition)
}
transition.setFrame(view: self.externalContentBackgroundView, frame: backgroundFrame)
if self.automaticallyLayoutExternalContentBackgroundView {
transition.setFrame(view: self.externalContentBackgroundView, frame: backgroundFrame)
}
transition.setAlpha(view: self.externalContentBackgroundView, alpha: backgroundAlpha)
transition.setCornerRadius(layer: self.layer, cornerRadius: contentCornerRadius)

View File

@ -435,6 +435,7 @@ final class PeerInfoSelectionPanelNode: ASDisplayNode {
}, openBoostToUnrestrict: {
}, updateRecordingTrimRange: { _, _, _, _ in
}, dismissAllTooltips: {
}, editTodoMessage: { _, _ in
}, updateHistoryFilter: { _ in
}, updateChatLocationThread: { _, _ in
}, toggleChatSidebarMode: {
@ -3854,6 +3855,8 @@ final class PeerInfoScreenNode: ViewControllerTracingNode, PeerInfoScreenNodePro
}, playShakeAnimation: {
}, displayQuickShare: { _, _ ,_ in
}, updateChatLocationThread: { _, _ in
}, requestToggleTodoMessageItem: { _, _, _ in
}, displayTodoToggleUnavailable: { _ in
}, automaticMediaDownloadSettings: MediaAutoDownloadSettings.defaultSettings,
pollActionState: ChatInterfacePollActionState(), stickerSettings: ChatInterfaceStickerSettings(), presentationContext: ChatPresentationContext(context: context, backgroundNode: nil))
self.hiddenMediaDisposable = context.sharedContext.mediaManager.galleryHiddenMediaManager.hiddenIds().startStrict(next: { [weak self] ids in

View File

@ -823,6 +823,7 @@ final class PeerSelectionControllerNode: ASDisplayNode {
}, openBoostToUnrestrict: {
}, updateRecordingTrimRange: { _, _, _, _ in
}, dismissAllTooltips: {
}, editTodoMessage: { _, _ in
}, updateHistoryFilter: { _ in
}, updateChatLocationThread: { _, _ in
}, toggleChatSidebarMode: {

View File

@ -835,11 +835,21 @@ private final class StarsTransactionSheetContent: CombinedComponent {
)
))
} else if case .unique = giftAnimationSubject {
let reason: String
if count < StarsAmount.zero, case let .transaction(transaction, _) = subject {
if transaction.flags.contains(.isStarGiftResale) {
reason = strings.Stars_Transaction_GiftPurchase
} else {
reason = strings.Stars_Transaction_GiftTransfer
}
} else {
reason = strings.Stars_Transaction_GiftSale
}
tableItems.append(.init(
id: "reason",
title: strings.Stars_Transaction_Giveaway_Reason,
component: AnyComponent(
MultilineTextComponent(text: .plain(NSAttributedString(string: count < StarsAmount.zero ? strings.Stars_Transaction_GiftPurchase : strings.Stars_Transaction_GiftSale, font: tableFont, textColor: tableTextColor)))
MultilineTextComponent(text: .plain(NSAttributedString(string: reason, font: tableFont, textColor: tableTextColor)))
)
))
}

View File

@ -328,7 +328,15 @@ final class StarsTransactionsListPanelComponent: Component {
break
}
}
itemSubtitle = item.count > StarsAmount.zero ? environment.strings.Stars_Intro_Transaction_GiftSale : environment.strings.Stars_Intro_Transaction_GiftPurchase
if item.count > StarsAmount.zero {
itemSubtitle = environment.strings.Stars_Intro_Transaction_GiftSale
} else {
if item.flags.contains(.isStarGiftResale) {
itemSubtitle = environment.strings.Stars_Intro_Transaction_GiftPurchase
} else {
itemSubtitle = environment.strings.Stars_Intro_Transaction_GiftTransfer
}
}
}
}
} else if let _ = item.giveawayMessageId {

View File

@ -9,17 +9,20 @@ public final class ToastContentComponent: Component {
public let content: AnyComponent<Empty>
public let insets: UIEdgeInsets
public let iconSpacing: CGFloat
public let action: (() -> Void)?
public init(
icon: AnyComponent<Empty>,
content: AnyComponent<Empty>,
insets: UIEdgeInsets = UIEdgeInsets(top: 10.0, left: 10.0, bottom: 10.0, right: 10.0),
iconSpacing: CGFloat = 10.0
iconSpacing: CGFloat = 10.0,
action: (() -> Void)? = nil
) {
self.icon = icon
self.content = content
self.insets = insets
self.iconSpacing = iconSpacing
self.action = action
}
public static func ==(lhs: ToastContentComponent, rhs: ToastContentComponent) -> Bool {
@ -39,6 +42,8 @@ public final class ToastContentComponent: Component {
}
public final class View: UIView {
private var component: ToastContentComponent?
private let backgroundView: BlurredBackgroundView
private let icon = ComponentView<Empty>()
private let content = ComponentView<Empty>()
@ -63,9 +68,21 @@ public final class ToastContentComponent: Component {
fatalError("init(coder:) has not been implemented")
}
@objc private func tapped() {
self.component?.action?()
}
func update(component: ToastContentComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
var contentHeight: CGFloat = 0.0
if self.component == nil {
if let _ = component.action {
self.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.tapped)))
}
}
self.component = component
let leftInset: CGFloat = component.insets.left
let rightInset: CGFloat = component.insets.right
let topInset: CGFloat = component.insets.top

View File

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "todolist_30.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "todo_check.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "todo_dot.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -21,7 +21,6 @@ import DeviceLocationManager
import ShareController
import UrlEscaping
import ContextUI
import ComposePollUI
import AlertUI
import PresentationDataUtils
import UndoUI
@ -4127,6 +4126,11 @@ extension ChatControllerImpl {
return
}
self.dismissAllTooltips()
}, editTodoMessage: { [weak self] messageId, append in
guard let self else {
return
}
self.openTodoEditing(messageId: messageId, append: append)
}, updateHistoryFilter: { [weak self] update in
guard let self else {
return

View File

@ -595,7 +595,17 @@ extension ChatControllerImpl {
self.dismissAllTooltips()
let insets = layout.insets(options: [.input])
let location = CGRect(origin: CGPoint(x: layout.size.width - layout.safeInsets.right - 42.0 - UIScreenPixel, y: layout.size.height - insets.bottom - 122.0), size: CGSize())
var screenWidth = layout.size.width
if layout.metrics.isTablet {
if layout.size.height == layout.deviceMetrics.screenSize.width {
screenWidth = layout.deviceMetrics.screenSize.height
} else {
screenWidth = layout.deviceMetrics.screenSize.width
}
}
let location = CGRect(origin: CGPoint(x: screenWidth - layout.safeInsets.right - 42.0 - UIScreenPixel, y: layout.size.height - insets.bottom - 122.0), size: CGSize())
let tooltipController = TooltipScreen(
account: self.context.account,

View File

@ -22,6 +22,7 @@ import ShareController
import UrlEscaping
import ContextUI
import ComposePollUI
import ComposeTodoScreen
import AlertUI
import PresentationDataUtils
import UndoUI
@ -342,6 +343,8 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G
var selectMessagePollOptionDisposables: DisposableDict<MessageId>?
var selectPollOptionFeedback: HapticFeedback?
var updateMessageTodoDisposables: DisposableDict<MessageId>?
var resolveUrlDisposable: MetaDisposable?
var contextQueryStates: [ChatPresentationInputQueryKind: (ChatPresentationInputQuery, Disposable)] = [:]
@ -4766,6 +4769,68 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G
return direction ? .right : .left
}
self.updateChatLocationThread(threadId: threadId, animationDirection: animationDirection ?? defaultDirection)
}, requestToggleTodoMessageItem: { [weak self] messageId, itemId, value in
guard let self else {
return
}
let disposables: DisposableDict<MessageId>
if let current = self.updateMessageTodoDisposables {
disposables = current
} else {
disposables = DisposableDict()
self.updateMessageTodoDisposables = disposables
}
var completedIds: [Int32] = []
var incompletedIds: [Int32] = []
if value {
completedIds.append(itemId)
} else {
incompletedIds.append(itemId)
}
let signal = self.context.engine.messages.requestUpdateTodoMessageItems(messageId: messageId, completedIds: completedIds, incompletedIds: incompletedIds)
disposables.set((signal
|> deliverOnMainQueue).startStrict(next: { todo in
}, error: { _ in
}), forKey: messageId)
}, displayTodoToggleUnavailable: { [weak self] messageId in
guard let self else {
return
}
self.dismissAllUndoControllers()
//TODO:localize
if !self.context.isPremium {
let controller = UndoOverlayController(
presentationData: self.presentationData,
content: .premiumPaywall(title: nil, text: "Only [Telegram Premium]() subscribers can mark tasks as done.", customUndoText: nil, timeout: nil, linkAction: nil),
action: { [weak self] action in
guard let self else {
return false
}
if case .info = action {
let controller = self.context.sharedContext.makePremiumIntroController(context: context, source: .presence, forceDark: false, dismissed: nil)
self.push(controller)
}
return false
}
)
self.present(controller, in: .current)
} else if let message = self.chatDisplayNode.historyNode.messageInCurrentHistoryView(messageId) {
var peerName = ""
if let author = message.author {
peerName = EnginePeer(author).compactDisplayTitle
}
let controller = UndoOverlayController(
presentationData: self.presentationData,
content: .universalImage(image: generateTintedImage(image: UIImage(bundleImageName: "Chat/Stickers/Lock"), color: .white)!, size: nil, title: nil, text: "\(peerName) has restricted others from editing this to do list.", customUndoText: nil, timeout: nil),
action: { _ in
return false
}
)
self.present(controller, in: .current)
}
}, automaticMediaDownloadSettings: self.automaticMediaDownloadSettings, pollActionState: ChatInterfacePollActionState(), stickerSettings: self.stickerSettings, presentationContext: ChatPresentationContext(context: context, backgroundNode: self.chatBackgroundNode))
controllerInteraction.enableFullTranslucency = context.sharedContext.energyUsageSettings.fullTranslucency
@ -5819,6 +5884,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G
self.updateChatLocationThreadDisposable?.dispose()
self.accountPeerDisposable?.dispose()
self.contentDataDisposable?.dispose()
self.updateMessageTodoDisposables?.dispose()
}
public func updatePresentationMode(_ mode: ChatControllerPresentationMode) {
@ -7549,57 +7615,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G
self.present(tooltipScreen, in: .current)
}
func configurePollCreation(isQuiz: Bool? = nil) -> ViewController? {
guard let peer = self.presentationInterfaceState.renderedPeer?.peer else {
return nil
}
return createPollController(context: self.context, updatedPresentationData: self.updatedPresentationData, peer: EnginePeer(peer), isQuiz: isQuiz, completion: { [weak self] poll in
guard let strongSelf = self else {
return
}
strongSelf.presentPaidMessageAlertIfNeeded(completion: { [weak self] postpone in
guard let strongSelf = self else {
return
}
let replyMessageSubject = strongSelf.presentationInterfaceState.interfaceState.replyMessageSubject
strongSelf.chatDisplayNode.setupSendActionOnViewUpdate({
if let strongSelf = self {
strongSelf.chatDisplayNode.collapseInput()
strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: false, {
$0.updatedInterfaceState { $0.withUpdatedReplyMessageSubject(nil).withUpdatedSendMessageEffect(nil) }
})
}
}, nil)
let message: EnqueueMessage = .message(
text: "",
attributes: [],
inlineStickers: [:],
mediaReference: .standalone(media: TelegramMediaPoll(
pollId: MediaId(namespace: Namespaces.Media.LocalPoll, id: Int64.random(in: Int64.min ... Int64.max)),
publicity: poll.publicity,
kind: poll.kind,
text: poll.text.string,
textEntities: poll.text.entities,
options: poll.options,
correctAnswers: poll.correctAnswers,
results: poll.results,
isClosed: false,
deadlineTimeout: poll.deadlineTimeout
)),
threadId: strongSelf.chatLocation.threadId,
replyToMessageId: nil,
replyToStoryId: nil,
localGroupingKey: nil,
correlationId: nil,
bubbleUpEmojiOrStickersets: []
)
strongSelf.sendMessages([message.withUpdatedReplyToMessageId(replyMessageSubject?.subjectModel)])
})
})
}
func transformEnqueueMessages(_ messages: [EnqueueMessage], postpone: Bool = false) -> [EnqueueMessage] {
let silentPosting = self.presentationInterfaceState.interfaceState.silentPosting
return transformEnqueueMessages(messages, silentPosting: silentPosting, postpone: postpone)

View File

@ -33,6 +33,8 @@ import AutomaticBusinessMessageSetupScreen
import MediaEditorScreen
import CameraScreen
import ShareController
import ComposeTodoScreen
import ComposePollUI
extension ChatControllerImpl {
enum AttachMenuSubject {
@ -113,6 +115,8 @@ extension ChatControllerImpl {
availableButtons.insert(.poll, at: max(0, availableButtons.count - 1))
}
availableButtons.append(.todo)
let presentationData = self.presentationData
var isScheduledMessages = false
@ -622,6 +626,26 @@ extension ChatControllerImpl {
completion(controller, controller.mediaPickerContext)
strongSelf.controllerNavigationDisposable.set(nil)
}
case .todo:
if strongSelf.context.isPremium {
if let controller = strongSelf.configureTodoCreation() as? AttachmentContainable {
completion(controller, controller.mediaPickerContext)
strongSelf.controllerNavigationDisposable.set(nil)
}
} else {
var replaceImpl: ((ViewController) -> Void)?
let demoController = strongSelf.context.sharedContext.makePremiumDemoController(context: strongSelf.context, subject: .todo, forceDark: false, action: {
let controller = context.sharedContext.makePremiumIntroController(context: context, source: .todo, forceDark: false, dismissed: nil)
replaceImpl?(controller)
}, dismissed: nil)
replaceImpl = { [weak demoController] c in
demoController?.replace(with: c)
}
strongSelf.push(demoController)
Queue.mainQueue().after(0.4) {
strongSelf.attachmentController?.dismiss(animated: false)
}
}
case .gift:
if let peer = strongSelf.presentationInterfaceState.renderedPeer?.peer, let starsContext = context.starsContext {
let premiumGiftOptions = strongSelf.presentationInterfaceState.premiumGiftOptions
@ -677,12 +701,12 @@ extension ChatControllerImpl {
let _ = self.context.engine.messages.acceptAttachMenuBotDisclaimer(botId: bot.peer.id).startStandalone()
}
let _ = (self.context.engine.messages.addBotToAttachMenu(botId: bot.peer.id, allowWrite: allowWrite)
|> deliverOnMainQueue).startStandalone(error: { _ in
|> deliverOnMainQueue).startStandalone(error: { _ in
}, completed: { [weak controller] in
controller?.refresh()
})
},
dismissed: {
dismissed: {
strongSelf.attachmentController?.dismiss(animated: true)
})
strongSelf.present(alertController, in: .window(.root))
@ -1967,4 +1991,147 @@ extension ChatControllerImpl {
mainController.supportedOrientations = ViewControllerSupportedOrientations(regularSize: .all, compactSize: .portrait)
self.push(mainController)
}
func configurePollCreation(isQuiz: Bool? = nil) -> ViewController? {
guard let peer = self.presentationInterfaceState.renderedPeer?.peer else {
return nil
}
return ComposePollScreen(
context: self.context,
initialData: ComposePollScreen.initialData(context: self.context),
peer: EnginePeer(peer),
isQuiz: isQuiz,
completion: { [weak self] poll in
guard let self else {
return
}
self.presentPaidMessageAlertIfNeeded(completion: { [weak self] postpone in
guard let self else {
return
}
let replyMessageSubject = self.presentationInterfaceState.interfaceState.replyMessageSubject
self.chatDisplayNode.setupSendActionOnViewUpdate({ [weak self] in
if let self {
self.chatDisplayNode.collapseInput()
self.updateChatPresentationInterfaceState(animated: true, interactive: false, {
$0.updatedInterfaceState { $0.withUpdatedReplyMessageSubject(nil).withUpdatedSendMessageEffect(nil) }
})
}
}, nil)
let message: EnqueueMessage = .message(
text: "",
attributes: [],
inlineStickers: [:],
mediaReference: .standalone(media: TelegramMediaPoll(
pollId: MediaId(namespace: Namespaces.Media.LocalPoll, id: Int64.random(in: Int64.min...Int64.max)),
publicity: poll.publicity,
kind: poll.kind,
text: poll.text.string,
textEntities: poll.text.entities,
options: poll.options,
correctAnswers: poll.correctAnswers,
results: poll.results,
isClosed: false,
deadlineTimeout: poll.deadlineTimeout
)),
threadId: self.chatLocation.threadId,
replyToMessageId: nil,
replyToStoryId: nil,
localGroupingKey: nil,
correlationId: nil,
bubbleUpEmojiOrStickersets: []
)
self.sendMessages([message.withUpdatedReplyToMessageId(replyMessageSubject?.subjectModel)])
})
}
)
}
func configureTodoCreation() -> ViewController? {
guard let peer = self.presentationInterfaceState.renderedPeer?.peer else {
return nil
}
return ComposeTodoScreen(
context: self.context,
initialData: ComposeTodoScreen.initialData(
context: self.context
),
peer: EnginePeer(peer),
completion: { [weak self] todo in
guard let self else {
return
}
self.presentPaidMessageAlertIfNeeded(completion: { [weak self] postpone in
guard let self else {
return
}
let replyMessageSubject = self.presentationInterfaceState.interfaceState.replyMessageSubject
self.chatDisplayNode.setupSendActionOnViewUpdate({ [weak self] in
if let self {
self.chatDisplayNode.collapseInput()
self.updateChatPresentationInterfaceState(animated: true, interactive: false, {
$0.updatedInterfaceState { $0.withUpdatedReplyMessageSubject(nil).withUpdatedSendMessageEffect(nil) }
})
}
}, nil)
let message: EnqueueMessage = .message(
text: "",
attributes: [],
inlineStickers: [:],
mediaReference: .standalone(media: todo),
threadId: self.chatLocation.threadId,
replyToMessageId: nil,
replyToStoryId: nil,
localGroupingKey: nil,
correlationId: nil,
bubbleUpEmojiOrStickersets: []
)
self.sendMessages([message.withUpdatedReplyToMessageId(replyMessageSubject?.subjectModel)])
})
}
)
}
func openTodoEditing(messageId: EngineMessage.Id, append: Bool) {
guard let message = self.chatDisplayNode.historyNode.messageInCurrentHistoryView(messageId), let peer = self.presentationInterfaceState.renderedPeer?.peer else {
return
}
guard let existingTodo = message.media.first(where: { $0 is TelegramMediaTodo }) as? TelegramMediaTodo else {
return
}
let canEdit = canEditMessage(context: self.context, limitsConfiguration: self.context.currentLimitsConfiguration.with { EngineConfiguration.Limits($0) }, message: message)
let controller = ComposeTodoScreen(
context: self.context,
initialData: ComposeTodoScreen.initialData(
context: self.context,
existingTodo: existingTodo,
append: append,
canEdit: canEdit
),
peer: EnginePeer(peer),
completion: { [weak self] todo in
guard let self else {
return
}
if canEdit {
let _ = self.context.engine.messages.requestEditMessage(
messageId: messageId,
text: "",
media: .update(.standalone(media: todo)),
entities: nil,
inlineStickers: [:]
).start()
} else {
let appendedItems = Array(todo.items[existingTodo.items.count ..< todo.items.count])
let _ = self.context.engine.messages.appendTodoMessageItems(messageId: messageId, items: appendedItems).start()
}
}
)
controller.navigationPresentation = .modal
self.push(controller)
}
}

View File

@ -790,22 +790,22 @@ public final class ChatHistoryListNodeImpl: ListView, ChatHistoryNode, ChatHisto
self.prefetchManager = InChatPrefetchManager(context: context)
self.adMessagesContext = adMessagesContext
var adMessages: Signal<(interPostInterval: Int32?, messages: [Message]), NoError>
var adMessages: Signal<(interPostInterval: Int32?, messages: [Message], startDelay: Int32?, betweenDelay: Int32?), NoError>
if case .bubbles = mode, let adMessagesContext {
let peerId = adMessagesContext.peerId
if peerId.namespace == Namespaces.Peer.CloudUser {
adMessages = .single((nil, []))
adMessages = .single((nil, [], nil, nil))
} else {
if context.sharedContext.immediateExperimentalUISettings.fakeAds {
adMessages = context.engine.data.get(
TelegramEngine.EngineData.Item.Peer.Peer(id: peerId)
)
|> map { peer -> (interPostInterval: Int32?, messages: [Message]) in
|> map { peer -> (interPostInterval: Int32?, messages: [Message], startDelay: Int32?, betweenDelay: Int32?) in
let fakeAdMessages: [Message] = (0 ..< 10).map { i -> Message in
var attributes: [MessageAttribute] = []
let mappedMessageType: AdMessageAttribute.MessageType = .sponsored
attributes.append(AdMessageAttribute(opaqueId: "fake_ad_\(i)".data(using: .utf8)!, messageType: mappedMessageType, url: "t.me/telegram", buttonText: "VIEW", sponsorInfo: nil, additionalInfo: nil, canReport: false, hasContentMedia: false))
attributes.append(AdMessageAttribute(opaqueId: "fake_ad_\(i)".data(using: .utf8)!, messageType: mappedMessageType, url: "t.me/telegram", buttonText: "VIEW", sponsorInfo: nil, additionalInfo: nil, canReport: false, hasContentMedia: false, minDisplayDuration: nil, maxDisplayDuration: nil))
var messagePeers = SimpleDictionary<PeerId, Peer>()
@ -874,14 +874,14 @@ public final class ChatHistoryListNodeImpl: ListView, ChatHistoryNode, ChatHisto
associatedStories: [:]
)
}
return (10, fakeAdMessages)
return (10, fakeAdMessages, nil, nil)
}
} else {
adMessages = adMessagesContext.state
}
}
} else {
adMessages = .single((nil, []))
adMessages = .single((nil, [], nil, nil))
}
let clientId = Atomic<Int32>(value: nextClientId)
@ -1223,9 +1223,9 @@ public final class ChatHistoryListNodeImpl: ListView, ChatHistoryNode, ChatHisto
self.beginChatHistoryTransitions(resetScrolling: true, switchedToAnotherSource: false)
}
private func beginAdMessageManagement(adMessages: Signal<(interPostInterval: Int32?, messages: [Message]), NoError>) {
private func beginAdMessageManagement(adMessages: Signal<(interPostInterval: Int32?, messages: [Message], startDelay: Int32?, betweenDelay: Int32?), NoError>) {
self.adMessagesDisposable = (adMessages
|> deliverOnMainQueue).startStrict(next: { [weak self] interPostInterval, messages in
|> deliverOnMainQueue).startStrict(next: { [weak self] interPostInterval, messages, _, _ in
guard let self else {
return
}

View File

@ -1479,25 +1479,33 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState
isMigrated = false
}
if data.canEdit && !isPinnedMessages && !isMigrated {
actions.append(.action(ContextMenuActionItem(text: chatPresentationInterfaceState.strings.Conversation_MessageDialogEdit, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Edit"), color: theme.actionSheet.primaryTextColor)
}, action: { c, f in
interfaceInteraction.setupEditMessage(messages[0].id, { transition in
f(.custom(transition))
})
})))
}
var activePoll: TelegramMediaPoll?
var activeTodo: TelegramMediaTodo?
for media in message.media {
if let poll = media as? TelegramMediaPoll, !poll.isClosed, message.id.namespace == Namespaces.Message.Cloud, poll.pollId.namespace == Namespaces.Media.CloudPoll {
if !isPollEffectivelyClosed(message: message, poll: poll) {
activePoll = poll
}
} else if let todo = media as? TelegramMediaTodo {
activeTodo = todo
}
}
if data.canEdit && !isPinnedMessages && !isMigrated {
actions.append(.action(ContextMenuActionItem(text: chatPresentationInterfaceState.strings.Conversation_MessageDialogEdit, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Edit"), color: theme.actionSheet.primaryTextColor)
}, action: { c, f in
if let _ = activeTodo {
interfaceInteraction.editTodoMessage(messages[0].id, false)
f(.dismissWithoutContent)
} else {
interfaceInteraction.setupEditMessage(messages[0].id, { transition in
f(.custom(transition))
})
}
})))
}
if let activePoll = activePoll, let voters = activePoll.results.voters {
var hasSelected = false
for result in voters {
@ -1515,6 +1523,21 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState
}
}
if let activeTodo {
var canAppend = false
if message.author?.id == context.account.peerId || activeTodo.flags.contains(.othersCanAppend) {
canAppend = true
}
if canAppend {
actions.append(.action(ContextMenuActionItem(text: "Add a Task", icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/AddCircle"), color: theme.actionSheet.primaryTextColor)
}, action: { _, f in
interfaceInteraction.editTodoMessage(messages[0].id, true)
f(.dismissWithoutContent)
})))
}
}
var canPin = data.canPin
if case let .replyThread(message) = chatPresentationInterfaceState.chatLocation {
if !message.isForumPost {

View File

@ -194,6 +194,8 @@ final class OverlayAudioPlayerControllerNode: ViewControllerTracingNode, ASGestu
}, playShakeAnimation: {
}, displayQuickShare: { _, _ ,_ in
}, updateChatLocationThread: { _, _ in
}, requestToggleTodoMessageItem: { _, _, _ in
}, displayTodoToggleUnavailable: { _ in
}, automaticMediaDownloadSettings: MediaAutoDownloadSettings.defaultSettings, pollActionState: ChatInterfacePollActionState(), stickerSettings: ChatInterfaceStickerSettings(), presentationContext: ChatPresentationContext(context: context, backgroundNode: nil))
self.dimNode = ASDisplayNode()

View File

@ -2399,6 +2399,8 @@ public final class SharedAccountContextImpl: SharedAccountContext {
}, playShakeAnimation: {
}, displayQuickShare: { _, _ ,_ in
}, updateChatLocationThread: { _, _ in
}, requestToggleTodoMessageItem: { _, _, _ in
}, displayTodoToggleUnavailable: { _ in
}, automaticMediaDownloadSettings: MediaAutoDownloadSettings.defaultSettings,
pollActionState: ChatInterfacePollActionState(), stickerSettings: ChatInterfaceStickerSettings(), presentationContext: ChatPresentationContext(context: context, backgroundNode: backgroundNode as? WallpaperBackgroundNode))
@ -2714,6 +2716,8 @@ public final class SharedAccountContextImpl: SharedAccountContext {
mappedSource = .animatedEmoji
case .paidMessages:
mappedSource = .paidMessages
case .todo:
mappedSource = .paidMessages
case let .auth(price):
mappedSource = .auth(price)
}
@ -2792,6 +2796,8 @@ public final class SharedAccountContextImpl: SharedAccountContext {
mappedSubject = .messageEffects
case .paidMessages:
mappedSubject = .paidMessages
case .todo:
mappedSubject = .todo
case .business:
mappedSubject = .business
buttonText = presentationData.strings.Chat_EmptyStateIntroFooterPremiumActionButton