Various improvements

This commit is contained in:
Ilya Laktyushin 2025-06-13 18:19:01 +02:00
parent a835c0a6f5
commit 88405d99ec
27 changed files with 848 additions and 276 deletions

View File

@ -14428,11 +14428,18 @@ Sorry for the inconvenience.";
"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.TodoMultipleCompleted" = "%1$@ marked %2$@ as done.";
"Notification.TodoMultipleIncompleted" = "%1$@ marked %2$@ as undone.";
"Notification.TodoCompletedYou" = "You marked \"%1$@\" as done.";
"Notification.TodoIncompletedYou" = "You marked \"%1$@\" as not done.";
"Notification.TodoMultipleCompletedYou" = "You marked %1$@ as done.";
"Notification.TodoMultipleIncompletedYou" = "You marked %1$@ as not done.";
"Notification.TodoAddedTasks_1" = "%@ task";
"Notification.TodoAddedTasks_any" = "%@ tasks";
"Notification.TodoAddedTask" = "%1$@ added a new task \"%2$@\" to \"%3$@\".";
"Notification.TodoAddedMultipleTasks" = "%1$@ added %2$@ to \"%3$@\".";
"Notification.TodoAddedTaskYou" = "You added a new task \"%1$@\" to \"%2$@\".";
"Notification.TodoAddedMultipleTasksYou" = "You added %1$@ to \"%2$@\".";

View File

@ -778,9 +778,11 @@ public enum ChatControllerSubject: Equatable {
}
public var quote: Quote?
public var todoTaskId: Int32?
public init(quote: Quote? = nil) {
public init(quote: Quote? = nil, todoTaskId: Int32? = nil) {
self.quote = quote
self.todoTaskId = todoTaskId
}
}

View File

@ -23,7 +23,7 @@ public struct MessageHistoryScrollToSubject: Equatable {
public var todoTaskId: Int32?
public var setupReply: Bool
public init(index: MessageHistoryAnchorIndex, quote: Quote?, todoTaskId: Int32? = nil, setupReply: Bool = false) {
public init(index: MessageHistoryAnchorIndex, quote: Quote? = nil, todoTaskId: Int32? = nil, setupReply: Bool = false) {
self.index = index
self.quote = quote
self.todoTaskId = todoTaskId
@ -44,10 +44,12 @@ public struct MessageHistoryInitialSearchSubject: Equatable {
public var location: ChatHistoryInitialSearchLocation
public var quote: Quote?
public var todoTaskId: Int32?
public init(location: ChatHistoryInitialSearchLocation, quote: Quote?) {
public init(location: ChatHistoryInitialSearchLocation, quote: Quote? = nil, todoTaskId: Int32? = nil) {
self.location = location
self.quote = quote
self.todoTaskId = todoTaskId
}
}

View File

@ -4755,7 +4755,7 @@ final class ChatListSearchListPaneNode: ASDisplayNode, ChatListSearchPaneNode {
strongSelf.interaction.dismissInput()
strongSelf.interaction.present(controller, nil)
} else if case let .messages(chatLocation, _, _) = playlistLocation {
let signal = strongSelf.context.sharedContext.messageFromPreloadedChatHistoryViewForLocation(id: id.messageId, location: ChatHistoryLocationInput(content: .InitialSearch(subject: MessageHistoryInitialSearchSubject(location: .id(id.messageId), quote: nil), count: 60, highlight: true, setupReply: false), id: 0), context: strongSelf.context, chatLocation: chatLocation, subject: nil, chatLocationContextHolder: Atomic<ChatLocationContextHolder?>(value: nil), tag: .tag(EngineMessage.Tags.music))
let signal = strongSelf.context.sharedContext.messageFromPreloadedChatHistoryViewForLocation(id: id.messageId, location: ChatHistoryLocationInput(content: .InitialSearch(subject: MessageHistoryInitialSearchSubject(location: .id(id.messageId)), count: 60, highlight: true, setupReply: false), id: 0), context: strongSelf.context, chatLocation: chatLocation, subject: nil, chatLocationContextHolder: Atomic<ChatLocationContextHolder?>(value: nil), tag: .tag(EngineMessage.Tags.music))
var cancelImpl: (() -> Void)?
let presentationData = strongSelf.context.sharedContext.currentPresentationData.with { $0 }

View File

@ -165,6 +165,9 @@ final class ComposePollScreenComponent: Component {
private var currentEditingTag: AnyObject?
private var reorderRecognizer: ReorderGestureRecognizer?
private var reorderingItem: (id: AnyHashable, snapshotView: UIView, backgroundView: UIView, initialPosition: CGPoint, position: CGPoint)?
override init(frame: CGRect) {
self.scrollView = UIScrollView()
self.scrollView.showsVerticalScrollIndicator = true
@ -181,6 +184,39 @@ final class ComposePollScreenComponent: Component {
self.scrollView.delegate = self
self.addSubview(self.scrollView)
let reorderRecognizer = ReorderGestureRecognizer(
shouldBegin: { [weak self] point in
guard let self, let (id, item) = self.item(at: point) else {
return (allowed: false, requiresLongPress: false, id: nil, item: nil)
}
return (allowed: true, requiresLongPress: false, id: id, item: item)
},
willBegin: { point in
},
began: { [weak self] item in
guard let self else {
return
}
self.setReorderingItem(item: item)
},
ended: { [weak self] in
guard let self else {
return
}
self.setReorderingItem(item: nil)
},
moved: { [weak self] distance in
guard let self else {
return
}
self.moveReorderingItem(distance: distance)
},
isActiveUpdated: { _ in
}
)
self.reorderRecognizer = reorderRecognizer
self.addGestureRecognizer(reorderRecognizer)
}
required init?(coder: NSCoder) {
@ -195,6 +231,114 @@ final class ComposePollScreenComponent: Component {
self.scrollView.setContentOffset(CGPoint(), animated: true)
}
private func item(at point: CGPoint) -> (AnyHashable, ComponentView<Empty>)? {
let localPoint = self.pollOptionsSectionContainer.convert(point, from: self)
for (id, itemView) in self.pollOptionsSectionContainer.itemViews {
if let view = itemView.contents.view as? ListComposePollOptionComponent.View, !view.isRevealed {
let viewFrame = view.convert(view.bounds, to: self.pollOptionsSectionContainer)
let iconFrame = CGRect(origin: CGPoint(x: viewFrame.maxX - viewFrame.height, y: viewFrame.minY), size: CGSize(width: viewFrame.height, height: viewFrame.height))
if iconFrame.contains(localPoint) {
return (id, itemView.contents)
}
}
}
return nil
}
func setReorderingItem(item: AnyHashable?) {
guard let environment = self.environment else {
return
}
var mappedItem: (AnyHashable, ComponentView<Empty>)?
for (id, itemView) in self.pollOptionsSectionContainer.itemViews {
if id == item {
mappedItem = (id, itemView.contents)
break
}
}
if self.reorderingItem?.id != mappedItem?.0 {
if let (id, visibleItem) = mappedItem, let view = visibleItem.view, !view.isHidden, let viewSuperview = view.superview, let snapshotView = view.snapshotView(afterScreenUpdates: false) {
let mappedCenter = viewSuperview.convert(view.center, to: self.scrollView)
let wrapperView = UIView()
wrapperView.alpha = 0.8
wrapperView.frame = CGRect(origin: mappedCenter.offsetBy(dx: -snapshotView.bounds.width / 2.0, dy: -snapshotView.bounds.height / 2.0), size: snapshotView.bounds.size)
let theme = environment.theme.withModalBlocksBackground()
let backgroundView = UIImageView(image: generateReorderingBackgroundImage(backgroundColor: theme.list.itemBlocksBackgroundColor))
backgroundView.frame = wrapperView.bounds.insetBy(dx: -10.0, dy: -10.0)
snapshotView.frame = snapshotView.bounds
wrapperView.addSubview(backgroundView)
wrapperView.addSubview(snapshotView)
backgroundView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
wrapperView.transform = CGAffineTransformMakeScale(1.04, 1.04)
wrapperView.layer.animateScale(from: 1.0, to: 1.04, duration: 0.2)
self.scrollView.addSubview(wrapperView)
self.reorderingItem = (id, wrapperView, backgroundView, mappedCenter, mappedCenter)
self.state?.updated()
} else {
if let reorderingItem = self.reorderingItem {
self.reorderingItem = nil
for (itemId, itemView) in self.pollOptionsSectionContainer.itemViews {
if itemId == reorderingItem.id, let view = itemView.contents.view {
let viewFrame = view.convert(view.bounds, to: self)
let transition = ComponentTransition.spring(duration: 0.3)
transition.setPosition(view: reorderingItem.snapshotView, position: viewFrame.center)
transition.setAlpha(view: reorderingItem.backgroundView, alpha: 0.0, completion: { _ in
reorderingItem.snapshotView.removeFromSuperview()
self.state?.updated()
})
transition.setScale(view: reorderingItem.snapshotView, scale: 1.0)
break
}
}
}
}
}
}
func moveReorderingItem(distance: CGPoint) {
if let (id, snapshotView, backgroundView, initialPosition, _) = self.reorderingItem {
let targetPosition = CGPoint(x: initialPosition.x + distance.x, y: initialPosition.y + distance.y)
self.reorderingItem = (id, snapshotView, backgroundView, initialPosition, targetPosition)
snapshotView.center = targetPosition
for (itemId, itemView) in self.pollOptionsSectionContainer.itemViews {
if itemId == id {
continue
}
if let view = itemView.contents.view {
let viewFrame = view.convert(view.bounds, to: self)
if viewFrame.contains(targetPosition) {
if let targetIndex = self.pollOptions.firstIndex(where: { AnyHashable($0.id) == itemId }), let reorderingItem = self.pollOptions.first(where: { AnyHashable($0.id) == id }) {
self.reorderIfPossible(item: reorderingItem, toIndex: targetIndex)
}
break
}
}
}
}
}
private func reorderIfPossible(item: PollOption, toIndex: Int) {
let targetItem = self.pollOptions[toIndex]
guard targetItem.textInputState.hasText else {
return
}
if let fromIndex = self.pollOptions.firstIndex(where: { $0.id == item.id }) {
self.pollOptions[toIndex] = item
self.pollOptions[fromIndex] = targetItem
HapticFeedback().tap()
self.state?.updated(transition: .spring(duration: 0.4))
}
}
func validatedInput() -> ComposedPoll? {
if self.pollTextInputState.text.length == 0 {
return nil
@ -799,6 +943,7 @@ final class ComposePollScreenComponent: Component {
},
assumeIsEditing: self.inputMediaNodeTargetTag === pollOption.textFieldTag,
characterLimit: component.initialData.maxPollOptionLength,
canReorder: true,
emptyLineHandling: .notAllowed,
returnKeyAction: { [weak self] in
guard let self else {
@ -848,6 +993,13 @@ final class ComposePollScreenComponent: Component {
}
self.state?.updated(transition: .spring(duration: 0.4))
},
deleteAction: { [weak self] in
guard let self else {
return
}
self.pollOptions.removeAll(where: { $0.id == optionId })
self.state?.updated(transition: .spring(duration: 0.4))
},
tag: pollOption.textFieldTag
))))
@ -878,6 +1030,12 @@ final class ComposePollScreenComponent: Component {
size: itemSize,
transition: itemTransition
))
var isReordering = false
if let reorderingItem = self.reorderingItem, itemId == reorderingItem.id {
isReordering = true
}
itemView.contents.view?.isHidden = isReordering
}
for i in 0 ..< self.pollOptions.count {

View File

@ -864,7 +864,7 @@ open class TelegramBaseController: ViewController, KeyShortcutResponder {
strongSelf.displayNode.view.window?.endEditing(true)
strongSelf.present(controller, in: .window(.root))
} else if case let .messages(chatLocation, _, _) = playlistLocation {
let signal = strongSelf.context.sharedContext.messageFromPreloadedChatHistoryViewForLocation(id: id.messageId, location: ChatHistoryLocationInput(content: .InitialSearch(subject: MessageHistoryInitialSearchSubject(location: .id(id.messageId), quote: nil), count: 60, highlight: true, setupReply: false), id: 0), context: strongSelf.context, chatLocation: chatLocation, subject: nil, chatLocationContextHolder: Atomic<ChatLocationContextHolder?>(value: nil), tag: .tag(MessageTags.music))
let signal = strongSelf.context.sharedContext.messageFromPreloadedChatHistoryViewForLocation(id: id.messageId, location: ChatHistoryLocationInput(content: .InitialSearch(subject: MessageHistoryInitialSearchSubject(location: .id(id.messageId)), count: 60, highlight: true, setupReply: false), id: 0), context: strongSelf.context, chatLocation: chatLocation, subject: nil, chatLocationContextHolder: Atomic<ChatLocationContextHolder?>(value: nil), tag: .tag(MessageTags.music))
var cancelImpl: (() -> Void)?
let presentationData = strongSelf.context.sharedContext.currentPresentationData.with { $0 }

View File

@ -1298,7 +1298,7 @@ public func universalServiceMessageString(presentationData: (PresentationTheme,
}
}
var taskTitle = "DELETED"
var taskTitle: String?
if let todo {
if let completedTaskId = completed.first, let completedTask = todo.items.first(where: { $0.id == completedTaskId }) {
taskTitle = completedTask.text
@ -1306,16 +1306,20 @@ public func universalServiceMessageString(presentationData: (PresentationTheme,
taskTitle = incompletedTask.text
}
}
if taskTitle.count > 20 {
taskTitle = taskTitle.prefix(20) + ""
if let taskTitleValue = taskTitle, taskTitleValue.count > 20 {
taskTitle = taskTitleValue.prefix(20) + ""
}
if message.author?.id == accountPeerId {
let resultString: PresentationStrings.FormattedString
if let _ = completed.first {
resultString = strings.Notification_TodoCompletedYou(taskTitle)
if completed.count > 1 || (completed.count == 1 && taskTitle == nil) {
resultString = strings.Notification_TodoMultipleCompletedYou(strings.Notification_TodoTasks(Int32(completed.count)))
} else if let _ = completed.first {
resultString = strings.Notification_TodoCompletedYou(taskTitle ?? "")
} else if incompleted.count > 1 || (incompleted.count == 1 && taskTitle == nil) {
resultString = strings.Notification_TodoMultipleIncompletedYou(strings.Notification_TodoTasks(Int32(incompleted.count)))
} else if let _ = incompleted.first {
resultString = strings.Notification_TodoIncompletedYou(taskTitle)
resultString = strings.Notification_TodoIncompletedYou(taskTitle ?? "")
} else {
resultString = strings.Notification_TodoCompletedYou("")
}
@ -1327,10 +1331,14 @@ public func universalServiceMessageString(presentationData: (PresentationTheme,
attributes[1] = boldAttributes
let resultString: PresentationStrings.FormattedString
if let _ = completed.first {
resultString = strings.Notification_TodoCompleted(peerName, taskTitle)
if completed.count > 1 {
resultString = strings.Notification_TodoMultipleCompleted(peerName, strings.Notification_TodoTasks(Int32(completed.count)))
} else if let _ = completed.first {
resultString = strings.Notification_TodoCompleted(peerName, taskTitle ?? "")
} else if incompleted.count > 1 {
resultString = strings.Notification_TodoMultipleIncompleted(peerName, strings.Notification_TodoTasks(Int32(incompleted.count)))
} else if let _ = incompleted.first {
resultString = strings.Notification_TodoIncompleted(peerName, taskTitle)
resultString = strings.Notification_TodoIncompleted(peerName, taskTitle ?? "")
} else {
resultString = strings.Notification_TodoCompleted(peerName, "")
}
@ -1359,7 +1367,7 @@ public func universalServiceMessageString(presentationData: (PresentationTheme,
}
resultString = strings.Notification_TodoAddedTaskYou(taskTitle, todoTitle)
} else {
resultString = strings.Notification_TodoAddedMultipleTasksYou(strings.Notification_TodoTasks(Int32(tasks.count)), todoTitle)
resultString = strings.Notification_TodoAddedMultipleTasksYou(strings.Notification_TodoAddedTasks(Int32(tasks.count)), todoTitle)
}
attributedString = addAttributesToStringWithRanges(resultString._tuple, body: bodyAttributes, argumentAttributes: [0: boldAttributes, 1: boldAttributes])
} else {
@ -1376,7 +1384,7 @@ public func universalServiceMessageString(presentationData: (PresentationTheme,
}
resultString = strings.Notification_TodoAddedTask(peerName, taskTitle, todoTitle)
} else {
resultString = strings.Notification_TodoAddedMultipleTasks(peerName, strings.Notification_TodoTasks(Int32(tasks.count)), todoTitle)
resultString = strings.Notification_TodoAddedMultipleTasks(peerName, strings.Notification_TodoAddedTasks(Int32(tasks.count)), todoTitle)
}
attributedString = addAttributesToStringWithRanges(resultString._tuple, body: bodyAttributes, argumentAttributes: attributes)
}

View File

@ -5916,8 +5916,8 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI
for contentNode in self.contentNodes {
if let contentNode = contentNode as? ChatMessageTextBubbleContentNode {
contentNode.updateQuoteTextHighlightState(text: nil, offset: nil, color: .clear, animated: true)
} else if let _ = contentNode as? ChatMessageTodoBubbleContentNode {
} else if let contentNode = contentNode as? ChatMessageTodoBubbleContentNode {
contentNode.updateTaskHighlightState(id: nil, color: .clear, animated: true)
}
}
@ -6012,8 +6012,19 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI
var taskFrame: CGRect?
for contentNode in self.contentNodes {
if let contentNode = contentNode as? ChatMessageTodoBubbleContentNode, let localFrame = contentNode.taskItemFrame(id: todoTaskId) {
taskFrame = contentNode.view.convert(localFrame, to: backgroundHighlightNode.view.superview)
if let contentNode = contentNode as? ChatMessageTodoBubbleContentNode {
contentNode.updateTaskHighlightState(id: todoTaskId, color: highlightColor, animated: false)
var sourceFrame = backgroundHighlightNode.view.convert(backgroundHighlightNode.bounds, to: contentNode.view)
if item.message.effectivelyIncoming(item.context.account.peerId) {
sourceFrame.origin.x += 6.0
sourceFrame.size.width -= 6.0
} else {
sourceFrame.size.width -= 6.0
}
if let localFrame = contentNode.animateTaskItemHighlightIn(id: todoTaskId, sourceFrame: sourceFrame, transition: transition) {
taskFrame = contentNode.view.convert(localFrame, to: backgroundHighlightNode.view.superview).insetBy(dx: -3.0, dy: 0.0)
}
break
}
}

View File

@ -1242,10 +1242,73 @@ public class ChatMessageTodoBubbleContentNode: ChatMessageBubbleContentNode {
return nil
}
public func taskItemFrame(id: Int32) -> CGRect? {
private var taskHighlightingNode: LinkHighlightingNode?
public func updateTaskHighlightState(id: Int32?, color: UIColor, animated: Bool) {
var rectsSet: [CGRect] = []
for node in self.optionNodes {
if node.option?.id == id {
return node.frame
rectsSet.append(node.frame.insetBy(dx: 3.0 - UIScreenPixel, dy: 2.0 - UIScreenPixel))
}
}
if !rectsSet.isEmpty {
let rects = rectsSet
let taskHighlightingNode: LinkHighlightingNode
if let current = self.taskHighlightingNode {
taskHighlightingNode = current
} else {
taskHighlightingNode = LinkHighlightingNode(color: color)
taskHighlightingNode.innerRadius = 0.0
taskHighlightingNode.outerRadius = 0.0
self.taskHighlightingNode = taskHighlightingNode
self.insertSubnode(taskHighlightingNode, belowSubnode: self.buttonNode)
}
taskHighlightingNode.frame = self.bounds
taskHighlightingNode.updateRects(rects)
} else {
if let taskHighlightingNode = self.taskHighlightingNode {
self.taskHighlightingNode = nil
if animated {
taskHighlightingNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak taskHighlightingNode] _ in
taskHighlightingNode?.removeFromSupernode()
})
} else {
taskHighlightingNode.removeFromSupernode()
}
}
}
}
public func animateTaskItemHighlightIn(id: Int32, sourceFrame: CGRect, transition: ContainedViewLayoutTransition) -> CGRect? {
if let taskHighlightingNode = self.taskHighlightingNode {
var currentRect = CGRect()
for rect in taskHighlightingNode.rects {
if currentRect.isEmpty {
currentRect = rect
} else {
currentRect = currentRect.union(rect)
}
}
if !currentRect.isEmpty {
currentRect = currentRect.insetBy(dx: -taskHighlightingNode.inset, dy: -taskHighlightingNode.inset)
let innerRect = currentRect.offsetBy(dx: taskHighlightingNode.frame.minX, dy: taskHighlightingNode.frame.minY)
taskHighlightingNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.1, delay: 0.04)
let fromScale = CGPoint(x: sourceFrame.width / innerRect.width, y: sourceFrame.height / innerRect.height)
var fromTransform = CATransform3DIdentity
let fromOffset = CGPoint(x: sourceFrame.midX - innerRect.midX, y: sourceFrame.midY - innerRect.midY)
fromTransform = CATransform3DTranslate(fromTransform, fromOffset.x, fromOffset.y, 0.0)
fromTransform = CATransform3DTranslate(fromTransform, -taskHighlightingNode.bounds.width * 0.5 + currentRect.midX, -taskHighlightingNode.bounds.height * 0.5 + currentRect.midY, 0.0)
fromTransform = CATransform3DScale(fromTransform, fromScale.x, fromScale.y, 1.0)
fromTransform = CATransform3DTranslate(fromTransform, taskHighlightingNode.bounds.width * 0.5 - currentRect.midX, taskHighlightingNode.bounds.height * 0.5 - currentRect.midY, 0.0)
taskHighlightingNode.transform = fromTransform
transition.updateTransform(node: taskHighlightingNode, transform: CGAffineTransformIdentity)
return currentRect.offsetBy(dx: taskHighlightingNode.frame.minX, dy: taskHighlightingNode.frame.minY)
}
}
return nil

View File

@ -182,7 +182,7 @@ final class ComposeTodoScreenComponent: Component {
private func item(at point: CGPoint) -> (AnyHashable, ComponentView<Empty>)? {
let localPoint = self.todoItemsSectionContainer.convert(point, from: self)
for (id, itemView) in self.todoItemsSectionContainer.itemViews {
if let view = itemView.contents.view {
if let view = itemView.contents.view as? ListComposePollOptionComponent.View, !view.isRevealed {
let viewFrame = view.convert(view.bounds, to: self.todoItemsSectionContainer)
let iconFrame = CGRect(origin: CGPoint(x: viewFrame.maxX - viewFrame.height, y: viewFrame.minY), size: CGSize(width: viewFrame.height, height: viewFrame.height))
if iconFrame.contains(localPoint) {
@ -914,6 +914,13 @@ final class ComposeTodoScreenComponent: Component {
}
self.state?.updated(transition: .spring(duration: 0.4))
},
deleteAction: isEnabled ? { [weak self] in
guard let self else {
return
}
self.todoItems.removeAll(where: { $0.id == optionId })
self.state?.updated(transition: .spring(duration: 0.4))
} : nil,
tag: todoItem.textFieldTag
))))
@ -1692,212 +1699,3 @@ public class ComposeTodoScreen: ViewControllerComponentContainer, AttachmentCont
return true
}
}
private final class ReorderGestureRecognizer: UIGestureRecognizer {
private let shouldBegin: (CGPoint) -> (allowed: Bool, requiresLongPress: Bool, id: AnyHashable?, item: ComponentView<Empty>?)
private let willBegin: (CGPoint) -> Void
private let began: (AnyHashable) -> Void
private let ended: () -> Void
private let moved: (CGPoint) -> Void
private let isActiveUpdated: (Bool) -> Void
private var initialLocation: CGPoint?
private var longTapTimer: SwiftSignalKit.Timer?
private var longPressTimer: SwiftSignalKit.Timer?
private var id: AnyHashable?
private var itemView: ComponentView<Empty>?
public init(shouldBegin: @escaping (CGPoint) -> (allowed: Bool, requiresLongPress: Bool, id: AnyHashable?, item: ComponentView<Empty>?), willBegin: @escaping (CGPoint) -> Void, began: @escaping (AnyHashable) -> Void, ended: @escaping () -> Void, moved: @escaping (CGPoint) -> Void, isActiveUpdated: @escaping (Bool) -> Void) {
self.shouldBegin = shouldBegin
self.willBegin = willBegin
self.began = began
self.ended = ended
self.moved = moved
self.isActiveUpdated = isActiveUpdated
super.init(target: nil, action: nil)
}
deinit {
self.longTapTimer?.invalidate()
self.longPressTimer?.invalidate()
}
private func startLongTapTimer() {
self.longTapTimer?.invalidate()
let longTapTimer = SwiftSignalKit.Timer(timeout: 0.25, repeat: false, completion: { [weak self] in
self?.longTapTimerFired()
}, queue: Queue.mainQueue())
self.longTapTimer = longTapTimer
longTapTimer.start()
}
private func stopLongTapTimer() {
self.itemView = nil
self.longTapTimer?.invalidate()
self.longTapTimer = nil
}
private func startLongPressTimer() {
self.longPressTimer?.invalidate()
let longPressTimer = SwiftSignalKit.Timer(timeout: 0.6, repeat: false, completion: { [weak self] in
self?.longPressTimerFired()
}, queue: Queue.mainQueue())
self.longPressTimer = longPressTimer
longPressTimer.start()
}
private func stopLongPressTimer() {
self.itemView = nil
self.longPressTimer?.invalidate()
self.longPressTimer = nil
}
override public func reset() {
super.reset()
self.itemView = nil
self.stopLongTapTimer()
self.stopLongPressTimer()
self.initialLocation = nil
self.isActiveUpdated(false)
}
private func longTapTimerFired() {
guard let location = self.initialLocation else {
return
}
self.longTapTimer?.invalidate()
self.longTapTimer = nil
self.willBegin(location)
}
private func longPressTimerFired() {
guard let _ = self.initialLocation else {
return
}
self.isActiveUpdated(true)
self.state = .began
self.longPressTimer?.invalidate()
self.longPressTimer = nil
self.longTapTimer?.invalidate()
self.longTapTimer = nil
if let id = self.id {
self.began(id)
}
self.isActiveUpdated(true)
}
override public func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
super.touchesBegan(touches, with: event)
if self.numberOfTouches > 1 {
self.isActiveUpdated(false)
self.state = .failed
self.ended()
return
}
if self.state == .possible {
if let location = touches.first?.location(in: self.view) {
let (allowed, requiresLongPress, id, itemView) = self.shouldBegin(location)
if allowed {
self.isActiveUpdated(true)
self.id = id
self.itemView = itemView
self.initialLocation = location
if requiresLongPress {
self.startLongTapTimer()
self.startLongPressTimer()
} else {
self.state = .began
if let id = self.id {
self.began(id)
}
}
} else {
self.isActiveUpdated(false)
self.state = .failed
}
} else {
self.isActiveUpdated(false)
self.state = .failed
}
}
}
override public func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent) {
super.touchesEnded(touches, with: event)
self.initialLocation = nil
self.stopLongTapTimer()
if self.longPressTimer != nil {
self.stopLongPressTimer()
self.isActiveUpdated(false)
self.state = .failed
}
if self.state == .began || self.state == .changed {
self.isActiveUpdated(false)
self.ended()
self.state = .failed
}
}
override public func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent) {
super.touchesCancelled(touches, with: event)
self.initialLocation = nil
self.stopLongTapTimer()
if self.longPressTimer != nil {
self.isActiveUpdated(false)
self.stopLongPressTimer()
self.state = .failed
}
if self.state == .began || self.state == .changed {
self.isActiveUpdated(false)
self.ended()
self.state = .failed
}
}
override public func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent) {
super.touchesMoved(touches, with: event)
if (self.state == .began || self.state == .changed), let initialLocation = self.initialLocation, let location = touches.first?.location(in: self.view) {
self.state = .changed
let offset = CGPoint(x: location.x - initialLocation.x, y: location.y - initialLocation.y)
self.moved(offset)
} else if let touch = touches.first, let initialTapLocation = self.initialLocation, self.longPressTimer != nil {
let touchLocation = touch.location(in: self.view)
let dX = touchLocation.x - initialTapLocation.x
let dY = touchLocation.y - initialTapLocation.y
if dX * dX + dY * dY > 3.0 * 3.0 {
self.stopLongTapTimer()
self.stopLongPressTimer()
self.initialLocation = nil
self.isActiveUpdated(false)
self.state = .failed
}
}
}
}
private func generateReorderingBackgroundImage(backgroundColor: UIColor) -> UIImage? {
return generateImage(CGSize(width: 64.0, height: 64.0), contextGenerator: { size, context in
context.clear(CGRect(origin: .zero, size: size))
context.addPath(UIBezierPath(roundedRect: CGRect(x: 10, y: 10, width: 44, height: 44), cornerRadius: 10).cgPath)
context.setShadow(offset: CGSize(width: 0.0, height: 0.0), blur: 24.0, color: UIColor(white: 0.0, alpha: 0.35).cgColor)
context.setFillColor(backgroundColor.cgColor)
context.fillPath()
})?.stretchableImage(withLeftCapWidth: 32, topCapHeight: 32)
}

View File

@ -86,6 +86,7 @@ public final class ListComposePollOptionComponent: Component {
public let inputMode: InputMode?
public let alwaysDisplayInputModeSelector: Bool
public let toggleInputMode: (() -> Void)?
public let deleteAction: (() -> Void)?
public let tag: AnyObject?
public init(
@ -107,6 +108,7 @@ public final class ListComposePollOptionComponent: Component {
inputMode: InputMode?,
alwaysDisplayInputModeSelector: Bool = false,
toggleInputMode: (() -> Void)?,
deleteAction: (() -> Void)? = nil,
tag: AnyObject? = nil
) {
self.externalState = externalState
@ -127,6 +129,7 @@ public final class ListComposePollOptionComponent: Component {
self.inputMode = inputMode
self.alwaysDisplayInputModeSelector = alwaysDisplayInputModeSelector
self.toggleInputMode = toggleInputMode
self.deleteAction = deleteAction
self.tag = tag
}
@ -176,7 +179,9 @@ public final class ListComposePollOptionComponent: Component {
if lhs.alwaysDisplayInputModeSelector != rhs.alwaysDisplayInputModeSelector {
return false
}
if (lhs.deleteAction == nil) != (rhs.deleteAction == nil) {
return false
}
return true
}
@ -253,7 +258,115 @@ public final class ListComposePollOptionComponent: Component {
}
}
public final class View: UIView, ListSectionComponent.ChildView, ComponentTaggedView {
private final class DeleteRevealView: UIView {
private let backgroundView: UIView
private let _title: String
private let title = ComponentView<Empty>()
private var revealOffset: CGFloat = 0.0
var currentSize = CGSize()
var tapped: (Bool) -> Void = { _ in }
init(title: String, color: UIColor) {
self._title = title
self.backgroundView = UIView()
self.backgroundView.backgroundColor = color
self.backgroundView.isUserInteractionEnabled = false
super.init(frame: .zero)
self.clipsToBounds = true
self.addSubview(self.backgroundView)
let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(self.handleTap(_:)))
self.addGestureRecognizer(tapRecognizer)
}
required public init?(coder: NSCoder) {
preconditionFailure()
}
@objc private func handleTap(_ gestureRecignizer: UITapGestureRecognizer) {
let location = gestureRecignizer.location(in: self)
if self.backgroundView.frame.contains(location) {
self.tapped(true)
} else {
self.tapped(false)
}
}
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
if abs(self.revealOffset) < .ulpOfOne {
return false
}
return super.point(inside: point, with: event)
}
func updateLayout(availableSize: CGSize, revealOffset: CGFloat, transition: ComponentTransition) -> CGSize {
let previousRevealOffset = self.revealOffset
self.revealOffset = revealOffset
let titleSize = self.title.update(
transition: .immediate,
component: AnyComponent(
MultilineTextComponent(
text: .plain(
NSAttributedString(
string: self._title,
font: Font.regular(17.0),
textColor: .white
)
)
)
),
environment: {},
containerSize: availableSize
)
let size = CGSize(width: max(74.0, titleSize.width + 20.0), height: availableSize.height)
let previousRevealFactor = abs(previousRevealOffset) / size.width
let revealFactor = abs(revealOffset) / size.width
let backgroundWidth = size.width * max(1.0, abs(revealFactor))
let previousIsExtended = previousRevealFactor >= 2.0
let isExtended = revealFactor >= 2.0
var titleTransition = transition
if isExtended != previousIsExtended {
titleTransition = .spring(duration: 0.3)
}
if let titleView = self.title.view {
if titleView.superview == nil {
self.backgroundView.addSubview(titleView)
}
let titleFrame = CGRect(
origin: CGPoint(
x: revealFactor > 2.0 ? 10.0 : max(10.0, backgroundWidth - titleSize.width - 10.0),
y: floor((size.height - titleSize.height) / 2.0)
),
size: titleSize
)
if titleTransition.animation.isImmediate && titleView.layer.animation(forKey: "position") != nil {
} else {
titleTransition.setFrame(view: titleView, frame: titleFrame)
}
}
let backgroundFrame = CGRect(origin: CGPoint(x: availableSize.width + revealOffset, y: 0.0), size: CGSize(width: backgroundWidth, height: size.height))
transition.setFrame(view: self.backgroundView, frame: backgroundFrame)
self.currentSize = size
return size
}
}
public final class View: UIView, ListSectionComponent.ChildView, ComponentTaggedView, UIGestureRecognizerDelegate {
private let textField = ComponentView<Empty>()
private var modeSelector: ComponentView<Empty>?
@ -261,6 +374,12 @@ public final class ListComposePollOptionComponent: Component {
private var checkView: CheckView?
private var deleteRevealView: DeleteRevealView?
private var revealOffset: CGFloat = 0.0
public private(set) var isRevealed: Bool = false
private var recognizer: RevealOptionsGestureRecognizer?
private var customPlaceholder: ComponentView<Empty>?
private var component: ListComposePollOptionComponent?
@ -293,7 +412,7 @@ public final class ListComposePollOptionComponent: Component {
public var customUpdateIsHighlighted: ((Bool) -> Void)?
public private(set) var separatorInset: CGFloat = 0.0
public override init(frame: CGRect) {
super.init(frame: CGRect())
}
@ -330,6 +449,97 @@ public final class ListComposePollOptionComponent: Component {
}
}
override public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
if let recognizer = self.recognizer, gestureRecognizer == self.recognizer, recognizer.numberOfTouches == 0 {
let translation = recognizer.velocity(in: recognizer.view)
if abs(translation.y) > 4.0 && abs(translation.y) > abs(translation.x) * 2.5 {
return false
}
}
if gestureRecognizer == self.recognizer, let externalState = self.component?.externalState, !externalState.hasText {
return false
}
return true
}
@objc private func handlePan(_ gestureRecognizer: UIPanGestureRecognizer) {
guard let component = self.component, component.deleteAction != nil else {
return
}
let translation = gestureRecognizer.translation(in: self)
let velocity = gestureRecognizer.velocity(in: self)
let revealWidth: CGFloat = self.deleteRevealView?.currentSize.width ?? 74.0
switch gestureRecognizer.state {
case .began:
self.window?.endEditing(true)
if self.isRevealed {
let location = gestureRecognizer.location(in: self)
if location.x > self.bounds.width - revealWidth {
gestureRecognizer.isEnabled = false
gestureRecognizer.isEnabled = true
return
}
}
case .changed:
var offset = self.revealOffset + translation.x
offset = max(-revealWidth * 6.0, min(0, offset))
self.revealOffset = offset
self.state?.updated()
gestureRecognizer.setTranslation(CGPoint(), in: self)
case .ended, .cancelled:
var shouldReveal = false
if abs(velocity.x) >= 100.0 {
shouldReveal = velocity.x < 0
} else {
if self.revealOffset.isZero && self.revealOffset < 0 {
shouldReveal = self.revealOffset < -revealWidth * 0.5
} else if self.isRevealed {
shouldReveal = self.revealOffset < -revealWidth * 0.3
} else {
shouldReveal = self.revealOffset < -revealWidth * 0.5
}
}
let isExtendedSwipe = self.revealOffset < -revealWidth * 2.0
if isExtendedSwipe && shouldReveal {
component.deleteAction?()
self.isRevealed = false
let transition = ComponentTransition(animation: .curve(duration: 0.3, curve: .spring))
self.revealOffset = 0.0
self.state?.updated(transition: transition)
} else {
let targetOffset: CGFloat = shouldReveal ? -revealWidth : 0.0
self.isRevealed = shouldReveal
let transition = ComponentTransition(animation: .curve(duration: 0.3, curve: .spring))
self.revealOffset = targetOffset
self.state?.updated(transition: transition)
}
default:
break
}
}
@objc private func handleTap(_ gestureRecognizer: UITapGestureRecognizer) {
guard self.isRevealed else {
return
}
let location = gestureRecognizer.location(in: self)
if location.x >= self.bounds.width + self.revealOffset {
self.component?.deleteAction?()
}
}
func update(component: ListComposePollOptionComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
self.isUpdating = true
defer {
@ -405,7 +615,7 @@ public final class ListComposePollOptionComponent: Component {
)
let size = CGSize(width: availableSize.width, height: textFieldSize.height - 1.0)
let textFieldFrame = CGRect(origin: CGPoint(x: leftInset - 16.0, y: 0.0), size: textFieldSize)
let textFieldFrame = CGRect(origin: CGPoint(x: leftInset - 16.0 + self.revealOffset, y: 0.0), size: textFieldSize)
if let textFieldView = self.textField.view {
if textFieldView.superview == nil {
@ -437,7 +647,7 @@ public final class ListComposePollOptionComponent: Component {
}
}
let checkSize = CGSize(width: 22.0, height: 22.0)
let checkFrame = CGRect(origin: CGPoint(x: floor((leftInset - checkSize.width) * 0.5), y: floor((size.height - checkSize.height) * 0.5)), size: checkSize)
let checkFrame = CGRect(origin: CGPoint(x: floor((leftInset - checkSize.width) * 0.5) + self.revealOffset, y: floor((size.height - checkSize.height) * 0.5)), size: checkSize)
if animateIn {
checkView.frame = CGRect(origin: CGPoint(x: -checkSize.width, y: self.bounds.height == 0.0 ? checkFrame.minY : floor((self.bounds.height - checkSize.height) * 0.5)), size: checkFrame.size)
@ -475,7 +685,7 @@ public final class ListComposePollOptionComponent: Component {
reorderIconSize = icon.size
}
let reorderIconFrame = CGRect(origin: CGPoint(x: size.width - 14.0 - reorderIconSize.width, y: floor((size.height - reorderIconSize.height) * 0.5)), size: reorderIconSize)
let reorderIconFrame = CGRect(origin: CGPoint(x: size.width - 14.0 - reorderIconSize.width + self.revealOffset, y: floor((size.height - reorderIconSize.height) * 0.5)), size: reorderIconSize)
reorderIconTransition.setPosition(view: reorderIconView, position: reorderIconFrame.center)
reorderIconTransition.setBounds(view: reorderIconView, bounds: CGRect(origin: CGPoint(), size: reorderIconFrame.size))
@ -539,7 +749,7 @@ public final class ListComposePollOptionComponent: Component {
environment: {},
containerSize: modeSelectorSize
)
let modeSelectorFrame = CGRect(origin: CGPoint(x: size.width - rightIconsInset - 4.0 - modeSelectorSize.width, y: floor((size.height - modeSelectorSize.height) * 0.5)), size: modeSelectorSize)
let modeSelectorFrame = CGRect(origin: CGPoint(x: size.width - rightIconsInset - 4.0 - modeSelectorSize.width + self.revealOffset, y: floor((size.height - modeSelectorSize.height) * 0.5)), size: modeSelectorSize)
if let modeSelectorView = modeSelector.view as? PlainButtonComponent.View {
let alphaTransition: ComponentTransition = .easeInOut(duration: 0.2)
@ -580,6 +790,51 @@ public final class ListComposePollOptionComponent: Component {
}
}
if let deleteAction = component.deleteAction {
if self.deleteRevealView == nil {
let deleteRevealView = DeleteRevealView(title: component.strings.Common_Delete, color: component.theme.list.itemDisclosureActions.destructive.fillColor)
deleteRevealView.tapped = { [weak self] action in
guard let self else {
return
}
if action {
deleteAction()
} else {
self.revealOffset = 0.0
self.isRevealed = false
self.state?.updated(transition: .spring(duration: 0.3))
}
}
self.deleteRevealView = deleteRevealView
self.addSubview(deleteRevealView)
}
if self.recognizer == nil {
let recognizer = RevealOptionsGestureRecognizer(target: self, action: #selector(self.handlePan(_:)))
recognizer.delegate = self
self.addGestureRecognizer(recognizer)
self.recognizer = recognizer
}
} else {
if let deleteRevealView = self.deleteRevealView {
self.deleteRevealView = nil
deleteRevealView.removeFromSuperview()
}
if let panGestureRecognizer = self.recognizer {
self.recognizer = nil
self.removeGestureRecognizer(panGestureRecognizer)
}
self.isRevealed = false
self.revealOffset = 0.0
}
if let deleteRevealView = self.deleteRevealView {
let _ = deleteRevealView.updateLayout(availableSize: size, revealOffset: self.revealOffset, transition: transition)
deleteRevealView.frame = CGRect(origin: .zero, size: size)
}
self.separatorInset = leftInset
return size
@ -646,3 +901,58 @@ public final class ListComposePollOptionComponent: Component {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}
final class RevealOptionsGestureRecognizer: UIPanGestureRecognizer {
var validatedGesture = false
var firstLocation: CGPoint = CGPoint()
var allowAnyDirection = false
var lastVelocity: CGPoint = CGPoint()
override public init(target: Any?, action: Selector?) {
super.init(target: target, action: action)
if #available(iOS 13.4, *) {
self.allowedScrollTypesMask = .continuous
}
self.maximumNumberOfTouches = 1
}
override public func reset() {
super.reset()
self.validatedGesture = false
}
func becomeCancelled() {
self.state = .cancelled
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
super.touchesBegan(touches, with: event)
let touch = touches.first!
self.firstLocation = touch.location(in: self.view)
}
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent) {
let location = touches.first!.location(in: self.view)
let translation = CGPoint(x: location.x - self.firstLocation.x, y: location.y - self.firstLocation.y)
if !self.validatedGesture {
if !self.allowAnyDirection && translation.x > 0.0 {
self.state = .failed
} else if abs(translation.y) > 4.0 && abs(translation.y) > abs(translation.x) * 2.5 {
self.state = .failed
} else if abs(translation.x) > 4.0 && abs(translation.y) * 2.5 < abs(translation.x) {
self.validatedGesture = true
}
}
if self.validatedGesture {
self.lastVelocity = self.velocity(in: self.view)
super.touchesMoved(touches, with: event)
}
}
}

View File

@ -0,0 +1,214 @@
import Foundation
import UIKit
import Display
import ComponentFlow
import SwiftSignalKit
public final class ReorderGestureRecognizer: UIGestureRecognizer {
private let shouldBegin: (CGPoint) -> (allowed: Bool, requiresLongPress: Bool, id: AnyHashable?, item: ComponentView<Empty>?)
private let willBegin: (CGPoint) -> Void
private let began: (AnyHashable) -> Void
private let ended: () -> Void
private let moved: (CGPoint) -> Void
private let isActiveUpdated: (Bool) -> Void
private var initialLocation: CGPoint?
private var longTapTimer: SwiftSignalKit.Timer?
private var longPressTimer: SwiftSignalKit.Timer?
private var id: AnyHashable?
private var itemView: ComponentView<Empty>?
public init(shouldBegin: @escaping (CGPoint) -> (allowed: Bool, requiresLongPress: Bool, id: AnyHashable?, item: ComponentView<Empty>?), willBegin: @escaping (CGPoint) -> Void, began: @escaping (AnyHashable) -> Void, ended: @escaping () -> Void, moved: @escaping (CGPoint) -> Void, isActiveUpdated: @escaping (Bool) -> Void) {
self.shouldBegin = shouldBegin
self.willBegin = willBegin
self.began = began
self.ended = ended
self.moved = moved
self.isActiveUpdated = isActiveUpdated
super.init(target: nil, action: nil)
}
deinit {
self.longTapTimer?.invalidate()
self.longPressTimer?.invalidate()
}
private func startLongTapTimer() {
self.longTapTimer?.invalidate()
let longTapTimer = SwiftSignalKit.Timer(timeout: 0.25, repeat: false, completion: { [weak self] in
self?.longTapTimerFired()
}, queue: Queue.mainQueue())
self.longTapTimer = longTapTimer
longTapTimer.start()
}
private func stopLongTapTimer() {
self.itemView = nil
self.longTapTimer?.invalidate()
self.longTapTimer = nil
}
private func startLongPressTimer() {
self.longPressTimer?.invalidate()
let longPressTimer = SwiftSignalKit.Timer(timeout: 0.6, repeat: false, completion: { [weak self] in
self?.longPressTimerFired()
}, queue: Queue.mainQueue())
self.longPressTimer = longPressTimer
longPressTimer.start()
}
private func stopLongPressTimer() {
self.itemView = nil
self.longPressTimer?.invalidate()
self.longPressTimer = nil
}
override public func reset() {
super.reset()
self.itemView = nil
self.stopLongTapTimer()
self.stopLongPressTimer()
self.initialLocation = nil
self.isActiveUpdated(false)
}
private func longTapTimerFired() {
guard let location = self.initialLocation else {
return
}
self.longTapTimer?.invalidate()
self.longTapTimer = nil
self.willBegin(location)
}
private func longPressTimerFired() {
guard let _ = self.initialLocation else {
return
}
self.isActiveUpdated(true)
self.state = .began
self.longPressTimer?.invalidate()
self.longPressTimer = nil
self.longTapTimer?.invalidate()
self.longTapTimer = nil
if let id = self.id {
self.began(id)
}
self.isActiveUpdated(true)
}
override public func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
super.touchesBegan(touches, with: event)
if self.numberOfTouches > 1 {
self.isActiveUpdated(false)
self.state = .failed
self.ended()
return
}
if self.state == .possible {
if let location = touches.first?.location(in: self.view) {
let (allowed, requiresLongPress, id, itemView) = self.shouldBegin(location)
if allowed {
self.isActiveUpdated(true)
self.id = id
self.itemView = itemView
self.initialLocation = location
if requiresLongPress {
self.startLongTapTimer()
self.startLongPressTimer()
} else {
self.state = .began
if let id = self.id {
self.began(id)
}
}
} else {
self.isActiveUpdated(false)
self.state = .failed
}
} else {
self.isActiveUpdated(false)
self.state = .failed
}
}
}
override public func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent) {
super.touchesEnded(touches, with: event)
self.initialLocation = nil
self.stopLongTapTimer()
if self.longPressTimer != nil {
self.stopLongPressTimer()
self.isActiveUpdated(false)
self.state = .failed
}
if self.state == .began || self.state == .changed {
self.isActiveUpdated(false)
self.ended()
self.state = .failed
}
}
override public func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent) {
super.touchesCancelled(touches, with: event)
self.initialLocation = nil
self.stopLongTapTimer()
if self.longPressTimer != nil {
self.isActiveUpdated(false)
self.stopLongPressTimer()
self.state = .failed
}
if self.state == .began || self.state == .changed {
self.isActiveUpdated(false)
self.ended()
self.state = .failed
}
}
override public func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent) {
super.touchesMoved(touches, with: event)
if (self.state == .began || self.state == .changed), let initialLocation = self.initialLocation, let location = touches.first?.location(in: self.view) {
self.state = .changed
let offset = CGPoint(x: location.x - initialLocation.x, y: location.y - initialLocation.y)
self.moved(offset)
} else if let touch = touches.first, let initialTapLocation = self.initialLocation, self.longPressTimer != nil {
let touchLocation = touch.location(in: self.view)
let dX = touchLocation.x - initialTapLocation.x
let dY = touchLocation.y - initialTapLocation.y
if dX * dX + dY * dY > 3.0 * 3.0 {
self.stopLongTapTimer()
self.stopLongPressTimer()
self.initialLocation = nil
self.isActiveUpdated(false)
self.state = .failed
}
}
}
}
public func generateReorderingBackgroundImage(backgroundColor: UIColor) -> UIImage? {
return generateImage(CGSize(width: 64.0, height: 64.0), contextGenerator: { size, context in
context.clear(CGRect(origin: .zero, size: size))
context.addPath(UIBezierPath(roundedRect: CGRect(x: 10, y: 10, width: 44, height: 44), cornerRadius: 10).cgPath)
context.setShadow(offset: CGSize(width: 0.0, height: 0.0), blur: 24.0, color: UIColor(white: 0.0, alpha: 0.35).cgColor)
context.setFillColor(backgroundColor.cgColor)
context.fillPath()
})?.stretchableImage(withLeftCapWidth: 32, topCapHeight: 32)
}

View File

@ -368,7 +368,7 @@ final class PeerInfoListPaneNode: ASDisplayNode, PeerInfoPaneNode {
}
if let id = state.id as? PeerMessagesMediaPlaylistItemId, let playlistLocation = strongSelf.playlistLocation as? PeerMessagesPlaylistLocation, case let .messages(chatLocation, _, _) = playlistLocation {
if type == .music {
let signal = strongSelf.context.sharedContext.messageFromPreloadedChatHistoryViewForLocation(id: id.messageId, location: ChatHistoryLocationInput(content: .InitialSearch(subject: MessageHistoryInitialSearchSubject(location: .id(id.messageId), quote: nil), count: 60, highlight: true, setupReply: false), id: 0), context: strongSelf.context, chatLocation: .peer(id: id.messageId.peerId), subject: nil, chatLocationContextHolder: Atomic<ChatLocationContextHolder?>(value: nil), tag: .tag(MessageTags.music))
let signal = strongSelf.context.sharedContext.messageFromPreloadedChatHistoryViewForLocation(id: id.messageId, location: ChatHistoryLocationInput(content: .InitialSearch(subject: MessageHistoryInitialSearchSubject(location: .id(id.messageId)), count: 60, highlight: true, setupReply: false), id: 0), context: strongSelf.context, chatLocation: .peer(id: id.messageId.peerId), subject: nil, chatLocationContextHolder: Atomic<ChatLocationContextHolder?>(value: nil), tag: .tag(MessageTags.music))
var cancelImpl: (() -> Void)?
let presentationData = strongSelf.context.sharedContext.currentPresentationData.with { $0 }

View File

@ -110,7 +110,6 @@ import ChatMessageAnimatedStickerItemNode
import ChatMessageBubbleItemNode
import ChatNavigationButton
import WebsiteType
import ChatQrCodeScreen
import PeerInfoScreen
import MediaEditorScreen
import WallpaperGalleryScreen

View File

@ -112,7 +112,6 @@ import ChatMessageAnimatedStickerItemNode
import ChatMessageBubbleItemNode
import ChatNavigationButton
import WebsiteType
import ChatQrCodeScreen
import PeerInfoScreen
import MediaEditorScreen
import WallpaperGalleryScreen

View File

@ -204,7 +204,7 @@ extension ChatControllerImpl {
let subject: ChatControllerSubject = .message(id: .id(messageId), highlight: ChatControllerSubject.MessageHighlight(quote: quote), timecode: nil, setupReply: false)
historyView = preloadedChatHistoryViewForLocation(ChatHistoryLocationInput(content: .InitialSearch(subject: MessageHistoryInitialSearchSubject(location: searchLocation, quote: nil), count: 50, highlight: true, setupReply: false), id: 0), context: self.context, chatLocation: preloadChatLocation, subject: subject, chatLocationContextHolder: Atomic<ChatLocationContextHolder?>(value: nil), fixedCombinedReadStates: nil, tag: nil, additionalData: [])
historyView = preloadedChatHistoryViewForLocation(ChatHistoryLocationInput(content: .InitialSearch(subject: MessageHistoryInitialSearchSubject(location: searchLocation), count: 50, highlight: true, setupReply: false), id: 0), context: self.context, chatLocation: preloadChatLocation, subject: subject, chatLocationContextHolder: Atomic<ChatLocationContextHolder?>(value: nil), fixedCombinedReadStates: nil, tag: nil, additionalData: [])
var signal: Signal<(MessageIndex?, Bool), NoError>
signal = historyView
@ -399,7 +399,7 @@ extension ChatControllerImpl {
}
}
var historyView: Signal<ChatHistoryViewUpdate, NoError>
historyView = preloadedChatHistoryViewForLocation(ChatHistoryLocationInput(content: .InitialSearch(subject: MessageHistoryInitialSearchSubject(location: searchLocation, quote: nil), count: 50, highlight: true, setupReply: setupReply), id: 0), context: self.context, chatLocation: self.chatLocation, subject: self.subject, chatLocationContextHolder: self.chatLocationContextHolder, fixedCombinedReadStates: nil, tag: nil, additionalData: [])
historyView = preloadedChatHistoryViewForLocation(ChatHistoryLocationInput(content: .InitialSearch(subject: MessageHistoryInitialSearchSubject(location: searchLocation), count: 50, highlight: true, setupReply: setupReply), id: 0), context: self.context, chatLocation: self.chatLocation, subject: self.subject, chatLocationContextHolder: self.chatLocationContextHolder, fixedCombinedReadStates: nil, tag: nil, additionalData: [])
var signal: Signal<(MessageIndex?, Bool), NoError>
signal = historyView
@ -512,13 +512,15 @@ extension ChatControllerImpl {
self.loadingMessage.set(.single(statusSubject) |> delay(0.1, queue: .mainQueue()))
var quote: ChatControllerSubject.MessageHighlight.Quote?
var todoTaskId: Int32?
var setupReply = false
if case let .id(_, params) = messageLocation {
quote = params.quote.flatMap { quote in ChatControllerSubject.MessageHighlight.Quote(string: quote.string, offset: quote.offset) }
todoTaskId = params.todoTaskId
setupReply = params.setupReply
}
let historyView = preloadedChatHistoryViewForLocation(ChatHistoryLocationInput(content: .InitialSearch(subject: MessageHistoryInitialSearchSubject(location: searchLocation, quote: quote.flatMap { quote in MessageHistoryInitialSearchSubject.Quote(string: quote.string, offset: quote.offset) }), count: 50, highlight: true, setupReply: setupReply), id: 0), context: self.context, chatLocation: self.chatLocation, subject: self.subject, chatLocationContextHolder: self.chatLocationContextHolder, fixedCombinedReadStates: nil, tag: nil, additionalData: [])
let historyView = preloadedChatHistoryViewForLocation(ChatHistoryLocationInput(content: .InitialSearch(subject: MessageHistoryInitialSearchSubject(location: searchLocation, quote: quote.flatMap { quote in MessageHistoryInitialSearchSubject.Quote(string: quote.string, offset: quote.offset) }, todoTaskId: todoTaskId), count: 50, highlight: true, setupReply: setupReply), id: 0), context: self.context, chatLocation: self.chatLocation, subject: self.subject, chatLocationContextHolder: self.chatLocationContextHolder, fixedCombinedReadStates: nil, tag: nil, additionalData: [])
var signal: Signal<MessageIndex?, NoError>
signal = historyView
|> mapToSignal { historyView -> Signal<MessageIndex?, NoError> in

View File

@ -111,7 +111,6 @@ import ChatMessageAnimatedStickerItemNode
import ChatMessageBubbleItemNode
import ChatNavigationButton
import WebsiteType
import ChatQrCodeScreen
import PeerInfoScreen
import MediaEditorScreen
import WallpaperGalleryScreen

View File

@ -111,7 +111,6 @@ import ChatMessageAnimatedStickerItemNode
import ChatMessageBubbleItemNode
import ChatNavigationButton
import WebsiteType
import ChatQrCodeScreen
import PeerInfoScreen
import MediaEditorScreen
import WallpaperGalleryScreen

View File

@ -111,7 +111,6 @@ import ChatMessageAnimatedStickerItemNode
import ChatMessageBubbleItemNode
import ChatNavigationButton
import WebsiteType
import ChatQrCodeScreen
import PeerInfoScreen
import MediaEditorScreen
import WallpaperGalleryScreen

View File

@ -111,7 +111,6 @@ import ChatMessageAnimatedStickerItemNode
import ChatMessageBubbleItemNode
import ChatNavigationButton
import WebsiteType
import ChatQrCodeScreen
import PeerInfoScreen
import MediaEditorScreen
import WallpaperGalleryScreen

View File

@ -111,7 +111,6 @@ import ChatMessageAnimatedStickerItemNode
import ChatMessageBubbleItemNode
import ChatNavigationButton
import WebsiteType
import ChatQrCodeScreen
import PeerInfoScreen
import MediaEditorScreen
import WallpaperGalleryScreen

View File

@ -112,7 +112,6 @@ import ChatMessageAnimatedStickerItemNode
import ChatMessageBubbleItemNode
import ChatNavigationButton
import WebsiteType
import ChatQrCodeScreen
import PeerInfoScreen
import MediaEditor
import MediaEditorScreen
@ -6155,7 +6154,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G
func pinnedHistorySignal(anchorMessageId: MessageId?, count: Int) -> Signal<ChatHistoryViewUpdate, NoError> {
let location: ChatHistoryLocation
if let anchorMessageId = anchorMessageId {
location = .InitialSearch(subject: MessageHistoryInitialSearchSubject(location: .id(anchorMessageId), quote: nil), count: count, highlight: false, setupReply: false)
location = .InitialSearch(subject: MessageHistoryInitialSearchSubject(location: .id(anchorMessageId)), count: count, highlight: false, setupReply: false)
} else {
location = .Initial(count: count)
}
@ -8592,9 +8591,9 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G
case let .chat(textInputState, subject, peekData):
dismissWebAppControllers()
if case .peer(peerId.id) = strongSelf.chatLocation {
if let subject = subject, case let .message(messageSubject, _, timecode, _) = subject {
if let subject = subject, case let .message(messageSubject, highlight, timecode, _) = subject {
if case let .id(messageId) = messageSubject {
strongSelf.navigateToMessage(from: sourceMessageId, to: .id(messageId, NavigateToMessageParams(timestamp: timecode, quote: nil)))
strongSelf.navigateToMessage(from: sourceMessageId, to: .id(messageId, NavigateToMessageParams(timestamp: timecode, quote: nil, todoTaskId: highlight?.todoTaskId)))
}
} else {
self?.playShakeAnimation()

View File

@ -10,7 +10,6 @@ import Display
import UIKit
import UndoUI
import ShareController
import ChatQrCodeScreen
import ChatShareMessageTagView
import ReactionSelectionNode
import TopMessageReactions
@ -114,7 +113,7 @@ extension ChatControllerImpl {
shareController.parentNavigationController = self.navigationController as? NavigationController
if let message = messages.first, message.media.contains(where: { media in
if media is TelegramMediaContact || media is TelegramMediaPoll {
if media is TelegramMediaContact || media is TelegramMediaPoll || media is TelegramMediaTodo {
return true
} else if let file = media as? TelegramMediaFile, file.isSticker || file.isAnimatedSticker || file.isVideoSticker {
return true
@ -139,12 +138,6 @@ extension ChatControllerImpl {
}
}
}
shareController.openShareAsImage = { [weak self] messages in
guard let self else {
return
}
self.present(ChatQrCodeScreenImpl(context: self.context, subject: .messages(messages)), in: .window(.root))
}
shareController.dismissed = { [weak self] shared in
if shared {
self?.commitPurposefulAction()

View File

@ -993,9 +993,9 @@ public final class ChatHistoryListNodeImpl: ListView, ChatHistoryNode, ChatHisto
initialSearchLocation = .index(MessageIndex.absoluteUpperBound())
}
}
self.chatHistoryLocationValue = ChatHistoryLocationInput(content: .InitialSearch(subject: MessageHistoryInitialSearchSubject(location: initialSearchLocation, quote: (highlight?.quote).flatMap { quote in MessageHistoryInitialSearchSubject.Quote(string: quote.string, offset: quote.offset) }), count: historyMessageCount, highlight: highlight != nil, setupReply: setupReply), id: 0)
self.chatHistoryLocationValue = ChatHistoryLocationInput(content: .InitialSearch(subject: MessageHistoryInitialSearchSubject(location: initialSearchLocation, quote: (highlight?.quote).flatMap { quote in MessageHistoryInitialSearchSubject.Quote(string: quote.string, offset: quote.offset) }, todoTaskId: highlight?.todoTaskId), count: historyMessageCount, highlight: highlight != nil, setupReply: setupReply), id: 0)
} else if let subject = subject, case let .pinnedMessages(maybeMessageId) = subject, let messageId = maybeMessageId {
self.chatHistoryLocationValue = ChatHistoryLocationInput(content: .InitialSearch(subject: MessageHistoryInitialSearchSubject(location: .id(messageId), quote: nil), count: historyMessageCount, highlight: true, setupReply: false), id: 0)
self.chatHistoryLocationValue = ChatHistoryLocationInput(content: .InitialSearch(subject: MessageHistoryInitialSearchSubject(location: .id(messageId)), count: historyMessageCount, highlight: true, setupReply: false), id: 0)
} else {
self.chatHistoryLocationValue = ChatHistoryLocationInput(content: .Initial(count: historyMessageCount), id: 0)
}
@ -1815,9 +1815,9 @@ public final class ChatHistoryListNodeImpl: ListView, ChatHistoryNode, ChatHisto
initialSearchLocation = .index(.absoluteUpperBound())
}
}
strongSelf.chatHistoryLocationValue = ChatHistoryLocationInput(content: .InitialSearch(subject: MessageHistoryInitialSearchSubject(location: initialSearchLocation, quote: (highlight?.quote).flatMap { quote in MessageHistoryInitialSearchSubject.Quote(string: quote.string, offset: quote.offset) }), count: historyMessageCount, highlight: highlight != nil, setupReply: setupReply), id: (strongSelf.chatHistoryLocationValue?.id).flatMap({ $0 + 1 }) ?? 0)
strongSelf.chatHistoryLocationValue = ChatHistoryLocationInput(content: .InitialSearch(subject: MessageHistoryInitialSearchSubject(location: initialSearchLocation, quote: (highlight?.quote).flatMap { quote in MessageHistoryInitialSearchSubject.Quote(string: quote.string, offset: quote.offset) }, todoTaskId: highlight?.todoTaskId), count: historyMessageCount, highlight: highlight != nil, setupReply: setupReply), id: (strongSelf.chatHistoryLocationValue?.id).flatMap({ $0 + 1 }) ?? 0)
} else if let subject = subject, case let .pinnedMessages(maybeMessageId) = subject, let messageId = maybeMessageId {
strongSelf.chatHistoryLocationValue = ChatHistoryLocationInput(content: .InitialSearch(subject: MessageHistoryInitialSearchSubject(location: .id(messageId), quote: nil), count: historyMessageCount, highlight: true, setupReply: false), id: (strongSelf.chatHistoryLocationValue?.id).flatMap({ $0 + 1 }) ?? 0)
strongSelf.chatHistoryLocationValue = ChatHistoryLocationInput(content: .InitialSearch(subject: MessageHistoryInitialSearchSubject(location: .id(messageId)), count: historyMessageCount, highlight: true, setupReply: false), id: (strongSelf.chatHistoryLocationValue?.id).flatMap({ $0 + 1 }) ?? 0)
} else if var chatHistoryLocation = strongSelf.chatHistoryLocationValue {
chatHistoryLocation.id += 1
strongSelf.chatHistoryLocationValue = chatHistoryLocation

View File

@ -282,7 +282,7 @@ func chatHistoryViewForLocation(
preloaded = true
return .HistoryView(view: view, type: reportUpdateType, scrollPosition: .index(subject: MessageHistoryScrollToSubject(index: anchorIndex, quote: searchLocationSubject.quote.flatMap { quote in MessageHistoryScrollToSubject.Quote(string: quote.string, offset: quote.offset) }, setupReply: setupReply), position: .center(.bottom), directionHint: .Down, animated: false, highlight: highlight, displayLink: false, setupReply: setupReply), flashIndicators: false, originalScrollPosition: nil, initialData: ChatHistoryCombinedInitialData(initialData: initialData, buttonKeyboardMessage: view.topTaggedMessages.first, cachedData: cachedData, cachedDataMessages: cachedDataMessages, readStateData: readStateData), id: location.id)
return .HistoryView(view: view, type: reportUpdateType, scrollPosition: .index(subject: MessageHistoryScrollToSubject(index: anchorIndex, quote: searchLocationSubject.quote.flatMap { quote in MessageHistoryScrollToSubject.Quote(string: quote.string, offset: quote.offset) }, todoTaskId: searchLocationSubject.todoTaskId, setupReply: setupReply), position: .center(.bottom), directionHint: .Down, animated: false, highlight: highlight, displayLink: false, setupReply: setupReply), flashIndicators: false, originalScrollPosition: nil, initialData: ChatHistoryCombinedInitialData(initialData: initialData, buttonKeyboardMessage: view.topTaggedMessages.first, cachedData: cachedData, cachedDataMessages: cachedDataMessages, readStateData: readStateData), id: location.id)
}
}
case let .Navigation(index, anchorIndex, count, _):
@ -415,7 +415,7 @@ func fetchAndPreloadReplyThreadInfo(context: AccountContext, subject: ReplyThrea
case .automatic:
if let atMessageId = atMessageId {
input = ChatHistoryLocationInput(
content: .InitialSearch(subject: MessageHistoryInitialSearchSubject(location: .id(atMessageId), quote: nil), count: 40, highlight: true, setupReply: false),
content: .InitialSearch(subject: MessageHistoryInitialSearchSubject(location: .id(atMessageId)), count: 40, highlight: true, setupReply: false),
id: 0
)
} else {

View File

@ -87,7 +87,7 @@ public enum ParsedInternalUrl {
case peer(UrlPeerReference, ParsedInternalPeerUrlParameter?)
case peerId(PeerId)
case privateMessage(messageId: MessageId, threadId: Int32?, timecode: Double?)
case privateMessage(messageId: MessageId, threadId: Int32?, timecode: Double?, taskId: Int32?)
case stickerPack(name: String, type: StickerPackUrlType)
case invoice(String)
case join(String)
@ -545,6 +545,7 @@ public func parseInternalUrl(sharedContext: SharedAccountContext, context: Accou
if let channelId = Int64(pathComponents[1]), let messageId = Int32(pathComponents[2]), channelId > 0 {
var threadId: Int32?
var timecode: Double?
var taskId: Int32?
if let queryItems = components.queryItems {
for queryItem in queryItems {
if let value = queryItem.value {
@ -557,11 +558,15 @@ public func parseInternalUrl(sharedContext: SharedAccountContext, context: Accou
if timestampValue != 0 {
timecode = Double(timestampValue)
}
} else if queryItem.name == "task" {
if let intValue = Int32(value) {
taskId = intValue
}
}
}
}
}
return .privateMessage(messageId: MessageId(peerId: PeerId(namespace: Namespaces.Peer.CloudChannel, id: PeerId.Id._internalFromInt64Value(channelId)), namespace: Namespaces.Message.Cloud, id: messageId), threadId: threadId, timecode: timecode)
return .privateMessage(messageId: MessageId(peerId: PeerId(namespace: Namespaces.Peer.CloudChannel, id: PeerId.Id._internalFromInt64Value(channelId)), namespace: Namespaces.Message.Cloud, id: messageId), threadId: threadId, timecode: timecode, taskId: taskId)
} else {
return nil
}
@ -574,6 +579,7 @@ public func parseInternalUrl(sharedContext: SharedAccountContext, context: Accou
} else if pathComponents.count == 4 && pathComponents[0] == "c" {
if let channelId = Int64(pathComponents[1]), let threadId = Int32(pathComponents[2]), let messageId = Int32(pathComponents[3]), channelId > 0 {
var timecode: Double?
var taskId: Int32?
if let queryItems = components.queryItems {
for queryItem in queryItems {
if let value = queryItem.value {
@ -582,11 +588,15 @@ public func parseInternalUrl(sharedContext: SharedAccountContext, context: Accou
if timestampValue != 0 {
timecode = Double(timestampValue)
}
} else if queryItem.name == "task" {
if let intValue = Int32(value) {
taskId = intValue
}
}
}
}
}
return .privateMessage(messageId: MessageId(peerId: PeerId(namespace: Namespaces.Peer.CloudChannel, id: PeerId.Id._internalFromInt64Value(channelId)), namespace: Namespaces.Message.Cloud, id: messageId), threadId: threadId, timecode: timecode)
return .privateMessage(messageId: MessageId(peerId: PeerId(namespace: Namespaces.Peer.CloudChannel, id: PeerId.Id._internalFromInt64Value(channelId)), namespace: Namespaces.Message.Cloud, id: messageId), threadId: threadId, timecode: timecode, taskId: taskId)
} else {
return nil
}
@ -935,7 +945,7 @@ private func resolveInternalUrl(context: AccountContext, url: ParsedInternalUrl)
return .single(.result(.peer(nil, .info(nil))))
}
})
case let .privateMessage(messageId, threadId, timecode):
case let .privateMessage(messageId, threadId, timecode, taskId):
return context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: messageId.peerId))
|> mapToSignal { peer -> Signal<ResolveInternalUrlResult, NoError> in
let foundPeer: Signal<EnginePeer?, NoError>
@ -1018,7 +1028,7 @@ private func resolveInternalUrl(context: AccountContext, url: ParsedInternalUrl)
return .result(.replyThreadMessage(replyThreadMessage: result, messageId: messageId))
})
} else {
return .single(.result(.peer(foundPeer._asPeer(), .chat(textInputState: nil, subject: .message(id: .id(messageId), highlight: ChatControllerSubject.MessageHighlight(quote: nil), timecode: timecode, setupReply: false), peekData: nil))))
return .single(.result(.peer(foundPeer._asPeer(), .chat(textInputState: nil, subject: .message(id: .id(messageId), highlight: ChatControllerSubject.MessageHighlight(quote: nil, todoTaskId: taskId), timecode: timecode, setupReply: false), peekData: nil))))
}
} else {
return .single(.result(.inaccessiblePeer))

View File

@ -1753,6 +1753,8 @@ public final class WebAppController: ViewController, AttachmentContainable {
self?.webView?.sendEvent(name: "secure_storage_cleared", data: data.string)
})
}
case "web_app_hide_keyboard":
self.view.window?.endEditing(true)
default:
break
}