Merge commit 'f59abe16896937b2dd8f885a859f9352cd76bf28'

This commit is contained in:
Isaac 2025-06-11 16:59:58 +08:00
commit f11ebd9559
29 changed files with 1289 additions and 199 deletions

View File

@ -20,11 +20,13 @@ public struct MessageHistoryScrollToSubject: Equatable {
public var index: MessageHistoryAnchorIndex public var index: MessageHistoryAnchorIndex
public var quote: Quote? public var quote: Quote?
public var todoTaskId: Int32?
public var setupReply: Bool public var setupReply: Bool
public init(index: MessageHistoryAnchorIndex, quote: Quote?, setupReply: Bool = false) { public init(index: MessageHistoryAnchorIndex, quote: Quote?, todoTaskId: Int32? = nil, setupReply: Bool = false) {
self.index = index self.index = index
self.quote = quote self.quote = quote
self.todoTaskId = todoTaskId
self.setupReply = setupReply self.setupReply = setupReply
} }
} }

View File

@ -837,7 +837,7 @@ private final class MainButtonNode: HighlightTrackingButtonNode {
transition.updateFrame(node: self.statusNode, frame: CGRect(origin: CGPoint(x: size.width - statusSize.width - 15.0, y: floorToScreenPixels((size.height - statusSize.height) / 2.0)), size: statusSize)) transition.updateFrame(node: self.statusNode, frame: CGRect(origin: CGPoint(x: size.width - statusSize.width - 15.0, y: floorToScreenPixels((size.height - statusSize.height) / 2.0)), size: statusSize))
self.statusNode.foregroundNodeColor = state.textColor self.statusNode.foregroundNodeColor = state.textColor
self.statusNode.transitionToState(state.progress == .side ? .progress(value: nil, cancelEnabled: false, appearance: SemanticStatusNodeState.ProgressAppearance(inset: 0.0, lineWidth: 2.0)) : .none) self.statusNode.transitionToState(state.progress == .side ? .progress(value: nil, cancelEnabled: false, appearance: SemanticStatusNodeState.ProgressAppearance(inset: 0.0, lineWidth: 2.0), animateRotation: true) : .none)
} }
} }

View File

@ -530,6 +530,8 @@ final class ComposePollScreenComponent: Component {
let themeUpdated = self.environment?.theme !== environment.theme let themeUpdated = self.environment?.theme !== environment.theme
self.environment = environment self.environment = environment
let theme = environment.theme.withModalBlocksBackground()
if self.component == nil { if self.component == nil {
self.isQuiz = component.isQuiz ?? false self.isQuiz = component.isQuiz ?? false
@ -682,7 +684,7 @@ final class ComposePollScreenComponent: Component {
let sectionSpacing: CGFloat = 24.0 let sectionSpacing: CGFloat = 24.0
if themeUpdated { if themeUpdated {
self.backgroundColor = environment.theme.list.blocksBackgroundColor self.backgroundColor = theme.list.blocksBackgroundColor
} }
let presentationData = component.context.sharedContext.currentPresentationData.with { $0 } let presentationData = component.context.sharedContext.currentPresentationData.with { $0 }
@ -695,7 +697,7 @@ final class ComposePollScreenComponent: Component {
pollTextSectionItems.append(AnyComponentWithIdentity(id: 0, component: AnyComponent(ListComposePollOptionComponent( pollTextSectionItems.append(AnyComponentWithIdentity(id: 0, component: AnyComponent(ListComposePollOptionComponent(
externalState: self.pollTextInputState, externalState: self.pollTextInputState,
context: component.context, context: component.context,
theme: environment.theme, theme: theme,
strings: environment.strings, strings: environment.strings,
resetText: self.resetPollText.flatMap { resetText in resetText: self.resetPollText.flatMap { resetText in
return ListComposePollOptionComponent.ResetText(value: NSAttributedString(string: resetText)) return ListComposePollOptionComponent.ResetText(value: NSAttributedString(string: resetText))
@ -737,12 +739,12 @@ final class ComposePollScreenComponent: Component {
let pollTextSectionSize = self.pollTextSection.update( let pollTextSectionSize = self.pollTextSection.update(
transition: transition, transition: transition,
component: AnyComponent(ListSectionComponent( component: AnyComponent(ListSectionComponent(
theme: environment.theme, theme: theme,
header: AnyComponent(MultilineTextComponent( header: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString( text: .plain(NSAttributedString(
string: environment.strings.CreatePoll_TextHeader, string: environment.strings.CreatePoll_TextHeader,
font: Font.regular(presentationData.listsFontSize.itemListBaseHeaderFontSize), font: Font.regular(presentationData.listsFontSize.itemListBaseHeaderFontSize),
textColor: environment.theme.list.freeTextColor textColor: theme.list.freeTextColor
)), )),
maximumNumberOfLines: 0 maximumNumberOfLines: 0
)), )),
@ -790,7 +792,7 @@ final class ComposePollScreenComponent: Component {
pollOptionsSectionItems.append(AnyComponentWithIdentity(id: pollOption.id, component: AnyComponent(ListComposePollOptionComponent( pollOptionsSectionItems.append(AnyComponentWithIdentity(id: pollOption.id, component: AnyComponent(ListComposePollOptionComponent(
externalState: pollOption.textInputState, externalState: pollOption.textInputState,
context: component.context, context: component.context,
theme: environment.theme, theme: theme,
strings: environment.strings, strings: environment.strings,
resetText: pollOption.resetText.flatMap { resetText in resetText: pollOption.resetText.flatMap { resetText in
return ListComposePollOptionComponent.ResetText(value: NSAttributedString(string: resetText)) return ListComposePollOptionComponent.ResetText(value: NSAttributedString(string: resetText))
@ -916,7 +918,7 @@ final class ComposePollScreenComponent: Component {
let pollOptionsSectionUpdateResult = self.pollOptionsSectionContainer.update( let pollOptionsSectionUpdateResult = self.pollOptionsSectionContainer.update(
configuration: ListSectionContentView.Configuration( configuration: ListSectionContentView.Configuration(
theme: environment.theme, theme: theme,
displaySeparators: true, displaySeparators: true,
extendsItemHighlightToSection: false, extendsItemHighlightToSection: false,
background: .all background: .all
@ -934,7 +936,7 @@ final class ComposePollScreenComponent: Component {
text: .plain(NSAttributedString( text: .plain(NSAttributedString(
string: environment.strings.CreatePoll_OptionsHeader, string: environment.strings.CreatePoll_OptionsHeader,
font: Font.regular(presentationData.listsFontSize.itemListBaseHeaderFontSize), font: Font.regular(presentationData.listsFontSize.itemListBaseHeaderFontSize),
textColor: environment.theme.list.freeTextColor textColor: theme.list.freeTextColor
)), )),
maximumNumberOfLines: 0 maximumNumberOfLines: 0
)), )),
@ -983,7 +985,7 @@ final class ComposePollScreenComponent: Component {
if pollOptionsLimitReached { if pollOptionsLimitReached {
pollOptionsFooterTransition = pollOptionsFooterTransition.withAnimation(.none) pollOptionsFooterTransition = pollOptionsFooterTransition.withAnimation(.none)
pollOptionsComponent = AnyComponent(MultilineTextComponent( pollOptionsComponent = AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(string: environment.strings.CreatePoll_AllOptionsAdded, font: Font.regular(presentationData.listsFontSize.itemListBaseHeaderFontSize), textColor: environment.theme.list.freeTextColor)), text: .plain(NSAttributedString(string: environment.strings.CreatePoll_AllOptionsAdded, font: Font.regular(presentationData.listsFontSize.itemListBaseHeaderFontSize), textColor: theme.list.freeTextColor)),
maximumNumberOfLines: 0 maximumNumberOfLines: 0
)) ))
} else { } else {
@ -1015,7 +1017,7 @@ final class ComposePollScreenComponent: Component {
pollOptionsComponent = AnyComponent(AnimatedTextComponent( pollOptionsComponent = AnyComponent(AnimatedTextComponent(
font: Font.regular(presentationData.listsFontSize.itemListBaseHeaderFontSize), font: Font.regular(presentationData.listsFontSize.itemListBaseHeaderFontSize),
color: environment.theme.list.freeTextColor, color: theme.list.freeTextColor,
items: pollOptionsFooterItems items: pollOptionsFooterItems
)) ))
} }
@ -1055,13 +1057,13 @@ final class ComposePollScreenComponent: Component {
var pollSettingsSectionItems: [AnyComponentWithIdentity<Empty>] = [] var pollSettingsSectionItems: [AnyComponentWithIdentity<Empty>] = []
if canBePublic { if canBePublic {
pollSettingsSectionItems.append(AnyComponentWithIdentity(id: "anonymous", component: AnyComponent(ListActionItemComponent( pollSettingsSectionItems.append(AnyComponentWithIdentity(id: "anonymous", component: AnyComponent(ListActionItemComponent(
theme: environment.theme, theme: theme,
title: AnyComponent(VStack([ title: AnyComponent(VStack([
AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent(MultilineTextComponent( AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString( text: .plain(NSAttributedString(
string: environment.strings.CreatePoll_Anonymous, string: environment.strings.CreatePoll_Anonymous,
font: Font.regular(presentationData.listsFontSize.baseDisplaySize), font: Font.regular(presentationData.listsFontSize.baseDisplaySize),
textColor: environment.theme.list.itemPrimaryTextColor textColor: theme.list.itemPrimaryTextColor
)), )),
maximumNumberOfLines: 1 maximumNumberOfLines: 1
))), ))),
@ -1077,13 +1079,13 @@ final class ComposePollScreenComponent: Component {
)))) ))))
} }
pollSettingsSectionItems.append(AnyComponentWithIdentity(id: "multiAnswer", component: AnyComponent(ListActionItemComponent( pollSettingsSectionItems.append(AnyComponentWithIdentity(id: "multiAnswer", component: AnyComponent(ListActionItemComponent(
theme: environment.theme, theme: theme,
title: AnyComponent(VStack([ title: AnyComponent(VStack([
AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent(MultilineTextComponent( AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString( text: .plain(NSAttributedString(
string: environment.strings.CreatePoll_MultipleChoice, string: environment.strings.CreatePoll_MultipleChoice,
font: Font.regular(presentationData.listsFontSize.baseDisplaySize), font: Font.regular(presentationData.listsFontSize.baseDisplaySize),
textColor: environment.theme.list.itemPrimaryTextColor textColor: theme.list.itemPrimaryTextColor
)), )),
maximumNumberOfLines: 1 maximumNumberOfLines: 1
))), ))),
@ -1101,13 +1103,13 @@ final class ComposePollScreenComponent: Component {
action: nil action: nil
)))) ))))
pollSettingsSectionItems.append(AnyComponentWithIdentity(id: "quiz", component: AnyComponent(ListActionItemComponent( pollSettingsSectionItems.append(AnyComponentWithIdentity(id: "quiz", component: AnyComponent(ListActionItemComponent(
theme: environment.theme, theme: theme,
title: AnyComponent(VStack([ title: AnyComponent(VStack([
AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent(MultilineTextComponent( AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString( text: .plain(NSAttributedString(
string: environment.strings.CreatePoll_Quiz, string: environment.strings.CreatePoll_Quiz,
font: Font.regular(presentationData.listsFontSize.baseDisplaySize), font: Font.regular(presentationData.listsFontSize.baseDisplaySize),
textColor: environment.theme.list.itemPrimaryTextColor textColor: theme.list.itemPrimaryTextColor
)), )),
maximumNumberOfLines: 1 maximumNumberOfLines: 1
))), ))),
@ -1128,13 +1130,13 @@ final class ComposePollScreenComponent: Component {
let pollSettingsSectionSize = self.pollSettingsSection.update( let pollSettingsSectionSize = self.pollSettingsSection.update(
transition: transition, transition: transition,
component: AnyComponent(ListSectionComponent( component: AnyComponent(ListSectionComponent(
theme: environment.theme, theme: theme,
header: nil, header: nil,
footer: AnyComponent(MultilineTextComponent( footer: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString( text: .plain(NSAttributedString(
string: environment.strings.CreatePoll_QuizInfo, string: environment.strings.CreatePoll_QuizInfo,
font: Font.regular(presentationData.listsFontSize.itemListBaseHeaderFontSize), font: Font.regular(presentationData.listsFontSize.itemListBaseHeaderFontSize),
textColor: environment.theme.list.freeTextColor textColor: theme.list.freeTextColor
)), )),
maximumNumberOfLines: 0 maximumNumberOfLines: 0
)), )),
@ -1158,12 +1160,12 @@ final class ComposePollScreenComponent: Component {
let quizAnswerSectionSize = self.quizAnswerSection.update( let quizAnswerSectionSize = self.quizAnswerSection.update(
transition: transition, transition: transition,
component: AnyComponent(ListSectionComponent( component: AnyComponent(ListSectionComponent(
theme: environment.theme, theme: theme,
header: AnyComponent(MultilineTextComponent( header: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString( text: .plain(NSAttributedString(
string: environment.strings.CreatePoll_ExplanationHeader, string: environment.strings.CreatePoll_ExplanationHeader,
font: Font.regular(presentationData.listsFontSize.itemListBaseHeaderFontSize), font: Font.regular(presentationData.listsFontSize.itemListBaseHeaderFontSize),
textColor: environment.theme.list.freeTextColor textColor: theme.list.freeTextColor
)), )),
maximumNumberOfLines: 0 maximumNumberOfLines: 0
)), )),
@ -1171,7 +1173,7 @@ final class ComposePollScreenComponent: Component {
text: .plain(NSAttributedString( text: .plain(NSAttributedString(
string: environment.strings.CreatePoll_ExplanationInfo, string: environment.strings.CreatePoll_ExplanationInfo,
font: Font.regular(presentationData.listsFontSize.itemListBaseHeaderFontSize), font: Font.regular(presentationData.listsFontSize.itemListBaseHeaderFontSize),
textColor: environment.theme.list.freeTextColor textColor: theme.list.freeTextColor
)), )),
maximumNumberOfLines: 0 maximumNumberOfLines: 0
)), )),
@ -1179,7 +1181,7 @@ final class ComposePollScreenComponent: Component {
AnyComponentWithIdentity(id: 0, component: AnyComponent(ListComposePollOptionComponent( AnyComponentWithIdentity(id: 0, component: AnyComponent(ListComposePollOptionComponent(
externalState: self.quizAnswerTextInputState, externalState: self.quizAnswerTextInputState,
context: component.context, context: component.context,
theme: environment.theme, theme: theme,
strings: environment.strings, strings: environment.strings,
resetText: self.resetQuizAnswerText.flatMap { resetText in resetText: self.resetQuizAnswerText.flatMap { resetText in
return ListComposePollOptionComponent.ResetText(value: NSAttributedString(string: resetText)) return ListComposePollOptionComponent.ResetText(value: NSAttributedString(string: resetText))
@ -1326,7 +1328,7 @@ final class ComposePollScreenComponent: Component {
component: AnyComponent(EmojiSuggestionsComponent( component: AnyComponent(EmojiSuggestionsComponent(
context: component.context, context: component.context,
userLocation: .other, userLocation: .other,
theme: EmojiSuggestionsComponent.Theme(theme: environment.theme, backgroundColor: environment.theme.list.itemBlocksBackgroundColor), theme: EmojiSuggestionsComponent.Theme(theme: theme, backgroundColor: theme.list.itemBlocksBackgroundColor),
animationCache: component.context.animationCache, animationCache: component.context.animationCache,
animationRenderer: component.context.animationRenderer, animationRenderer: component.context.animationRenderer,
files: value, files: value,

View File

@ -66,6 +66,9 @@ swift_library(
"//submodules/ComponentFlow", "//submodules/ComponentFlow",
"//submodules/TelegramUI/Components/ToastComponent", "//submodules/TelegramUI/Components/ToastComponent",
"//submodules/SemanticStatusNode", "//submodules/SemanticStatusNode",
"//submodules/TelegramUI/Components/PlainButtonComponent",
"//submodules/AvatarNode",
"//submodules/PhotoResources",
], ],
visibility = [ visibility = [
"//visibility:public", "//visibility:public",

View File

@ -0,0 +1,212 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import SwiftSignalKit
import ComponentFlow
import SemanticStatusNode
import AnimatedTextComponent
public final class AdRemainingProgressComponent: Component {
public let initialTimestamp: Int32
public let minDisplayDuration: Int32
public let maxDisplayDuration: Int32
public let action: (Bool) -> Void
public init(
initialTimestamp: Int32,
minDisplayDuration: Int32,
maxDisplayDuration: Int32,
action: @escaping (Bool) -> Void
) {
self.initialTimestamp = initialTimestamp
self.minDisplayDuration = minDisplayDuration
self.maxDisplayDuration = maxDisplayDuration
self.action = action
}
public static func ==(lhs: AdRemainingProgressComponent, rhs: AdRemainingProgressComponent) -> Bool {
if lhs.initialTimestamp != rhs.initialTimestamp {
return false
}
if lhs.minDisplayDuration != rhs.minDisplayDuration {
return false
}
if lhs.maxDisplayDuration != rhs.maxDisplayDuration {
return false
}
return true
}
public final class View: HighlightTrackingButton {
private var component: AdRemainingProgressComponent?
private weak var componentState: EmptyComponentState?
private let node: SemanticStatusNode
private var textComponent = ComponentView<Empty>()
private var cancelIcon = UIImageView()
private var progress: Double = 1.0
override init(frame: CGRect) {
self.node = SemanticStatusNode(backgroundNodeColor: .clear, foregroundNodeColor: .white)
self.node.isUserInteractionEnabled = false
self.cancelIcon.alpha = 0.0
self.cancelIcon.isUserInteractionEnabled = false
super.init(frame: frame)
self.addSubview(self.node.view)
self.addSubview(self.cancelIcon)
self.addTarget(self, action: #selector(self.pressed), for: .touchUpInside)
self.highligthedChanged = { [weak self] highlighted in
if let self {
if highlighted {
self.layer.removeAnimation(forKey: "opacity")
self.alpha = 0.7
} else {
self.alpha = 1.0
self.layer.animateAlpha(from: 7, to: 1.0, duration: 0.2)
}
}
}
}
required init(coder: NSCoder) {
preconditionFailure()
}
@objc private func pressed() {
guard let component = self.component else {
return
}
component.action(self.progress < .ulpOfOne)
}
override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
return super.hitTest(point, with: event)
}
var timer: SwiftSignalKit.Timer?
func update(component: AdRemainingProgressComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
if self.component == nil {
self.timer = SwiftSignalKit.Timer(timeout: 0.25, repeat: true, completion: { [weak self] progress in
guard let self else {
return
}
self.componentState?.updated(transition: .easeInOut(duration: 0.2))
}, queue: Queue.mainQueue())
self.timer?.start()
}
self.component = component
self.componentState = state
let size = CGSize(width: 24.0, height: 24.0)
let color = UIColor(rgb: 0x64d2ff)
if self.cancelIcon.image == nil {
self.cancelIcon.image = generateCancelIcon(color: color)
self.node.foregroundNodeColor = color
}
var progress = 0.0
let currentTimestamp = CFAbsoluteTimeGetCurrent()
let minTimestamp = Double(component.initialTimestamp + component.minDisplayDuration)
let initialTimestamp = Double(component.initialTimestamp)
let remaining = min(9, max(1, minTimestamp - currentTimestamp))
var textIsHidden = false
if currentTimestamp >= initialTimestamp && currentTimestamp <= minTimestamp {
progress = (minTimestamp - currentTimestamp) / (minTimestamp - initialTimestamp)
} else {
progress = 0
textIsHidden = true
}
self.progress = progress
let textSize = self.textComponent.update(
transition: transition,
component: AnyComponent(
AnimatedTextComponent(
font: Font.regular(14.0),
color: color,
items: [AnimatedTextComponent.Item(id: 0, content: .number(Int(remaining), minDigits: 1))]
)
),
environment: {},
containerSize: size
)
let iconTransition = ComponentTransition(animation: .curve(duration: 0.25, curve: .spring))
if let textView = self.textComponent.view {
if textView.superview == nil {
textView.isUserInteractionEnabled = false
self.addSubview(textView)
}
textView.frame = CGRect(origin: CGPoint(x: (size.width - textSize.width) / 2.0, y: (size.height - textSize.height) / 2.0), size: textSize)
iconTransition.setAlpha(view: textView, alpha: textIsHidden ? 0.0 : 1.0)
iconTransition.setAlpha(view: self.cancelIcon, alpha: textIsHidden ? 1.0 : 0.0)
}
if let icon = self.cancelIcon.image {
self.cancelIcon.bounds = CGRect(origin: .zero, size: icon.size)
var iconScale = 0.7
var iconAlpha = 1.0
var iconPosition = CGPoint(x: size.width / 2.0 + 10.0, y: size.height / 2.0 - 10.0)
if progress > 0.8 {
iconScale = 0.01
iconAlpha = 0.0
} else if progress < .ulpOfOne {
iconScale = 1.0
iconPosition = CGPoint(x: size.width / 2.0, y: size.height / 2.0)
}
iconTransition.setAlpha(view: self.cancelIcon, alpha: iconAlpha)
iconTransition.setScale(view: self.cancelIcon, scale: iconScale)
iconTransition.setPosition(view: self.cancelIcon, position: iconPosition)
}
self.node.frame = CGRect(origin: .zero, size: size)
self.node.transitionToState(.progress(value: max(0.0, min(1.0, progress)), cancelEnabled: false, appearance: SemanticStatusNodeState.ProgressAppearance(inset: 1.0, lineWidth: 1.0 + UIScreenPixel), animateRotation: false), updateCutout: false)
return size
}
}
public func makeView() -> View {
return View(frame: CGRect())
}
public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}
private func generateCancelIcon(color: UIColor) -> UIImage? {
return generateImage(CGSize(width: 12.0, height: 12.0), rotatedContext: { size, context in
let bounds = CGRect(origin: CGPoint(), size: size)
context.clear(bounds)
let lineWidth = 2.0 - UIScreenPixel
context.setLineWidth(lineWidth)
context.setLineCap(.round)
context.setStrokeColor(color.cgColor)
context.move(to: CGPoint(x: lineWidth / 2.0, y: lineWidth / 2.0))
context.addLine(to: CGPoint(x: size.width - lineWidth / 2.0, y: size.height - lineWidth / 2.0))
context.strokePath()
context.move(to: CGPoint(x: size.width - lineWidth / 2.0, y: lineWidth / 2.0))
context.addLine(to: CGPoint(x: lineWidth / 2.0, y: size.height - lineWidth / 2.0))
context.strokePath()
})
}

View File

@ -756,7 +756,7 @@ final class ChatImageGalleryItemNode: ZoomableContentGalleryItemNode {
guard let controller = self.baseNavigationController()?.topViewController as? ViewController else { guard let controller = self.baseNavigationController()?.topViewController as? ViewController else {
return return
} }
let contextController = ContextController(presentationData: self.presentationData.withUpdated(theme: defaultDarkColorPresentationTheme), source: .reference(HeaderContextReferenceContentSource(controller: controller, sourceNode: self.moreBarButton.referenceNode)), items: items |> map { ContextController.Items(content: .list($0)) }, gesture: gesture) let contextController = ContextController(presentationData: self.presentationData.withUpdated(theme: defaultDarkColorPresentationTheme), source: .reference(HeaderContextReferenceContentSource(controller: controller, sourceNode: self.moreBarButton.referenceNode, actionsOnTop: false)), items: items |> map { ContextController.Items(content: .list($0)) }, gesture: gesture)
controller.presentInGlobalOverlay(contextController) controller.presentInGlobalOverlay(contextController)
} }

View File

@ -223,86 +223,191 @@ private final class UniversalVideoGalleryItemPictureInPictureNode: ASDisplayNode
} }
} }
private let fullscreenImage = generateTintedImage(image: UIImage(bundleImageName: "Media Gallery/Fullscreen"), color: .white)
private let minimizeImage = generateTintedImage(image: UIImage(bundleImageName: "Media Gallery/Minimize"), color: .white)
private final class UniversalVideoGalleryItemOverlayNode: GalleryOverlayContentNode { private final class UniversalVideoGalleryItemOverlayNode: GalleryOverlayContentNode {
private let wrapperNode: ASDisplayNode private var context: AccountContext?
private let fullscreenNode: HighlightableButtonNode
private var validLayout: (CGSize, LayoutMetrics, UIEdgeInsets)?
var action: ((Bool) -> Void)? private var adView = ComponentView<Empty>()
override init() { private var message: Message?
self.wrapperNode = ASDisplayNode() private var adContext: AdMessagesHistoryContext?
self.wrapperNode.alpha = 0.0 private var adState: (startDelay: Int32?, betweenDelay: Int32?, messages: [Message])?
private let adDisposable = MetaDisposable()
self.fullscreenNode = HighlightableButtonNode() private var program: [(Int32, Message?)] = []
self.fullscreenNode.setImage(fullscreenImage, for: .normal)
self.fullscreenNode.setImage(minimizeImage, for: .selected)
self.fullscreenNode.setImage(minimizeImage, for: [.selected, .highlighted])
super.init() var performAction: ((GalleryControllerInteractionTapAction) -> Void)?
var presentPremiumDemo: (() -> Void)?
var openMoreMenu: ((ContextReferenceContentNode, Message) -> Void)?
self.addSubnode(self.wrapperNode) private var validLayout: (size: CGSize, metrics: LayoutMetrics, insets: UIEdgeInsets)?
self.wrapperNode.addSubnode(self.fullscreenNode)
self.fullscreenNode.addTarget(self, action: #selector(self.toggleFullscreenPressed), forControlEvents: .touchUpInside) deinit {
self.adDisposable.dispose()
} }
func setMessage(context: AccountContext, message: Message) {
self.context = context
guard self.message?.id != message.id else {
return
}
self.message = message
let adContext = context.engine.messages.adMessages(peerId: message.id.peerId, messageId: message.id)
self.adContext = adContext
self.adDisposable.set((adContext.state
|> deliverOnMainQueue).start(next: { [weak self] state in
guard let self else {
return
}
if !state.messages.isEmpty {
self.adState = (state.startDelay, state.betweenDelay, state.messages)
var startTime = Int32(CFAbsoluteTimeGetCurrent()) // + (state.startDelay ?? 0)
var program: [(Int32, Message?)] = []
var maxDisplayDuration: Int32 = 30
for message in state.messages {
if !program.isEmpty {
program.append((startTime, nil))
startTime += (state.betweenDelay ?? 0)
}
program.append((startTime, message))
if let adAttribute = message.adAttribute {
maxDisplayDuration = adAttribute.maxDisplayDuration ?? 30
startTime += maxDisplayDuration
}
}
program.append((startTime + maxDisplayDuration, nil))
self.program = program
} else {
self.adState = nil
self.program = []
}
if let validLayout = self.validLayout {
self.updateLayout(size: validLayout.size, metrics: validLayout.metrics, insets: validLayout.insets, isHidden: false, transition: .immediate)
}
}))
}
var timer: SwiftSignalKit.Timer?
var hiddenMessages = Set<MessageId>()
var isAnimatingOut = false
var reportedMessages = Set<Data>()
override func updateLayout(size: CGSize, metrics: LayoutMetrics, insets: UIEdgeInsets, isHidden: Bool, transition: ContainedViewLayoutTransition) { override func updateLayout(size: CGSize, metrics: LayoutMetrics, insets: UIEdgeInsets, isHidden: Bool, transition: ContainedViewLayoutTransition) {
self.validLayout = (size, metrics, insets) self.validLayout = (size, metrics, insets)
let isLandscape = size.width > size.height if self.timer == nil {
self.fullscreenNode.isSelected = isLandscape self.timer = SwiftSignalKit.Timer(timeout: 0.5, repeat: true, completion: { [weak self] progress in
guard let self else {
return
}
if let validLayout = self.validLayout {
self.updateLayout(size: validLayout.size, metrics: validLayout.metrics, insets: validLayout.insets, isHidden: false, transition: .immediate)
}
}, queue: Queue.mainQueue())
self.timer?.start()
}
let iconSize: CGFloat = 42.0 let isLandscape = size.width > size.height
let inset: CGFloat = 4.0 let _ = isLandscape
let buttonFrame = CGRect(origin: CGPoint(x: size.width - iconSize - inset - insets.right, y: size.height - iconSize - inset - insets.bottom), size: CGSize(width: iconSize, height: iconSize))
transition.updateFrame(node: self.wrapperNode, frame: buttonFrame) let currentTime = Int32(CFAbsoluteTimeGetCurrent())
transition.updateFrame(node: self.fullscreenNode, frame: CGRect(origin: CGPoint(), size: buttonFrame.size)) var currentAd: (Int32, Message?)?
for (time, maybeMessage) in program {
if currentTime > time {
currentAd = (time, maybeMessage)
}
}
if let context = self.context, let (initialTimestamp, maybeMessage) = currentAd, let adMessage = maybeMessage, !self.hiddenMessages.contains(adMessage.id) {
if let adAttribute = adMessage.adAttribute {
if !self.reportedMessages.contains(adAttribute.opaqueId) {
self.reportedMessages.insert(adAttribute.opaqueId)
context.engine.messages.markAdAsSeen(opaqueId: adAttribute.opaqueId)
}
}
let sideInset: CGFloat = 16.0
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
let adSize = self.adView.update(
transition: .immediate,
component: AnyComponent(
VideoAdComponent(
context: context,
theme: presentationData.theme,
strings: presentationData.strings,
message: EngineMessage(adMessage),
initialTimestamp: initialTimestamp,
action: { [weak self] available in
guard let self else {
return
}
if available {
self.hiddenMessages.insert(adMessage.id)
if let validLayout = self.validLayout {
self.updateLayout(size: validLayout.size, metrics: validLayout.metrics, insets: validLayout.insets, isHidden: false, transition: .immediate)
}
} else {
self.presentPremiumDemo?()
}
},
adAction: { [weak self] in
if let self, let ad = adMessage.adAttribute {
context.engine.messages.markAdAction(opaqueId: ad.opaqueId, media: false, fullscreen: false)
self.performAction?(.url(url: ad.url, concealed: false))
}
},
moreAction: { [weak self] sourceNode in
if let self {
self.openMoreMenu?(sourceNode, adMessage)
}
}
)
),
environment: {},
containerSize: CGSize(width: size.width - sideInset * 2.0, height: 200.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)
}
transition.updateFrame(view: adView, frame: CGRect(origin: CGPoint(x: floor((size.width - adSize.width) / 2.0), y: size.height - adSize.height - insets.bottom), size: adSize))
}
} else if let adView = self.adView.view, !self.isAnimatingOut {
self.isAnimatingOut = true
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, completion: { _ in
adView.removeFromSuperview()
Queue.mainQueue().after(0.1) {
adView.layer.removeAllAnimations()
}
self.isAnimatingOut = false
})
}
} }
override func animateIn(previousContentNode: GalleryOverlayContentNode?, transition: ContainedViewLayoutTransition) { override func animateIn(previousContentNode: GalleryOverlayContentNode?, transition: ContainedViewLayoutTransition) {
if !self.visibilityAlpha.isZero {
transition.updateAlpha(node: self.wrapperNode, alpha: 1.0)
}
} }
override func animateOut(nextContentNode: GalleryOverlayContentNode?, transition: ContainedViewLayoutTransition, completion: @escaping () -> Void) { override func animateOut(nextContentNode: GalleryOverlayContentNode?, transition: ContainedViewLayoutTransition, completion: @escaping () -> Void) {
transition.updateAlpha(node: self.wrapperNode, alpha: 0.0)
}
override func setVisibilityAlpha(_ alpha: CGFloat) {
super.setVisibilityAlpha(alpha)
self.updateFullscreenButtonVisibility()
}
func updateFullscreenButtonVisibility() {
self.wrapperNode.alpha = self.visibilityAlpha
if let validLayout = self.validLayout {
self.updateLayout(size: validLayout.0, metrics: validLayout.1, insets: validLayout.2, isHidden: false, transition: .animated(duration: 0.3, curve: .easeInOut))
}
}
@objc func toggleFullscreenPressed() {
var toLandscape = false
if let (size, _, _) = self.validLayout, size.width < size.height {
toLandscape = true
}
if toLandscape {
self.wrapperNode.alpha = 0.0
}
self.action?(toLandscape)
} }
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
if !self.wrapperNode.frame.contains(point) { if let adView = self.adView.view, adView.frame.contains(point) {
return nil
}
return super.hitTest(point, with: event) return super.hitTest(point, with: event)
} }
return nil
}
} }
private struct FetchControls { private struct FetchControls {
@ -1150,14 +1255,6 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode {
self?.isInteractingPromise.set(value) self?.isInteractingPromise.set(value)
} }
self.overlayContentNode.action = { [weak self] toLandscape in
guard let self else {
return
}
self.updateControlsVisibility(!toLandscape)
self.updateOrientation(toLandscape ? .landscapeRight : .portrait)
}
self.statusButtonNode.addSubnode(self.statusNode) self.statusButtonNode.addSubnode(self.statusNode)
self.statusButtonNode.addTarget(self, action: #selector(self.statusButtonPressed), forControlEvents: .touchUpInside) self.statusButtonNode.addTarget(self, action: #selector(self.statusButtonPressed), forControlEvents: .touchUpInside)
@ -1262,7 +1359,11 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode {
guard let self else { guard let self else {
return return
} }
self.openMoreMenu(sourceNode: self.moreBarButton.referenceNode, gesture: gesture, isSettings: false) var adMessage: Message?
if case let .message(message, _) = self.item?.contentInfo, let _ = message.adAttribute {
adMessage = message
}
self.openMoreMenu(sourceNode: self.moreBarButton.referenceNode, gesture: gesture, adMessage: adMessage, isSettings: false)
} }
self.titleContentView = GalleryTitleView(frame: CGRect()) self.titleContentView = GalleryTitleView(frame: CGRect())
@ -1524,13 +1625,6 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode {
let _ = isAdaptive let _ = isAdaptive
let dimensions = item.content.dimensions
if dimensions.height > 0.0 {
if dimensions.width / dimensions.height < 1.33 || isAnimated {
self.overlayContentNode.isHidden = true
}
}
if let videoNode = self.videoNode { if let videoNode = self.videoNode {
videoNode.canAttachContent = false videoNode.canAttachContent = false
videoNode.removeFromSupernode() videoNode.removeFromSupernode()
@ -1993,6 +2087,17 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode {
} }
} }
self.footerContentNode.setup(origin: item.originData, caption: item.caption, isAd: isAd) self.footerContentNode.setup(origin: item.originData, caption: item.caption, isAd: isAd)
if let contentInfo = item.contentInfo, case let .message(message, _) = contentInfo {
self.overlayContentNode.performAction = item.performAction
self.overlayContentNode.presentPremiumDemo = { [weak self] in
self?.presentPremiumDemo()
}
self.overlayContentNode.openMoreMenu = { [weak self] sourceNode, adMessage in
self?.openMoreMenu(sourceNode: sourceNode, gesture: nil, adMessage: adMessage, isSettings: false, actionsOnTop: true)
}
self.overlayContentNode.setMessage(context: item.context, message: message)
}
} }
override func controlsVisibilityUpdated(isVisible: Bool) { override func controlsVisibilityUpdated(isVisible: Bool) {
@ -3109,15 +3214,15 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode {
self.moreBarButton.contextAction?(self.moreBarButton.containerNode, nil) self.moreBarButton.contextAction?(self.moreBarButton.containerNode, nil)
} }
private func openMoreMenu(sourceNode: ContextReferenceContentNode, gesture: ContextGesture?, isSettings: Bool) { private func openMoreMenu(sourceNode: ContextReferenceContentNode, gesture: ContextGesture?, adMessage: Message?, isSettings: Bool, actionsOnTop: Bool = false) {
guard let controller = self.baseNavigationController()?.topViewController as? ViewController else { guard let controller = self.baseNavigationController()?.topViewController as? ViewController else {
return return
} }
var dismissImpl: (() -> Void)? var dismissImpl: (() -> Void)?
let items: Signal<(items: [ContextMenuItem], topItems: [ContextMenuItem]), NoError> let items: Signal<(items: [ContextMenuItem], topItems: [ContextMenuItem]), NoError>
if case let .message(message, _) = self.item?.contentInfo, let _ = message.adAttribute { if let adMessage {
items = self.adMenuMainItems() |> map { items in items = self.adMenuMainItems(message: adMessage) |> map { items in
return (items, []) return (items, [])
} }
} else { } else {
@ -3126,7 +3231,7 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode {
}) })
} }
let contextController = ContextController(presentationData: self.presentationData.withUpdated(theme: defaultDarkColorPresentationTheme), source: .reference(HeaderContextReferenceContentSource(controller: controller, sourceNode: sourceNode)), items: items |> map { items in let contextController = ContextController(presentationData: self.presentationData.withUpdated(theme: defaultDarkColorPresentationTheme), source: .reference(HeaderContextReferenceContentSource(controller: controller, sourceNode: sourceNode, actionsOnTop: actionsOnTop)), items: items |> map { items in
if !items.topItems.isEmpty { if !items.topItems.isEmpty {
return ContextController.Items(id: AnyHashable(0), content: .twoLists(items.items, items.topItems)) return ContextController.Items(id: AnyHashable(0), content: .twoLists(items.items, items.topItems))
} else { } else {
@ -3164,8 +3269,22 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode {
return speedList return speedList
} }
private func adMenuMainItems() -> Signal<[ContextMenuItem], NoError> { private func presentPremiumDemo() {
guard case let .message(message, _) = self.item?.contentInfo, let adAttribute = message.adAttribute else { var replaceImpl: ((ViewController) -> Void)?
let controller = self.context.sharedContext.makePremiumDemoController(context: self.context, subject: .noAds, forceDark: true, action: {
let controller = self.context.sharedContext.makePremiumIntroController(context: self.context, source: .ads, forceDark: true, dismissed: nil)
replaceImpl?(controller)
}, dismissed: nil)
replaceImpl = { [weak controller] c in
controller?.replace(with: c)
}
if let navigationController = self.baseNavigationController() {
navigationController.pushViewController(controller)
}
}
private func adMenuMainItems(message: Message) -> Signal<[ContextMenuItem], NoError> {
guard let adAttribute = message.adAttribute else {
return .single([]) return .single([])
} }
@ -3244,18 +3363,8 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode {
actions.append(.action(ContextMenuActionItem(text: presentationData.strings.SponsoredMessageMenu_Hide, textColor: .primary, textLayout: .twoLinesMax, textFont: .custom(font: Font.regular(presentationData.listsFontSize.baseDisplaySize - 1.0), height: nil, verticalOffset: nil), badge: nil, icon: { theme in actions.append(.action(ContextMenuActionItem(text: presentationData.strings.SponsoredMessageMenu_Hide, textColor: .primary, textLayout: .twoLinesMax, textFont: .custom(font: Font.regular(presentationData.listsFontSize.baseDisplaySize - 1.0), height: nil, verticalOffset: nil), badge: nil, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Clear"), color: theme.actionSheet.primaryTextColor) return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Clear"), color: theme.actionSheet.primaryTextColor)
}, iconSource: nil, action: { [weak self] c, _ in }, iconSource: nil, action: { [weak self] c, _ in
c?.dismiss(completion: { c?.dismiss(completion: { [weak self] in
var replaceImpl: ((ViewController) -> Void)? self?.presentPremiumDemo()
let controller = context.sharedContext.makePremiumDemoController(context: context, subject: .noAds, forceDark: true, action: {
let controller = context.sharedContext.makePremiumIntroController(context: context, source: .ads, forceDark: true, dismissed: nil)
replaceImpl?(controller)
}, dismissed: nil)
replaceImpl = { [weak controller] c in
controller?.replace(with: c)
}
if let navigationController = self?.baseNavigationController() as? NavigationController {
navigationController.pushViewController(controller)
}
}) })
}))) })))
} }
@ -3765,7 +3874,7 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode {
} }
@objc private func settingsButtonPressed() { @objc private func settingsButtonPressed() {
self.openMoreMenu(sourceNode: self.settingsBarButton.referenceNode, gesture: nil, isSettings: true) self.openMoreMenu(sourceNode: self.settingsBarButton.referenceNode, gesture: nil, adMessage: nil, isSettings: true)
} }
override func adjustForPreviewing() { override func adjustForPreviewing() {
@ -3775,7 +3884,7 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode {
} }
override func footerContent() -> Signal<(GalleryFooterContentNode?, GalleryOverlayContentNode?), NoError> { override func footerContent() -> Signal<(GalleryFooterContentNode?, GalleryOverlayContentNode?), NoError> {
return .single((self.footerContentNode, nil)) return .single((self.footerContentNode, self.overlayContentNode))
} }
func updatePlaybackRate(_ playbackRate: Double?) { func updatePlaybackRate(_ playbackRate: Double?) {
@ -3940,14 +4049,16 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode {
final class HeaderContextReferenceContentSource: ContextReferenceContentSource { final class HeaderContextReferenceContentSource: ContextReferenceContentSource {
private let controller: ViewController private let controller: ViewController
private let sourceNode: ContextReferenceContentNode private let sourceNode: ContextReferenceContentNode
private let actionsOnTop: Bool
init(controller: ViewController, sourceNode: ContextReferenceContentNode) { init(controller: ViewController, sourceNode: ContextReferenceContentNode, actionsOnTop: Bool) {
self.controller = controller self.controller = controller
self.sourceNode = sourceNode self.sourceNode = sourceNode
self.actionsOnTop = actionsOnTop
} }
func transitionInfo() -> ContextControllerReferenceViewInfo? { func transitionInfo() -> ContextControllerReferenceViewInfo? {
return ContextControllerReferenceViewInfo(referenceView: self.sourceNode.view, contentAreaInScreenSpace: UIScreen.main.bounds) return ContextControllerReferenceViewInfo(referenceView: self.sourceNode.view, contentAreaInScreenSpace: UIScreen.main.bounds, actionsPosition: self.actionsOnTop ? .top : .bottom)
} }
} }

View File

@ -0,0 +1,289 @@
import Foundation
import UIKit
import Display
import SwiftSignalKit
import ComponentFlow
import MultilineTextComponent
import Postbox
import TelegramCore
import TelegramPresentationData
import ContextUI
import PlainButtonComponent
import AvatarNode
import AccountContext
import PhotoResources
final class VideoAdComponent: Component {
let context: AccountContext
let theme: PresentationTheme
let strings: PresentationStrings
let message: EngineMessage
let initialTimestamp: Int32
let action: (Bool) -> Void
let adAction: () -> Void
let moreAction: (ContextReferenceContentNode) -> Void
init(
context: AccountContext,
theme: PresentationTheme,
strings: PresentationStrings,
message: EngineMessage,
initialTimestamp: Int32,
action: @escaping (Bool) -> Void,
adAction: @escaping () -> Void,
moreAction: @escaping (ContextReferenceContentNode) -> Void
) {
self.context = context
self.theme = theme
self.strings = strings
self.message = message
self.initialTimestamp = initialTimestamp
self.action = action
self.adAction = adAction
self.moreAction = moreAction
}
static func ==(lhs: VideoAdComponent, rhs: VideoAdComponent) -> Bool {
if lhs.message != rhs.message {
return false
}
if lhs.initialTimestamp != rhs.initialTimestamp {
return false
}
return true
}
final class View: UIView {
private var component: VideoAdComponent?
private weak var componentState: EmptyComponentState?
private let wrapperView: UIView
private let backgroundView: UIView
private let imageNode: TransformImageNode
private let title = ComponentView<Empty>()
private let text = ComponentView<Empty>()
private let button = ComponentView<Empty>()
private let buttonNode: ContextReferenceContentNode
private let progress = ComponentView<Empty>()
private var adIcon: UIImage?
override init(frame: CGRect) {
self.wrapperView = UIView()
self.wrapperView.clipsToBounds = true
self.wrapperView.layer.cornerRadius = 14.0
if #available(iOS 13.0, *) {
self.wrapperView.layer.cornerCurve = .continuous
}
self.backgroundView = UIVisualEffectView(effect: UIBlurEffect(style: .dark))
self.imageNode = TransformImageNode()
self.imageNode.isUserInteractionEnabled = false
self.buttonNode = ContextReferenceContentNode()
super.init(frame: frame)
self.addSubview(self.wrapperView)
self.wrapperView.addSubview(self.backgroundView)
self.wrapperView.addSubview(self.buttonNode.view)
self.wrapperView.addSubview(self.imageNode.view)
self.backgroundView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.tapped)))
}
required init(coder: NSCoder) {
preconditionFailure()
}
@objc private func tapped() {
if let component = self.component {
component.adAction()
}
}
func update(component: VideoAdComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
let isFirstTime = self.component == nil
self.component = component
let titleString = component.message.author?.compactDisplayTitle ?? ""
var media: Media?
if let photo = component.message.media.first as? TelegramMediaImage {
media = photo
} else if let file = component.message.media.first as? TelegramMediaFile {
media = file
}
if isFirstTime {
let signal: Signal<(TransformImageArguments) -> DrawingContext?, NoError>
if let photo = media as? TelegramMediaImage {
signal = mediaGridMessagePhoto(account: component.context.account, userLocation: .other, photoReference: .standalone(media: photo))
} else if let file = media as? TelegramMediaFile {
signal = mediaGridMessageVideo(postbox: component.context.account.postbox, userLocation: .other, videoReference: .standalone(media: file))
} else {
signal = .complete()
}
self.imageNode.setSignal(signal)
}
let leftInset: CGFloat = media != nil ? 51.0 : 16.0
let rightInset: CGFloat = 60.0
let titleSize = self.title.update(
transition: .immediate,
component: AnyComponent(
MultilineTextComponent(text: .plain(NSAttributedString(string: titleString, font: Font.semibold(14.0), textColor: .white)))
),
environment: {},
containerSize: CGSize(width: availableSize.width - leftInset - rightInset, height: availableSize.height)
)
let textSize = self.text.update(
transition: .immediate,
component: AnyComponent(
MultilineTextComponent(
text: .plain(NSAttributedString(string: component.message.text, font: Font.regular(14.0), textColor: .white)),
maximumNumberOfLines: 2
)
),
environment: {},
containerSize: CGSize(width: availableSize.width - leftInset - rightInset, height: availableSize.height)
)
let contentHeight = titleSize.height + 3.0 + textSize.height
let size = CGSize(width: availableSize.width, height: contentHeight + 24.0)
let imageSize = CGSize(width: 30.0, height: 30.0)
self.imageNode.frame = CGRect(origin: CGPoint(x: 10.0, y: floor((size.height - imageSize.height) / 2.0)), size: imageSize)
let makeLayout = self.imageNode.asyncLayout()
let apply = makeLayout(TransformImageArguments(corners: ImageCorners(radius: imageSize.width / 2.0), imageSize: imageSize, boundingSize: imageSize, intrinsicInsets: .zero))
apply()
let contentOriginY = floor((size.height - contentHeight) / 2.0)
let titleFrame = CGRect(origin: CGPoint(x: leftInset, y: contentOriginY), size: titleSize)
if let titleView = self.title.view {
if titleView.superview == nil {
titleView.isUserInteractionEnabled = false
self.wrapperView.addSubview(titleView)
}
titleView.frame = titleFrame
}
let textFrame = CGRect(origin: CGPoint(x: leftInset, y: contentOriginY + contentHeight - textSize.height), size: textSize)
if let textView = self.text.view {
if textView.superview == nil {
textView.isUserInteractionEnabled = false
self.wrapperView.addSubview(textView)
}
textView.frame = textFrame
}
let color = UIColor(rgb: 0x64d2ff)
if self.adIcon == nil {
self.adIcon = generateAdIcon(color: color, strings: component.strings)
}
let buttonSize = self.button.update(
transition: .immediate,
component: AnyComponent(
PlainButtonComponent(
content: AnyComponent(
Image(image: self.adIcon, contentMode: .center)
),
effectAlignment: .center,
action: { [weak self] in
if let self {
component.moreAction(self.buttonNode)
}
},
animateScale: false
)
),
environment: {},
containerSize: availableSize
)
let buttonFrame = CGRect(origin: CGPoint(x: titleFrame.maxX + 4.0, y: floor(titleFrame.midY - buttonSize.height / 2.0) + 1.0), size: buttonSize)
if let buttonView = self.button.view {
if buttonView.superview == nil {
self.wrapperView.addSubview(buttonView)
}
buttonView.frame = buttonFrame
}
self.buttonNode.frame = buttonFrame
let progressSize = self.progress.update(
transition: .immediate,
component: AnyComponent(
AdRemainingProgressComponent(
initialTimestamp: component.initialTimestamp,
minDisplayDuration: 10,
maxDisplayDuration: 30,
action: { [weak self] available in
guard let self, let component = self.component else {
return
}
component.action(available)
}
)
),
environment: {},
containerSize: availableSize
)
let progressFrame = CGRect(origin: CGPoint(x: size.width - progressSize.width - 16.0, y: floor((size.height - progressSize.height) / 2.0)), size: progressSize)
if let progressView = self.progress.view {
if progressView.superview == nil {
self.wrapperView.addSubview(progressView)
}
progressView.frame = progressFrame
}
self.wrapperView.frame = CGRect(origin: .zero, size: size)
self.backgroundView.frame = CGRect(origin: .zero, size: size)
return size
}
}
func makeView() -> View {
return View(frame: CGRect())
}
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}
private func generateAdIcon(color: UIColor, strings: PresentationStrings) -> UIImage? {
let titleString = NSAttributedString(string: strings.ChatList_Search_Ad, font: Font.regular(11.0), textColor: color, paragraphAlignment: .center)
let stringRect = titleString.boundingRect(with: CGSize(width: 200.0, height: 20.0), options: .usesLineFragmentOrigin, context: nil)
return generateImage(CGSize(width: floor(stringRect.width) + 18.0, height: 15.0), rotatedContext: { size, context in
let bounds = CGRect(origin: CGPoint(), size: size)
context.clear(bounds)
context.setFillColor(color.withMultipliedAlpha(0.1).cgColor)
context.addPath(UIBezierPath(roundedRect: bounds, cornerRadius: size.height / 2.0).cgPath)
context.fillPath()
context.setFillColor(color.cgColor)
let circleSize = CGSize(width: 2.0 - UIScreenPixel, height: 2.0 - UIScreenPixel)
context.fillEllipse(in: CGRect(origin: CGPoint(x: size.width - 8.0, y: 3.0 + UIScreenPixel), size: circleSize))
context.fillEllipse(in: CGRect(origin: CGPoint(x: size.width - 8.0, y: 7.0 - UIScreenPixel), size: circleSize))
context.fillEllipse(in: CGRect(origin: CGPoint(x: size.width - 8.0, y: 10.0), size: circleSize))
let textRect = CGRect(
x: 5.0,
y: (size.height - stringRect.height) / 2.0 - UIScreenPixel,
width: stringRect.width,
height: stringRect.height
)
UIGraphicsPushContext(context)
titleString.draw(in: textRect)
UIGraphicsPopContext()
})
}

View File

@ -1360,7 +1360,7 @@ public final class ListMessageFileItemNode: ListMessageNode {
switch fetchStatus { switch fetchStatus {
case let .Fetching(_, progress): case let .Fetching(_, progress):
if item.isDownloadList { if item.isDownloadList {
iconStatusState = .progress(value: CGFloat(progress), cancelEnabled: true, appearance: nil) iconStatusState = .progress(value: CGFloat(progress), cancelEnabled: true, appearance: nil, animateRotation: true)
} }
case .Local: case .Local:
if isAudio || isInstantVideo { if isAudio || isInstantVideo {

View File

@ -32,7 +32,7 @@ public enum SemanticStatusNodeState: Equatable {
case play case play
case pause case pause
case check(appearance: CheckAppearance?) case check(appearance: CheckAppearance?)
case progress(value: CGFloat?, cancelEnabled: Bool, appearance: ProgressAppearance?) case progress(value: CGFloat?, cancelEnabled: Bool, appearance: ProgressAppearance?, animateRotation: Bool)
case secretTimeout(position: Double, duration: Double, generationTimestamp: Double, appearance: ProgressAppearance?) case secretTimeout(position: Double, duration: Double, generationTimestamp: Double, appearance: ProgressAppearance?)
case customIcon(UIImage) case customIcon(UIImage)
} }
@ -136,12 +136,12 @@ private extension SemanticStatusNodeState {
} else { } else {
return SemanticStatusNodeSecretTimeoutContext(position: position, duration: duration, generationTimestamp: generationTimestamp, appearance: appearance) return SemanticStatusNodeSecretTimeoutContext(position: position, duration: duration, generationTimestamp: generationTimestamp, appearance: appearance)
} }
case let .progress(value, cancelEnabled, appearance): case let .progress(value, cancelEnabled, appearance, animateRotation):
if let current = current as? SemanticStatusNodeProgressContext, current.displayCancel == cancelEnabled { if let current = current as? SemanticStatusNodeProgressContext, current.displayCancel == cancelEnabled {
current.updateValue(value: value) current.updateValue(value: value)
return current return current
} else { } else {
return SemanticStatusNodeProgressContext(value: value, displayCancel: cancelEnabled, appearance: appearance) return SemanticStatusNodeProgressContext(value: value, displayCancel: cancelEnabled, appearance: appearance, animateRotation: animateRotation)
} }
} }
} }

View File

@ -25,13 +25,15 @@ final class SemanticStatusNodeProgressContext: SemanticStatusNodeStateContext {
let value: CGFloat? let value: CGFloat?
let displayCancel: Bool let displayCancel: Bool
let appearance: SemanticStatusNodeState.ProgressAppearance? let appearance: SemanticStatusNodeState.ProgressAppearance?
let animateRotation: Bool
let timestamp: Double let timestamp: Double
init(transitionFraction: CGFloat, value: CGFloat?, displayCancel: Bool, appearance: SemanticStatusNodeState.ProgressAppearance?, timestamp: Double) { init(transitionFraction: CGFloat, value: CGFloat?, displayCancel: Bool, appearance: SemanticStatusNodeState.ProgressAppearance?, animateRotation: Bool, timestamp: Double) {
self.transitionFraction = transitionFraction self.transitionFraction = transitionFraction
self.value = value self.value = value
self.displayCancel = displayCancel self.displayCancel = displayCancel
self.appearance = appearance self.appearance = appearance
self.animateRotation = animateRotation
self.timestamp = timestamp self.timestamp = timestamp
super.init() super.init()
@ -59,6 +61,10 @@ final class SemanticStatusNodeProgressContext: SemanticStatusNodeStateContext {
var endAngle: CGFloat var endAngle: CGFloat
if let value = self.value { if let value = self.value {
progress = value progress = value
if !self.animateRotation {
progress = 1.0 - progress
}
startAngle = -CGFloat.pi / 2.0 startAngle = -CGFloat.pi / 2.0
endAngle = CGFloat(progress) * 2.0 * CGFloat.pi + startAngle endAngle = CGFloat(progress) * 2.0 * CGFloat.pi + startAngle
@ -98,14 +104,16 @@ final class SemanticStatusNodeProgressContext: SemanticStatusNodeStateContext {
pathDiameter = diameter - lineWidth - 2.5 * 2.0 pathDiameter = diameter - lineWidth - 2.5 * 2.0
} }
if self.animateRotation {
var angle = self.timestamp.truncatingRemainder(dividingBy: Double.pi * 2.0) var angle = self.timestamp.truncatingRemainder(dividingBy: Double.pi * 2.0)
angle *= 4.0 angle *= 4.0
context.translateBy(x: diameter / 2.0, y: diameter / 2.0) context.translateBy(x: diameter / 2.0, y: diameter / 2.0)
context.rotate(by: CGFloat(angle.truncatingRemainder(dividingBy: Double.pi * 2.0))) context.rotate(by: CGFloat(angle.truncatingRemainder(dividingBy: Double.pi * 2.0)))
context.translateBy(x: -diameter / 2.0, y: -diameter / 2.0) context.translateBy(x: -diameter / 2.0, y: -diameter / 2.0)
}
let path = UIBezierPath(arcCenter: CGPoint(x: diameter / 2.0, y: diameter / 2.0), radius: pathDiameter / 2.0, startAngle: startAngle, endAngle: endAngle, clockwise: true) let path = UIBezierPath(arcCenter: CGPoint(x: diameter / 2.0, y: diameter / 2.0), radius: pathDiameter / 2.0, startAngle: startAngle, endAngle: endAngle, clockwise: self.animateRotation)
path.lineWidth = lineWidth path.lineWidth = lineWidth
path.lineCapStyle = .round path.lineCapStyle = .round
path.stroke() path.stroke()
@ -147,6 +155,7 @@ final class SemanticStatusNodeProgressContext: SemanticStatusNodeStateContext {
var value: CGFloat? var value: CGFloat?
let displayCancel: Bool let displayCancel: Bool
let appearance: SemanticStatusNodeState.ProgressAppearance? let appearance: SemanticStatusNodeState.ProgressAppearance?
let animateRotation: Bool
var transition: SemanticStatusNodeProgressTransition? var transition: SemanticStatusNodeProgressTransition?
var isAnimating: Bool { var isAnimating: Bool {
@ -155,10 +164,11 @@ final class SemanticStatusNodeProgressContext: SemanticStatusNodeStateContext {
var requestUpdate: () -> Void = {} var requestUpdate: () -> Void = {}
init(value: CGFloat?, displayCancel: Bool, appearance: SemanticStatusNodeState.ProgressAppearance?) { init(value: CGFloat?, displayCancel: Bool, appearance: SemanticStatusNodeState.ProgressAppearance?, animateRotation: Bool) {
self.value = value self.value = value
self.displayCancel = displayCancel self.displayCancel = displayCancel
self.appearance = appearance self.appearance = appearance
self.animateRotation = animateRotation
} }
func drawingState(transitionFraction: CGFloat) -> SemanticStatusNodeStateDrawingState { func drawingState(transitionFraction: CGFloat) -> SemanticStatusNodeStateDrawingState {
@ -178,7 +188,7 @@ final class SemanticStatusNodeProgressContext: SemanticStatusNodeStateContext {
} else { } else {
resolvedValue = nil resolvedValue = nil
} }
return DrawingState(transitionFraction: transitionFraction, value: resolvedValue, displayCancel: self.displayCancel, appearance: self.appearance, timestamp: timestamp) return DrawingState(transitionFraction: transitionFraction, value: resolvedValue, displayCancel: self.displayCancel, appearance: self.appearance, animateRotation: self.animateRotation, timestamp: timestamp)
} }
func maskView() -> UIView? { func maskView() -> UIView? {

View File

@ -160,7 +160,7 @@ final class CallControllerButtonItemNode: HighlightTrackingButtonNode {
let statusNode = SemanticStatusNode(backgroundNodeColor: .white, foregroundNodeColor: .clear, cutout: statusFrame.insetBy(dx: 8.0, dy: 8.0)) let statusNode = SemanticStatusNode(backgroundNodeColor: .white, foregroundNodeColor: .clear, cutout: statusFrame.insetBy(dx: 8.0, dy: 8.0))
self.statusNode = statusNode self.statusNode = statusNode
self.contentContainer.insertSubnode(statusNode, belowSubnode: self.contentNode) self.contentContainer.insertSubnode(statusNode, belowSubnode: self.contentNode)
statusNode.transitionToState(.progress(value: nil, cancelEnabled: false, appearance: SemanticStatusNodeState.ProgressAppearance(inset: 4.0, lineWidth: 3.0)), animated: false, completion: {}) statusNode.transitionToState(.progress(value: nil, cancelEnabled: false, appearance: SemanticStatusNodeState.ProgressAppearance(inset: 4.0, lineWidth: 3.0), animateRotation: true), animated: false, completion: {})
} }
if let statusNode = self.statusNode { if let statusNode = self.statusNode {
statusNode.frame = statusFrame statusNode.frame = statusFrame

View File

@ -133,7 +133,16 @@ public struct PresentationResourcesItemList {
public static func itemListReorderIndicatorIcon(_ theme: PresentationTheme) -> UIImage? { public static func itemListReorderIndicatorIcon(_ theme: PresentationTheme) -> UIImage? {
return theme.image(PresentationResourceKey.itemListReorderIndicatorIcon.rawValue, { theme in return theme.image(PresentationResourceKey.itemListReorderIndicatorIcon.rawValue, { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Item List/Reorder"), color: theme.list.controlSecondaryColor) return generateImage(CGSize(width: 17.0, height: 14.0), rotatedContext: { size, context in
context.clear(CGRect(origin: CGPoint(), size: size))
context.setFillColor(theme.list.itemBlocksSeparatorColor.cgColor)
let lineHeight = 1.0 + UIScreenPixel
context.addPath(CGPath(roundedRect: CGRect(x: 0.0, y: UIScreenPixel, width: 17.0, height: lineHeight), cornerWidth: lineHeight / 2.0, cornerHeight: lineHeight / 2.0, transform: nil))
context.addPath(CGPath(roundedRect: CGRect(x: 0.0, y: UIScreenPixel + 6.0, width: 17.0, height: lineHeight), cornerWidth: lineHeight / 2.0, cornerHeight: lineHeight / 2.0, transform: nil))
context.addPath(CGPath(roundedRect: CGRect(x: 0.0, y: UIScreenPixel + 12.0, width: 17.0, height: lineHeight), cornerWidth: lineHeight / 2.0, cornerHeight: lineHeight / 2.0, transform: nil))
context.fillPath()
})
}) })
} }

View File

@ -323,7 +323,7 @@ private final class ScrollContent: CombinedComponent {
let infoBackground = infoBackground.update( let infoBackground = infoBackground.update(
component: RoundedRectangle( component: RoundedRectangle(
color: theme.list.blocksBackgroundColor, color: theme.overallDarkAppearance ? theme.list.itemModalBlocksBackgroundColor : theme.list.blocksBackgroundColor,
cornerRadius: 10.0 cornerRadius: 10.0
), ),
availableSize: CGSize(width: context.availableSize.width - sideInset * 2.0, height: totalInfoHeight), availableSize: CGSize(width: context.availableSize.width - sideInset * 2.0, height: totalInfoHeight),
@ -423,11 +423,13 @@ private final class ContainerComponent: CombinedComponent {
let environment = context.environment[EnvironmentType.self] let environment = context.environment[EnvironmentType.self]
let state = context.state let state = context.state
let theme = environment.theme
let openContextMenu = context.component.openContextMenu let openContextMenu = context.component.openContextMenu
let dismiss = context.component.dismiss let dismiss = context.component.dismiss
let background = background.update( let background = background.update(
component: Rectangle(color: environment.theme.list.plainBackgroundColor), component: Rectangle(color: theme.overallDarkAppearance ? theme.list.modalBlocksBackgroundColor : theme.list.plainBackgroundColor),
environment: {}, environment: {},
availableSize: context.availableSize, availableSize: context.availableSize,
transition: context.transition transition: context.transition
@ -696,7 +698,7 @@ public class AdsInfoScreen: ViewController {
super.init() super.init()
self.containerView.clipsToBounds = true self.containerView.clipsToBounds = true
self.containerView.backgroundColor = self.presentationData.theme.overallDarkAppearance ? self.presentationData.theme.list.blocksBackgroundColor : self.presentationData.theme.list.plainBackgroundColor self.containerView.backgroundColor = self.presentationData.theme.overallDarkAppearance ? self.presentationData.theme.list.modalBlocksBackgroundColor : self.presentationData.theme.list.plainBackgroundColor
self.addSubnode(self.dim) self.addSubnode(self.dim)

View File

@ -92,8 +92,8 @@ private final class SheetPageContent: CombinedComponent {
let state = context.state let state = context.state
let presentationData = component.context.sharedContext.currentPresentationData.with { $0 } let presentationData = component.context.sharedContext.currentPresentationData.with { $0 }
let theme = presentationData.theme let theme = environment.theme
let strings = presentationData.strings let strings = environment.strings
let sideInset: CGFloat = 16.0 + environment.safeInsets.left let sideInset: CGFloat = 16.0 + environment.safeInsets.left

View File

@ -684,6 +684,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI
private struct HighlightedState: Equatable { private struct HighlightedState: Equatable {
var quote: ChatInterfaceHighlightedState.Quote? var quote: ChatInterfaceHighlightedState.Quote?
var todoTaskId: Int32?
} }
private var highlightedState: HighlightedState? private var highlightedState: HighlightedState?
@ -5903,7 +5904,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI
if let highlightedStateValue = item.controllerInteraction.highlightedState { if let highlightedStateValue = item.controllerInteraction.highlightedState {
for (message, _) in item.content { for (message, _) in item.content {
if highlightedStateValue.messageStableId == message.stableId { if highlightedStateValue.messageStableId == message.stableId {
highlightedState = HighlightedState(quote: highlightedStateValue.quote) highlightedState = HighlightedState(quote: highlightedStateValue.quote, todoTaskId: highlightedStateValue.todoTaskId)
break break
} }
} }
@ -5915,6 +5916,8 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI
for contentNode in self.contentNodes { for contentNode in self.contentNodes {
if let contentNode = contentNode as? ChatMessageTextBubbleContentNode { if let contentNode = contentNode as? ChatMessageTextBubbleContentNode {
contentNode.updateQuoteTextHighlightState(text: nil, offset: nil, color: .clear, animated: true) contentNode.updateQuoteTextHighlightState(text: nil, offset: nil, color: .clear, animated: true)
} else if let _ = contentNode as? ChatMessageTodoBubbleContentNode {
} }
} }
@ -5998,6 +6001,34 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI
} }
} }
}) })
} else if highlightedState?.todoTaskId != nil {
Queue.mainQueue().after(0.3, { [weak self] in
guard let self, let _ = self.item, let backgroundHighlightNode = self.backgroundHighlightNode else {
return
}
if let highlightedState = self.highlightedState, let todoTaskId = highlightedState.todoTaskId {
let transition: ContainedViewLayoutTransition = .animated(duration: 0.4, curve: .spring)
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)
break
}
}
if let taskFrame {
self.backgroundHighlightNode = nil
backgroundHighlightNode.updateLayout(size: taskFrame.size, transition: transition)
transition.updateFrame(node: backgroundHighlightNode, frame: taskFrame)
backgroundHighlightNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.1, delay: 0.05, removeOnCompletion: false, completion: { [weak backgroundHighlightNode] _ in
backgroundHighlightNode?.removeFromSupernode()
})
}
}
})
} }
} }
} else { } else {

View File

@ -1645,7 +1645,7 @@ public final class ChatMessageInteractiveFileNode: ASDisplayNode {
if let updatingMedia = arguments.attributes.updatingMedia, case .update = updatingMedia.media { if let updatingMedia = arguments.attributes.updatingMedia, case .update = updatingMedia.media {
let adjustedProgress = max(CGFloat(updatingMedia.progress), 0.027) let adjustedProgress = max(CGFloat(updatingMedia.progress), 0.027)
state = .progress(value: CGFloat(adjustedProgress), cancelEnabled: true, appearance: nil) state = .progress(value: CGFloat(adjustedProgress), cancelEnabled: true, appearance: nil, animateRotation: true)
} else { } else {
switch resourceStatus.mediaStatus { switch resourceStatus.mediaStatus {
case var .fetchStatus(fetchStatus): case var .fetchStatus(fetchStatus):
@ -1668,7 +1668,7 @@ public final class ChatMessageInteractiveFileNode: ASDisplayNode {
if message.groupingKey != nil, adjustedProgress.isEqual(to: 1.0), (message.flags.contains(.Unsent) || wasCheck) { if message.groupingKey != nil, adjustedProgress.isEqual(to: 1.0), (message.flags.contains(.Unsent) || wasCheck) {
state = .check(appearance: nil) state = .check(appearance: nil)
} else { } else {
state = .progress(value: CGFloat(adjustedProgress), cancelEnabled: true, appearance: nil) state = .progress(value: CGFloat(adjustedProgress), cancelEnabled: true, appearance: nil, animateRotation: true)
} }
} }
case .Local: case .Local:
@ -1712,7 +1712,7 @@ public final class ChatMessageInteractiveFileNode: ASDisplayNode {
switch resourceStatus.fetchStatus { switch resourceStatus.fetchStatus {
case let .Fetching(_, progress): case let .Fetching(_, progress):
let adjustedProgress = max(progress, 0.027) let adjustedProgress = max(progress, 0.027)
streamingState = .progress(value: CGFloat(adjustedProgress), cancelEnabled: true, appearance: .init(inset: 1.0, lineWidth: 2.0)) streamingState = .progress(value: CGFloat(adjustedProgress), cancelEnabled: true, appearance: .init(inset: 1.0, lineWidth: 2.0), animateRotation: true)
case .Local: case .Local:
streamingState = .none streamingState = .none
case .Remote, .Paused: case .Remote, .Paused:
@ -1730,7 +1730,7 @@ public final class ChatMessageInteractiveFileNode: ASDisplayNode {
} else if case .check = state { } else if case .check = state {
} else { } else {
let adjustedProgress: CGFloat = 0.027 let adjustedProgress: CGFloat = 0.027
state = .progress(value: CGFloat(adjustedProgress), cancelEnabled: true, appearance: .init(inset: 1.0, lineWidth: 2.0)) state = .progress(value: CGFloat(adjustedProgress), cancelEnabled: true, appearance: .init(inset: 1.0, lineWidth: 2.0), animateRotation: true)
} }
} }

View File

@ -1323,13 +1323,13 @@ public class ChatMessageInteractiveInstantVideoNode: ASDisplayNode {
case let .Fetching(_, progress): case let .Fetching(_, progress):
if let isBuffering = isBuffering { if let isBuffering = isBuffering {
if isBuffering { if isBuffering {
state = .progress(value: nil, cancelEnabled: true, appearance: nil) state = .progress(value: nil, cancelEnabled: true, appearance: nil, animateRotation: true)
} else { } else {
state = .none state = .none
} }
} else { } else {
let adjustedProgress = max(progress, 0.027) let adjustedProgress = max(progress, 0.027)
state = .progress(value: CGFloat(adjustedProgress), cancelEnabled: true, appearance: nil) state = .progress(value: CGFloat(adjustedProgress), cancelEnabled: true, appearance: nil, animateRotation: true)
} }
case .Local: case .Local:
if isViewOnceMessage { if isViewOnceMessage {
@ -1355,7 +1355,7 @@ public class ChatMessageInteractiveInstantVideoNode: ASDisplayNode {
isLocal = true isLocal = true
} }
if (isBuffering ?? false) && !isLocal { if (isBuffering ?? false) && !isLocal {
state = .progress(value: nil, cancelEnabled: true, appearance: nil) state = .progress(value: nil, cancelEnabled: true, appearance: nil, animateRotation: true)
} else { } else {
state = .none state = .none
} }

View File

@ -1297,7 +1297,6 @@ public class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode {
public func updateQuoteTextHighlightState(text: String?, offset: Int?, color: UIColor, animated: Bool) { public func updateQuoteTextHighlightState(text: String?, offset: Int?, color: UIColor, animated: Bool) {
var rectsSet: [CGRect] = [] var rectsSet: [CGRect] = []
if let text = text, !text.isEmpty, let cachedLayout = self.textNode.textNode.cachedLayout, let string = cachedLayout.attributedString?.string { if let text = text, !text.isEmpty, let cachedLayout = self.textNode.textNode.cachedLayout, let string = cachedLayout.attributedString?.string {
let quoteRange = findQuoteRange(string: string, quoteText: text, offset: offset) let quoteRange = findQuoteRange(string: string, quoteText: text, offset: offset)
if let quoteRange, let rects = cachedLayout.rangeRects(in: quoteRange)?.rects, !rects.isEmpty { if let quoteRange, let rects = cachedLayout.rangeRects(in: quoteRange)?.rects, !rects.isEmpty {
rectsSet = rects rectsSet = rects

View File

@ -1241,4 +1241,13 @@ public class ChatMessageTodoBubbleContentNode: ChatMessageBubbleContentNode {
} }
return nil return nil
} }
public func taskItemFrame(id: Int32) -> CGRect? {
for node in self.optionNodes {
if node.option?.id == id {
return node.frame
}
}
return nil
}
} }

View File

@ -31,10 +31,12 @@ public struct ChatInterfaceHighlightedState: Equatable {
public let messageStableId: UInt32 public let messageStableId: UInt32
public let quote: Quote? public let quote: Quote?
public let todoTaskId: Int32?
public init(messageStableId: UInt32, quote: Quote?) { public init(messageStableId: UInt32, quote: Quote?, todoTaskId: Int32?) {
self.messageStableId = messageStableId self.messageStableId = messageStableId
self.quote = quote self.quote = quote
self.todoTaskId = todoTaskId
} }
} }
@ -96,13 +98,15 @@ public struct NavigateToMessageParams {
public var timestamp: Double? public var timestamp: Double?
public var quote: Quote? public var quote: Quote?
public var todoTaskId: Int32?
public var progress: Promise<Bool>? public var progress: Promise<Bool>?
public var forceNew: Bool public var forceNew: Bool
public var setupReply: Bool public var setupReply: Bool
public init(timestamp: Double?, quote: Quote?, progress: Promise<Bool>? = nil, forceNew: Bool = false, setupReply: Bool = false) { public init(timestamp: Double?, quote: Quote?, todoTaskId: Int32? = nil, progress: Promise<Bool>? = nil, forceNew: Bool = false, setupReply: Bool = false) {
self.timestamp = timestamp self.timestamp = timestamp
self.quote = quote self.quote = quote
self.todoTaskId = todoTaskId
self.progress = progress self.progress = progress
self.forceNew = forceNew self.forceNew = forceNew
self.setupReply = setupReply self.setupReply = setupReply

View File

@ -109,6 +109,9 @@ final class ComposeTodoScreenComponent: Component {
private var currentEditingTag: AnyObject? private var currentEditingTag: AnyObject?
private var reorderRecognizer: ReorderGestureRecognizer?
private var reorderingItem: (id: AnyHashable, snapshotView: UIView, backgroundView: UIView, initialPosition: CGPoint, position: CGPoint)?
var isAppendableByOthers = false var isAppendableByOthers = false
var isCompletableByOthers = false var isCompletableByOthers = false
@ -129,6 +132,39 @@ final class ComposeTodoScreenComponent: Component {
self.scrollView.delegate = self self.scrollView.delegate = self
self.addSubview(self.scrollView) 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) { required init?(coder: NSCoder) {
@ -143,6 +179,124 @@ final class ComposeTodoScreenComponent: Component {
self.scrollView.setContentOffset(CGPoint(), animated: true) self.scrollView.setContentOffset(CGPoint(), animated: true)
} }
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 {
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) {
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.todoItemsSectionContainer.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.todoItemsSectionContainer.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.todoItemsSectionContainer.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.todoItems.firstIndex(where: { AnyHashable($0.id) == itemId }), let reorderingItem = self.todoItems.first(where: { AnyHashable($0.id) == id }) {
self.reorderIfPossible(item: reorderingItem, toIndex: targetIndex)
}
break
}
}
}
}
}
private func reorderIfPossible(item: TodoItem, toIndex: Int) {
guard let component = self.component else {
return
}
let targetItem = self.todoItems[toIndex]
guard targetItem.textInputState.hasText else {
return
}
var canEdit = true
if let _ = component.initialData.existingTodo, !component.initialData.canEdit {
canEdit = false
}
if !canEdit, let existingTodo = component.initialData.existingTodo, existingTodo.items.contains(where: { $0.id == targetItem.id }) {
return
}
if let fromIndex = self.todoItems.firstIndex(where: { $0.id == item.id }) {
self.todoItems[toIndex] = item
self.todoItems[fromIndex] = targetItem
HapticFeedback().tap()
self.state?.updated(transition: .spring(duration: 0.4))
}
}
func validatedInput() -> TelegramMediaTodo? { func validatedInput() -> TelegramMediaTodo? {
if self.todoTextInputState.text.length == 0 { if self.todoTextInputState.text.length == 0 {
return nil return nil
@ -432,6 +586,8 @@ final class ComposeTodoScreenComponent: Component {
let themeUpdated = self.environment?.theme !== environment.theme let themeUpdated = self.environment?.theme !== environment.theme
self.environment = environment self.environment = environment
let theme = environment.theme.withModalBlocksBackground()
let isFirstTime = self.component == nil let isFirstTime = self.component == nil
if self.component == nil { if self.component == nil {
if let existingTodo = component.initialData.existingTodo { if let existingTodo = component.initialData.existingTodo {
@ -599,7 +755,7 @@ final class ComposeTodoScreenComponent: Component {
let sectionSpacing: CGFloat = 24.0 let sectionSpacing: CGFloat = 24.0
if themeUpdated { if themeUpdated {
self.backgroundColor = environment.theme.list.blocksBackgroundColor self.backgroundColor = theme.list.blocksBackgroundColor
} }
let presentationData = component.context.sharedContext.currentPresentationData.with { $0 } let presentationData = component.context.sharedContext.currentPresentationData.with { $0 }
@ -617,7 +773,7 @@ final class ComposeTodoScreenComponent: Component {
todoTextSectionItems.append(AnyComponentWithIdentity(id: 0, component: AnyComponent(ListComposePollOptionComponent( todoTextSectionItems.append(AnyComponentWithIdentity(id: 0, component: AnyComponent(ListComposePollOptionComponent(
externalState: self.todoTextInputState, externalState: self.todoTextInputState,
context: component.context, context: component.context,
theme: environment.theme, theme: theme,
strings: environment.strings, strings: environment.strings,
isEnabled: canEdit, isEnabled: canEdit,
resetText: self.resetTodoText.flatMap { resetText in resetText: self.resetTodoText.flatMap { resetText in
@ -625,7 +781,6 @@ final class ComposeTodoScreenComponent: Component {
}, },
assumeIsEditing: self.inputMediaNodeTargetTag === self.todoTextFieldTag, assumeIsEditing: self.inputMediaNodeTargetTag === self.todoTextFieldTag,
characterLimit: component.initialData.maxTodoTextLength, characterLimit: component.initialData.maxTodoTextLength,
canReorder: canEdit,
emptyLineHandling: .allowed, emptyLineHandling: .allowed,
returnKeyAction: { [weak self] in returnKeyAction: { [weak self] in
guard let self else { guard let self else {
@ -661,7 +816,7 @@ final class ComposeTodoScreenComponent: Component {
let todoTextSectionSize = self.todoTextSection.update( let todoTextSectionSize = self.todoTextSection.update(
transition: transition, transition: transition,
component: AnyComponent(ListSectionComponent( component: AnyComponent(ListSectionComponent(
theme: environment.theme, theme: theme,
header: nil, header: nil,
footer: nil, footer: nil,
items: todoTextSectionItems items: todoTextSectionItems
@ -701,7 +856,7 @@ final class ComposeTodoScreenComponent: Component {
todoItemsSectionItems.append(AnyComponentWithIdentity(id: todoItem.id, component: AnyComponent(ListComposePollOptionComponent( todoItemsSectionItems.append(AnyComponentWithIdentity(id: todoItem.id, component: AnyComponent(ListComposePollOptionComponent(
externalState: todoItem.textInputState, externalState: todoItem.textInputState,
context: component.context, context: component.context,
theme: environment.theme, theme: theme,
strings: environment.strings, strings: environment.strings,
isEnabled: isEnabled, isEnabled: isEnabled,
resetText: todoItem.resetText.flatMap { resetText in resetText: todoItem.resetText.flatMap { resetText in
@ -709,6 +864,7 @@ final class ComposeTodoScreenComponent: Component {
}, },
assumeIsEditing: self.inputMediaNodeTargetTag === todoItem.textFieldTag, assumeIsEditing: self.inputMediaNodeTargetTag === todoItem.textFieldTag,
characterLimit: component.initialData.maxTodoItemLength, characterLimit: component.initialData.maxTodoItemLength,
canReorder: isEnabled,
emptyLineHandling: .notAllowed, emptyLineHandling: .notAllowed,
returnKeyAction: { [weak self] in returnKeyAction: { [weak self] in
guard let self else { guard let self else {
@ -788,6 +944,12 @@ final class ComposeTodoScreenComponent: Component {
size: itemSize, size: itemSize,
transition: itemTransition 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.todoItems.count { for i in 0 ..< self.todoItems.count {
@ -836,7 +998,7 @@ final class ComposeTodoScreenComponent: Component {
let todoItemsSectionUpdateResult = self.todoItemsSectionContainer.update( let todoItemsSectionUpdateResult = self.todoItemsSectionContainer.update(
configuration: ListSectionContentView.Configuration( configuration: ListSectionContentView.Configuration(
theme: environment.theme, theme: theme,
displaySeparators: true, displaySeparators: true,
extendsItemHighlightToSection: false, extendsItemHighlightToSection: false,
background: .all background: .all
@ -854,7 +1016,7 @@ final class ComposeTodoScreenComponent: Component {
text: .plain(NSAttributedString( text: .plain(NSAttributedString(
string: "TO DO LIST", string: "TO DO LIST",
font: Font.regular(presentationData.listsFontSize.itemListBaseHeaderFontSize), font: Font.regular(presentationData.listsFontSize.itemListBaseHeaderFontSize),
textColor: environment.theme.list.freeTextColor textColor: theme.list.freeTextColor
)), )),
maximumNumberOfLines: 0 maximumNumberOfLines: 0
)), )),
@ -900,19 +1062,19 @@ final class ComposeTodoScreenComponent: Component {
} }
let todoItemsComponent: AnyComponent<Empty> let todoItemsComponent: AnyComponent<Empty>
if !"".isEmpty, todoItemsLimitReached { if todoItemsLimitReached {
todoItemsFooterTransition = todoItemsFooterTransition.withAnimation(.none) todoItemsFooterTransition = todoItemsFooterTransition.withAnimation(.none)
let textFont = Font.regular(presentationData.listsFontSize.itemListBaseHeaderFontSize) let textFont = Font.regular(presentationData.listsFontSize.itemListBaseHeaderFontSize)
let boldTextFont = Font.semibold(presentationData.listsFontSize.itemListBaseHeaderFontSize) let boldTextFont = Font.semibold(presentationData.listsFontSize.itemListBaseHeaderFontSize)
let textColor = environment.theme.list.freeTextColor let textColor = theme.list.freeTextColor
todoItemsComponent = AnyComponent(MultilineTextComponent( todoItemsComponent = AnyComponent(MultilineTextComponent(
text: .markdown( text: .markdown(
text: "Limit of tasks reached. You can increase the limit to **20 tasks** by subscribing to [Telegram Premium]().", text: "Maximum number of tasks reached.",
attributes: MarkdownAttributes( attributes: MarkdownAttributes(
body: MarkdownAttributeSet(font: textFont, textColor: textColor), body: MarkdownAttributeSet(font: textFont, textColor: textColor),
bold: MarkdownAttributeSet(font: boldTextFont, textColor: textColor), bold: MarkdownAttributeSet(font: boldTextFont, textColor: textColor),
link: MarkdownAttributeSet(font: textFont, textColor: environment.theme.list.itemAccentColor), link: MarkdownAttributeSet(font: textFont, textColor: theme.list.itemAccentColor),
linkAttribute: { contents in linkAttribute: { contents in
return (TelegramTextAttributes.URL, contents) return (TelegramTextAttributes.URL, contents)
} }
@ -969,7 +1131,7 @@ final class ComposeTodoScreenComponent: Component {
todoItemsComponent = AnyComponent(AnimatedTextComponent( todoItemsComponent = AnyComponent(AnimatedTextComponent(
font: Font.regular(presentationData.listsFontSize.itemListBaseHeaderFontSize), font: Font.regular(presentationData.listsFontSize.itemListBaseHeaderFontSize),
color: environment.theme.list.freeTextColor, color: theme.list.freeTextColor,
items: todoItemsFooterItems items: todoItemsFooterItems
)) ))
} }
@ -1004,13 +1166,13 @@ final class ComposeTodoScreenComponent: Component {
var todoSettingsSectionItems: [AnyComponentWithIdentity<Empty>] = [] var todoSettingsSectionItems: [AnyComponentWithIdentity<Empty>] = []
if canEdit && component.peer.id != component.context.account.peerId { if canEdit && component.peer.id != component.context.account.peerId {
todoSettingsSectionItems.append(AnyComponentWithIdentity(id: "completable", component: AnyComponent(ListActionItemComponent( todoSettingsSectionItems.append(AnyComponentWithIdentity(id: "completable", component: AnyComponent(ListActionItemComponent(
theme: environment.theme, theme: theme,
title: AnyComponent(VStack([ title: AnyComponent(VStack([
AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent(MultilineTextComponent( AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString( text: .plain(NSAttributedString(
string: "Allow Others to Mark as Done", string: "Allow Others to Mark as Done",
font: Font.regular(presentationData.listsFontSize.baseDisplaySize), font: Font.regular(presentationData.listsFontSize.baseDisplaySize),
textColor: environment.theme.list.itemPrimaryTextColor textColor: theme.list.itemPrimaryTextColor
)), )),
maximumNumberOfLines: 1 maximumNumberOfLines: 1
))), ))),
@ -1027,13 +1189,13 @@ final class ComposeTodoScreenComponent: Component {
if self.isCompletableByOthers { if self.isCompletableByOthers {
todoSettingsSectionItems.append(AnyComponentWithIdentity(id: "editable", component: AnyComponent(ListActionItemComponent( todoSettingsSectionItems.append(AnyComponentWithIdentity(id: "editable", component: AnyComponent(ListActionItemComponent(
theme: environment.theme, theme: theme,
title: AnyComponent(VStack([ title: AnyComponent(VStack([
AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent(MultilineTextComponent( AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString( text: .plain(NSAttributedString(
string: "Allow Others to Add Tasks", string: "Allow Others to Add Tasks",
font: Font.regular(presentationData.listsFontSize.baseDisplaySize), font: Font.regular(presentationData.listsFontSize.baseDisplaySize),
textColor: environment.theme.list.itemPrimaryTextColor textColor: theme.list.itemPrimaryTextColor
)), )),
maximumNumberOfLines: 1 maximumNumberOfLines: 1
))), ))),
@ -1054,7 +1216,7 @@ final class ComposeTodoScreenComponent: Component {
let todoSettingsSectionSize = self.todoSettingsSection.update( let todoSettingsSectionSize = self.todoSettingsSection.update(
transition: transition, transition: transition,
component: AnyComponent(ListSectionComponent( component: AnyComponent(ListSectionComponent(
theme: environment.theme, theme: theme,
header: nil, header: nil,
footer: nil, footer: nil,
items: todoSettingsSectionItems items: todoSettingsSectionItems
@ -1164,7 +1326,7 @@ final class ComposeTodoScreenComponent: Component {
component: AnyComponent(EmojiSuggestionsComponent( component: AnyComponent(EmojiSuggestionsComponent(
context: component.context, context: component.context,
userLocation: .other, userLocation: .other,
theme: EmojiSuggestionsComponent.Theme(theme: environment.theme, backgroundColor: environment.theme.list.itemBlocksBackgroundColor), theme: EmojiSuggestionsComponent.Theme(theme: theme, backgroundColor: theme.list.itemBlocksBackgroundColor),
animationCache: component.context.animationCache, animationCache: component.context.animationCache,
animationRenderer: component.context.animationRenderer, animationRenderer: component.context.animationRenderer,
files: value, files: value,
@ -1530,3 +1692,212 @@ public class ComposeTodoScreen: ViewControllerComponentContainer, AttachmentCont
return true 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

@ -257,6 +257,7 @@ public final class ListComposePollOptionComponent: Component {
private let textField = ComponentView<Empty>() private let textField = ComponentView<Empty>()
private var modeSelector: ComponentView<Empty>? private var modeSelector: ComponentView<Empty>?
private var reorderIconView: UIImageView?
private var checkView: CheckView? private var checkView: CheckView?
@ -455,6 +456,43 @@ public final class ListComposePollOptionComponent: Component {
}) })
} }
var rightIconsInset: CGFloat = 0.0
if component.canReorder, let externalState = component.externalState, externalState.hasText {
var reorderIconTransition = transition
let reorderIconView: UIImageView
if let current = self.reorderIconView {
reorderIconView = current
} else {
reorderIconTransition = reorderIconTransition.withAnimation(.none)
reorderIconView = UIImageView()
self.reorderIconView = reorderIconView
self.addSubview(reorderIconView)
}
reorderIconView.image = PresentationResourcesItemList.itemListReorderIndicatorIcon(component.theme)
var reorderIconSize = CGSize()
if let icon = reorderIconView.image {
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)
reorderIconTransition.setPosition(view: reorderIconView, position: reorderIconFrame.center)
reorderIconTransition.setBounds(view: reorderIconView, bounds: CGRect(origin: CGPoint(), size: reorderIconFrame.size))
rightIconsInset += 36.0
} else if let reorderIconView = self.reorderIconView {
self.reorderIconView = nil
if !transition.animation.isImmediate {
let alphaTransition: ComponentTransition = .easeInOut(duration: 0.2)
alphaTransition.setAlpha(view: reorderIconView, alpha: 0.0, completion: { [weak reorderIconView] _ in
reorderIconView?.removeFromSuperview()
})
alphaTransition.setScale(view: reorderIconView, scale: 0.001)
} else {
reorderIconView.removeFromSuperview()
}
}
if let inputMode = component.inputMode { if let inputMode = component.inputMode {
var modeSelectorTransition = transition var modeSelectorTransition = transition
let modeSelector: ComponentView<Empty> let modeSelector: ComponentView<Empty>
@ -501,7 +539,7 @@ public final class ListComposePollOptionComponent: Component {
environment: {}, environment: {},
containerSize: modeSelectorSize containerSize: modeSelectorSize
) )
let modeSelectorFrame = CGRect(origin: CGPoint(x: size.width - 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, y: floor((size.height - modeSelectorSize.height) * 0.5)), size: modeSelectorSize)
if let modeSelectorView = modeSelector.view as? PlainButtonComponent.View { if let modeSelectorView = modeSelector.view as? PlainButtonComponent.View {
let alphaTransition: ComponentTransition = .easeInOut(duration: 0.2) let alphaTransition: ComponentTransition = .easeInOut(duration: 0.2)

View File

@ -336,7 +336,7 @@ public final class StoryFooterPanelComponent: Component {
statusNode.view.frame = CGRect(origin: CGPoint(x: innerLeftOffset, y: floor((size.height - statusSize.height) * 0.5)), size: statusSize) statusNode.view.frame = CGRect(origin: CGPoint(x: innerLeftOffset, y: floor((size.height - statusSize.height) * 0.5)), size: statusSize)
innerLeftOffset += statusSize.width + 10.0 innerLeftOffset += statusSize.width + 10.0
statusNode.transitionToState(.progress(value: CGFloat(max(0.08, self.uploadProgress)), cancelEnabled: true, appearance: SemanticStatusNodeState.ProgressAppearance(inset: 0.0, lineWidth: 2.0))) statusNode.transitionToState(.progress(value: CGFloat(max(0.08, self.uploadProgress)), cancelEnabled: true, appearance: SemanticStatusNodeState.ProgressAppearance(inset: 0.0, lineWidth: 2.0), animateRotation: true))
let uploadingTextSize = uploadingText.update( let uploadingTextSize = uploadingText.update(
transition: .immediate, transition: .immediate,

View File

@ -9,20 +9,17 @@ public final class ToastContentComponent: Component {
public let content: AnyComponent<Empty> public let content: AnyComponent<Empty>
public let insets: UIEdgeInsets public let insets: UIEdgeInsets
public let iconSpacing: CGFloat public let iconSpacing: CGFloat
public let action: (() -> Void)?
public init( public init(
icon: AnyComponent<Empty>, icon: AnyComponent<Empty>,
content: AnyComponent<Empty>, content: AnyComponent<Empty>,
insets: UIEdgeInsets = UIEdgeInsets(top: 10.0, left: 10.0, bottom: 10.0, right: 10.0), 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.icon = icon
self.content = content self.content = content
self.insets = insets self.insets = insets
self.iconSpacing = iconSpacing self.iconSpacing = iconSpacing
self.action = action
} }
public static func ==(lhs: ToastContentComponent, rhs: ToastContentComponent) -> Bool { public static func ==(lhs: ToastContentComponent, rhs: ToastContentComponent) -> Bool {
@ -69,18 +66,9 @@ public final class ToastContentComponent: Component {
} }
@objc private func tapped() {
self.component?.action?()
}
func update(component: ToastContentComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize { func update(component: ToastContentComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
var contentHeight: CGFloat = 0.0 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 self.component = component
let leftInset: CGFloat = component.insets.left let leftInset: CGFloat = component.insets.left

View File

@ -4766,7 +4766,7 @@ extension ChatControllerImpl {
} }
} }
let highlightedState = ChatInterfaceHighlightedState(messageStableId: message.stableId, quote: toSubject.quote.flatMap { quote in ChatInterfaceHighlightedState.Quote(string: quote.string, offset: quote.offset) }) let highlightedState = ChatInterfaceHighlightedState(messageStableId: message.stableId, quote: toSubject.quote.flatMap { quote in ChatInterfaceHighlightedState.Quote(string: quote.string, offset: quote.offset) }, todoTaskId: toSubject.todoTaskId)
controllerInteraction.highlightedState = highlightedState controllerInteraction.highlightedState = highlightedState
strongSelf.updateItemNodesHighlightedStates(animated: initial) strongSelf.updateItemNodesHighlightedStates(animated: initial)
strongSelf.contentData?.scrolledToMessageIdValue = ScrolledToMessageId(id: mappedId, allowedReplacementDirection: []) strongSelf.contentData?.scrolledToMessageIdValue = ScrolledToMessageId(id: mappedId, allowedReplacementDirection: [])

View File

@ -340,13 +340,15 @@ extension ChatControllerImpl {
} }
var quote: (string: String, offset: Int?)? var quote: (string: String, offset: Int?)?
var todoTaskId: Int32?
var setupReply = false var setupReply = false
if case let .id(_, params) = messageLocation { if case let .id(_, params) = messageLocation {
quote = params.quote.flatMap { quote in (string: quote.string, offset: quote.offset) } quote = params.quote.flatMap { quote in (string: quote.string, offset: quote.offset) }
setupReply = params.setupReply setupReply = params.setupReply
todoTaskId = params.todoTaskId
} }
self.chatDisplayNode.historyNode.scrollToMessage(from: scrollFromIndex, to: message.index, animated: animated, quote: quote, scrollPosition: scrollPosition, setupReply: setupReply) self.chatDisplayNode.historyNode.scrollToMessage(from: scrollFromIndex, to: message.index, animated: animated, quote: quote, todoTaskId: todoTaskId, scrollPosition: scrollPosition, setupReply: setupReply)
if delayCompletion { if delayCompletion {
Queue.mainQueue().after(0.25, { Queue.mainQueue().after(0.25, {

View File

@ -970,7 +970,15 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G
case .pinnedMessageUpdated, .gameScore, .setSameChatWallpaper, .giveawayResults, .customText, .todoCompletions, .todoAppendTasks: case .pinnedMessageUpdated, .gameScore, .setSameChatWallpaper, .giveawayResults, .customText, .todoCompletions, .todoAppendTasks:
for attribute in message.attributes { for attribute in message.attributes {
if let attribute = attribute as? ReplyMessageAttribute { if let attribute = attribute as? ReplyMessageAttribute {
self.navigateToMessage(from: message.id, to: .id(attribute.messageId, NavigateToMessageParams(timestamp: nil, quote: attribute.isQuote ? attribute.quote.flatMap { quote in NavigateToMessageParams.Quote(string: quote.text, offset: quote.offset) } : nil))) var todoTaskId: Int32?
if case let .todoCompletions(completed, incompleted) = action.action {
if let completedTaskId = completed.first {
todoTaskId = completedTaskId
} else if let incompletedTaskId = incompleted.first {
todoTaskId = incompletedTaskId
}
}
self.navigateToMessage(from: message.id, to: .id(attribute.messageId, NavigateToMessageParams(timestamp: nil, quote: attribute.isQuote ? attribute.quote.flatMap { quote in NavigateToMessageParams.Quote(string: quote.text, offset: quote.offset) } : nil, todoTaskId: todoTaskId)))
break break
} }
} }
@ -4840,7 +4848,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G
guard let self else { guard let self else {
return return
} }
self.dismissAllUndoControllers() self.dismissAllTooltips()
//TODO:localize //TODO:localize
if !self.context.isPremium { if !self.context.isPremium {
let controller = UndoOverlayController( let controller = UndoOverlayController(

View File

@ -3340,8 +3340,8 @@ public final class ChatHistoryListNodeImpl: ListView, ChatHistoryNode, ChatHisto
} }
} }
public func scrollToMessage(from fromIndex: MessageIndex, to toIndex: MessageIndex, animated: Bool, highlight: Bool = true, quote: (string: String, offset: Int?)? = nil, scrollPosition: ListViewScrollPosition = .center(.bottom), setupReply: Bool = false) { public func scrollToMessage(from fromIndex: MessageIndex, to toIndex: MessageIndex, animated: Bool, highlight: Bool = true, quote: (string: String, offset: Int?)? = nil, todoTaskId: Int32? = nil, scrollPosition: ListViewScrollPosition = .center(.bottom), setupReply: Bool = false) {
self.chatHistoryLocationValue = ChatHistoryLocationInput(content: .Scroll(subject: MessageHistoryScrollToSubject(index: .message(toIndex), quote: quote.flatMap { quote in MessageHistoryScrollToSubject.Quote(string: quote.string, offset: quote.offset) }, setupReply: setupReply), anchorIndex: .message(toIndex), sourceIndex: .message(fromIndex), scrollPosition: scrollPosition, animated: animated, highlight: highlight, setupReply: setupReply), id: self.takeNextHistoryLocationId()) self.chatHistoryLocationValue = ChatHistoryLocationInput(content: .Scroll(subject: MessageHistoryScrollToSubject(index: .message(toIndex), quote: quote.flatMap { quote in MessageHistoryScrollToSubject.Quote(string: quote.string, offset: quote.offset) }, todoTaskId: todoTaskId, setupReply: setupReply), anchorIndex: .message(toIndex), sourceIndex: .message(fromIndex), scrollPosition: scrollPosition, animated: animated, highlight: highlight, setupReply: setupReply), id: self.takeNextHistoryLocationId())
} }
public func anchorMessageInCurrentHistoryView() -> Message? { public func anchorMessageInCurrentHistoryView() -> Message? {