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

View File

@ -66,6 +66,9 @@ swift_library(
"//submodules/ComponentFlow",
"//submodules/TelegramUI/Components/ToastComponent",
"//submodules/SemanticStatusNode",
"//submodules/TelegramUI/Components/PlainButtonComponent",
"//submodules/AvatarNode",
"//submodules/PhotoResources",
],
visibility = [
"//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 {
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)
}

View File

@ -223,85 +223,190 @@ 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 let wrapperNode: ASDisplayNode
private let fullscreenNode: HighlightableButtonNode
private var validLayout: (CGSize, LayoutMetrics, UIEdgeInsets)?
var action: ((Bool) -> Void)?
override init() {
self.wrapperNode = ASDisplayNode()
self.wrapperNode.alpha = 0.0
private var context: AccountContext?
self.fullscreenNode = HighlightableButtonNode()
self.fullscreenNode.setImage(fullscreenImage, for: .normal)
self.fullscreenNode.setImage(minimizeImage, for: .selected)
self.fullscreenNode.setImage(minimizeImage, for: [.selected, .highlighted])
private var adView = ComponentView<Empty>()
super.init()
private var message: Message?
private var adContext: AdMessagesHistoryContext?
private var adState: (startDelay: Int32?, betweenDelay: Int32?, messages: [Message])?
private let adDisposable = MetaDisposable()
private var program: [(Int32, Message?)] = []
var performAction: ((GalleryControllerInteractionTapAction) -> Void)?
var presentPremiumDemo: (() -> Void)?
var openMoreMenu: ((ContextReferenceContentNode, Message) -> Void)?
private var validLayout: (size: CGSize, metrics: LayoutMetrics, insets: UIEdgeInsets)?
self.addSubnode(self.wrapperNode)
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) {
self.validLayout = (size, metrics, insets)
let isLandscape = size.width > size.height
self.fullscreenNode.isSelected = isLandscape
if self.timer == nil {
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 inset: CGFloat = 4.0
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)
transition.updateFrame(node: self.fullscreenNode, frame: CGRect(origin: CGPoint(), size: buttonFrame.size))
let isLandscape = size.width > size.height
let _ = isLandscape
let currentTime = Int32(CFAbsoluteTimeGetCurrent())
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) {
if !self.visibilityAlpha.isZero {
transition.updateAlpha(node: self.wrapperNode, alpha: 1.0)
}
}
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? {
if !self.wrapperNode.frame.contains(point) {
return nil
if let adView = self.adView.view, adView.frame.contains(point) {
return super.hitTest(point, with: event)
}
return super.hitTest(point, with: event)
return nil
}
}
@ -1149,15 +1254,7 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode {
self.footerContentNode.interacting = { [weak self] value in
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.addTarget(self, action: #selector(self.statusButtonPressed), forControlEvents: .touchUpInside)
@ -1262,7 +1359,11 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode {
guard let self else {
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())
@ -1394,7 +1495,7 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode {
self.dismiss()
}
}
func setupItem(_ item: UniversalVideoGalleryItem) {
if self.item?.content.id != item.content.id {
var chapters = parseMediaPlayerChapters(item.caption)
@ -1523,14 +1624,7 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode {
}
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 {
videoNode.canAttachContent = false
videoNode.removeFromSupernode()
@ -1993,6 +2087,17 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode {
}
}
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) {
@ -3109,15 +3214,15 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode {
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 {
return
}
var dismissImpl: (() -> Void)?
let items: Signal<(items: [ContextMenuItem], topItems: [ContextMenuItem]), NoError>
if case let .message(message, _) = self.item?.contentInfo, let _ = message.adAttribute {
items = self.adMenuMainItems() |> map { items in
if let adMessage {
items = self.adMenuMainItems(message: adMessage) |> map { items in
return (items, [])
}
} 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 {
return ContextController.Items(id: AnyHashable(0), content: .twoLists(items.items, items.topItems))
} else {
@ -3164,8 +3269,22 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode {
return speedList
}
private func adMenuMainItems() -> Signal<[ContextMenuItem], NoError> {
guard case let .message(message, _) = self.item?.contentInfo, let adAttribute = message.adAttribute else {
private func presentPremiumDemo() {
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([])
}
@ -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
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Clear"), color: theme.actionSheet.primaryTextColor)
}, iconSource: nil, action: { [weak self] c, _ in
c?.dismiss(completion: {
var replaceImpl: ((ViewController) -> Void)?
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)
}
c?.dismiss(completion: { [weak self] in
self?.presentPremiumDemo()
})
})))
}
@ -3765,7 +3874,7 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode {
}
@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() {
@ -3775,7 +3884,7 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode {
}
override func footerContent() -> Signal<(GalleryFooterContentNode?, GalleryOverlayContentNode?), NoError> {
return .single((self.footerContentNode, nil))
return .single((self.footerContentNode, self.overlayContentNode))
}
func updatePlaybackRate(_ playbackRate: Double?) {
@ -3940,14 +4049,16 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode {
final class HeaderContextReferenceContentSource: ContextReferenceContentSource {
private let controller: ViewController
private let sourceNode: ContextReferenceContentNode
init(controller: ViewController, sourceNode: ContextReferenceContentNode) {
private let actionsOnTop: Bool
init(controller: ViewController, sourceNode: ContextReferenceContentNode, actionsOnTop: Bool) {
self.controller = controller
self.sourceNode = sourceNode
self.actionsOnTop = actionsOnTop
}
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 {
case let .Fetching(_, progress):
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:
if isAudio || isInstantVideo {

View File

@ -32,7 +32,7 @@ public enum SemanticStatusNodeState: Equatable {
case play
case pause
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 customIcon(UIImage)
}
@ -136,12 +136,12 @@ private extension SemanticStatusNodeState {
} else {
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 {
current.updateValue(value: value)
return current
} 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 displayCancel: Bool
let appearance: SemanticStatusNodeState.ProgressAppearance?
let animateRotation: Bool
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.value = value
self.displayCancel = displayCancel
self.appearance = appearance
self.animateRotation = animateRotation
self.timestamp = timestamp
super.init()
@ -59,6 +61,10 @@ final class SemanticStatusNodeProgressContext: SemanticStatusNodeStateContext {
var endAngle: CGFloat
if let value = self.value {
progress = value
if !self.animateRotation {
progress = 1.0 - progress
}
startAngle = -CGFloat.pi / 2.0
endAngle = CGFloat(progress) * 2.0 * CGFloat.pi + startAngle
@ -98,14 +104,16 @@ final class SemanticStatusNodeProgressContext: SemanticStatusNodeStateContext {
pathDiameter = diameter - lineWidth - 2.5 * 2.0
}
var angle = self.timestamp.truncatingRemainder(dividingBy: Double.pi * 2.0)
angle *= 4.0
if self.animateRotation {
var angle = self.timestamp.truncatingRemainder(dividingBy: Double.pi * 2.0)
angle *= 4.0
context.translateBy(x: diameter / 2.0, y: diameter / 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)
context.rotate(by: CGFloat(angle.truncatingRemainder(dividingBy: Double.pi * 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.lineCapStyle = .round
path.stroke()
@ -147,6 +155,7 @@ final class SemanticStatusNodeProgressContext: SemanticStatusNodeStateContext {
var value: CGFloat?
let displayCancel: Bool
let appearance: SemanticStatusNodeState.ProgressAppearance?
let animateRotation: Bool
var transition: SemanticStatusNodeProgressTransition?
var isAnimating: Bool {
@ -155,10 +164,11 @@ final class SemanticStatusNodeProgressContext: SemanticStatusNodeStateContext {
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.displayCancel = displayCancel
self.appearance = appearance
self.animateRotation = animateRotation
}
func drawingState(transitionFraction: CGFloat) -> SemanticStatusNodeStateDrawingState {
@ -178,7 +188,7 @@ final class SemanticStatusNodeProgressContext: SemanticStatusNodeStateContext {
} else {
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? {

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))
self.statusNode = statusNode
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 {
statusNode.frame = statusFrame

View File

@ -133,7 +133,16 @@ public struct PresentationResourcesItemList {
public static func itemListReorderIndicatorIcon(_ theme: PresentationTheme) -> UIImage? {
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(
component: RoundedRectangle(
color: theme.list.blocksBackgroundColor,
color: theme.overallDarkAppearance ? theme.list.itemModalBlocksBackgroundColor : theme.list.blocksBackgroundColor,
cornerRadius: 10.0
),
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 state = context.state
let theme = environment.theme
let openContextMenu = context.component.openContextMenu
let dismiss = context.component.dismiss
let background = background.update(
component: Rectangle(color: environment.theme.list.plainBackgroundColor),
component: Rectangle(color: theme.overallDarkAppearance ? theme.list.modalBlocksBackgroundColor : theme.list.plainBackgroundColor),
environment: {},
availableSize: context.availableSize,
transition: context.transition
@ -694,9 +696,9 @@ public class AdsInfoScreen: ViewController {
self.footerView = ComponentHostView()
super.init()
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)

View File

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

View File

@ -684,6 +684,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI
private struct HighlightedState: Equatable {
var quote: ChatInterfaceHighlightedState.Quote?
var todoTaskId: Int32?
}
private var highlightedState: HighlightedState?
@ -5903,7 +5904,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI
if let highlightedStateValue = item.controllerInteraction.highlightedState {
for (message, _) in item.content {
if highlightedStateValue.messageStableId == message.stableId {
highlightedState = HighlightedState(quote: highlightedStateValue.quote)
highlightedState = HighlightedState(quote: highlightedStateValue.quote, todoTaskId: highlightedStateValue.todoTaskId)
break
}
}
@ -5915,6 +5916,8 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI
for contentNode in self.contentNodes {
if let contentNode = contentNode as? ChatMessageTextBubbleContentNode {
contentNode.updateQuoteTextHighlightState(text: nil, offset: nil, color: .clear, animated: true)
} else if let _ = contentNode as? ChatMessageTodoBubbleContentNode {
}
}
@ -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 {

View File

@ -1645,7 +1645,7 @@ public final class ChatMessageInteractiveFileNode: ASDisplayNode {
if let updatingMedia = arguments.attributes.updatingMedia, case .update = updatingMedia.media {
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 {
switch resourceStatus.mediaStatus {
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) {
state = .check(appearance: nil)
} else {
state = .progress(value: CGFloat(adjustedProgress), cancelEnabled: true, appearance: nil)
state = .progress(value: CGFloat(adjustedProgress), cancelEnabled: true, appearance: nil, animateRotation: true)
}
}
case .Local:
@ -1712,7 +1712,7 @@ public final class ChatMessageInteractiveFileNode: ASDisplayNode {
switch resourceStatus.fetchStatus {
case let .Fetching(_, progress):
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:
streamingState = .none
case .Remote, .Paused:
@ -1730,7 +1730,7 @@ public final class ChatMessageInteractiveFileNode: ASDisplayNode {
} else if case .check = state {
} else {
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):
if let isBuffering = isBuffering {
if isBuffering {
state = .progress(value: nil, cancelEnabled: true, appearance: nil)
state = .progress(value: nil, cancelEnabled: true, appearance: nil, animateRotation: true)
} else {
state = .none
}
} else {
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:
if isViewOnceMessage {
@ -1355,7 +1355,7 @@ public class ChatMessageInteractiveInstantVideoNode: ASDisplayNode {
isLocal = true
}
if (isBuffering ?? false) && !isLocal {
state = .progress(value: nil, cancelEnabled: true, appearance: nil)
state = .progress(value: nil, cancelEnabled: true, appearance: nil, animateRotation: true)
} else {
state = .none
}

View File

@ -1297,7 +1297,6 @@ public class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode {
public func updateQuoteTextHighlightState(text: String?, offset: Int?, color: UIColor, animated: Bool) {
var rectsSet: [CGRect] = []
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)
if let quoteRange, let rects = cachedLayout.rangeRects(in: quoteRange)?.rects, !rects.isEmpty {
rectsSet = rects

View File

@ -1241,4 +1241,13 @@ public class ChatMessageTodoBubbleContentNode: ChatMessageBubbleContentNode {
}
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 quote: Quote?
public let todoTaskId: Int32?
public init(messageStableId: UInt32, quote: Quote?) {
public init(messageStableId: UInt32, quote: Quote?, todoTaskId: Int32?) {
self.messageStableId = messageStableId
self.quote = quote
self.todoTaskId = todoTaskId
}
}
@ -96,13 +98,15 @@ public struct NavigateToMessageParams {
public var timestamp: Double?
public var quote: Quote?
public var todoTaskId: Int32?
public var progress: Promise<Bool>?
public var forceNew: 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.quote = quote
self.todoTaskId = todoTaskId
self.progress = progress
self.forceNew = forceNew
self.setupReply = setupReply

View File

@ -109,6 +109,9 @@ final class ComposeTodoScreenComponent: Component {
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 isCompletableByOthers = false
@ -129,6 +132,39 @@ final class ComposeTodoScreenComponent: Component {
self.scrollView.delegate = self
self.addSubview(self.scrollView)
let reorderRecognizer = ReorderGestureRecognizer(
shouldBegin: { [weak self] point in
guard let self, let (id, item) = self.item(at: point) else {
return (allowed: false, requiresLongPress: false, id: nil, item: nil)
}
return (allowed: true, requiresLongPress: false, id: id, item: item)
},
willBegin: { point in
},
began: { [weak self] item in
guard let self else {
return
}
self.setReorderingItem(item: item)
},
ended: { [weak self] in
guard let self else {
return
}
self.setReorderingItem(item: nil)
},
moved: { [weak self] distance in
guard let self else {
return
}
self.moveReorderingItem(distance: distance)
},
isActiveUpdated: { _ in
}
)
self.reorderRecognizer = reorderRecognizer
self.addGestureRecognizer(reorderRecognizer)
}
required init?(coder: NSCoder) {
@ -143,6 +179,124 @@ final class ComposeTodoScreenComponent: Component {
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? {
if self.todoTextInputState.text.length == 0 {
return nil
@ -432,6 +586,8 @@ final class ComposeTodoScreenComponent: Component {
let themeUpdated = self.environment?.theme !== environment.theme
self.environment = environment
let theme = environment.theme.withModalBlocksBackground()
let isFirstTime = self.component == nil
if self.component == nil {
if let existingTodo = component.initialData.existingTodo {
@ -599,7 +755,7 @@ final class ComposeTodoScreenComponent: Component {
let sectionSpacing: CGFloat = 24.0
if themeUpdated {
self.backgroundColor = environment.theme.list.blocksBackgroundColor
self.backgroundColor = theme.list.blocksBackgroundColor
}
let presentationData = component.context.sharedContext.currentPresentationData.with { $0 }
@ -617,7 +773,7 @@ final class ComposeTodoScreenComponent: Component {
todoTextSectionItems.append(AnyComponentWithIdentity(id: 0, component: AnyComponent(ListComposePollOptionComponent(
externalState: self.todoTextInputState,
context: component.context,
theme: environment.theme,
theme: theme,
strings: environment.strings,
isEnabled: canEdit,
resetText: self.resetTodoText.flatMap { resetText in
@ -625,7 +781,6 @@ final class ComposeTodoScreenComponent: Component {
},
assumeIsEditing: self.inputMediaNodeTargetTag === self.todoTextFieldTag,
characterLimit: component.initialData.maxTodoTextLength,
canReorder: canEdit,
emptyLineHandling: .allowed,
returnKeyAction: { [weak self] in
guard let self else {
@ -661,7 +816,7 @@ final class ComposeTodoScreenComponent: Component {
let todoTextSectionSize = self.todoTextSection.update(
transition: transition,
component: AnyComponent(ListSectionComponent(
theme: environment.theme,
theme: theme,
header: nil,
footer: nil,
items: todoTextSectionItems
@ -701,7 +856,7 @@ final class ComposeTodoScreenComponent: Component {
todoItemsSectionItems.append(AnyComponentWithIdentity(id: todoItem.id, component: AnyComponent(ListComposePollOptionComponent(
externalState: todoItem.textInputState,
context: component.context,
theme: environment.theme,
theme: theme,
strings: environment.strings,
isEnabled: isEnabled,
resetText: todoItem.resetText.flatMap { resetText in
@ -709,6 +864,7 @@ final class ComposeTodoScreenComponent: Component {
},
assumeIsEditing: self.inputMediaNodeTargetTag === todoItem.textFieldTag,
characterLimit: component.initialData.maxTodoItemLength,
canReorder: isEnabled,
emptyLineHandling: .notAllowed,
returnKeyAction: { [weak self] in
guard let self else {
@ -774,7 +930,7 @@ final class ComposeTodoScreenComponent: Component {
self.todoItemsSectionContainer.itemViews[itemId] = itemView
itemView.contents.parentState = state
}
let itemSize = itemView.contents.update(
transition: itemTransition,
component: item.component,
@ -788,6 +944,12 @@ final class ComposeTodoScreenComponent: Component {
size: itemSize,
transition: itemTransition
))
var isReordering = false
if let reorderingItem = self.reorderingItem, itemId == reorderingItem.id {
isReordering = true
}
itemView.contents.view?.isHidden = isReordering
}
for i in 0 ..< self.todoItems.count {
@ -836,7 +998,7 @@ final class ComposeTodoScreenComponent: Component {
let todoItemsSectionUpdateResult = self.todoItemsSectionContainer.update(
configuration: ListSectionContentView.Configuration(
theme: environment.theme,
theme: theme,
displaySeparators: true,
extendsItemHighlightToSection: false,
background: .all
@ -854,7 +1016,7 @@ final class ComposeTodoScreenComponent: Component {
text: .plain(NSAttributedString(
string: "TO DO LIST",
font: Font.regular(presentationData.listsFontSize.itemListBaseHeaderFontSize),
textColor: environment.theme.list.freeTextColor
textColor: theme.list.freeTextColor
)),
maximumNumberOfLines: 0
)),
@ -900,19 +1062,19 @@ final class ComposeTodoScreenComponent: Component {
}
let todoItemsComponent: AnyComponent<Empty>
if !"".isEmpty, todoItemsLimitReached {
if todoItemsLimitReached {
todoItemsFooterTransition = todoItemsFooterTransition.withAnimation(.none)
let textFont = Font.regular(presentationData.listsFontSize.itemListBaseHeaderFontSize)
let boldTextFont = Font.semibold(presentationData.listsFontSize.itemListBaseHeaderFontSize)
let textColor = environment.theme.list.freeTextColor
let textColor = theme.list.freeTextColor
todoItemsComponent = AnyComponent(MultilineTextComponent(
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(
body: MarkdownAttributeSet(font: textFont, 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
return (TelegramTextAttributes.URL, contents)
}
@ -969,7 +1131,7 @@ final class ComposeTodoScreenComponent: Component {
todoItemsComponent = AnyComponent(AnimatedTextComponent(
font: Font.regular(presentationData.listsFontSize.itemListBaseHeaderFontSize),
color: environment.theme.list.freeTextColor,
color: theme.list.freeTextColor,
items: todoItemsFooterItems
))
}
@ -1004,13 +1166,13 @@ final class ComposeTodoScreenComponent: Component {
var todoSettingsSectionItems: [AnyComponentWithIdentity<Empty>] = []
if canEdit && component.peer.id != component.context.account.peerId {
todoSettingsSectionItems.append(AnyComponentWithIdentity(id: "completable", component: AnyComponent(ListActionItemComponent(
theme: environment.theme,
theme: theme,
title: AnyComponent(VStack([
AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(
string: "Allow Others to Mark as Done",
font: Font.regular(presentationData.listsFontSize.baseDisplaySize),
textColor: environment.theme.list.itemPrimaryTextColor
textColor: theme.list.itemPrimaryTextColor
)),
maximumNumberOfLines: 1
))),
@ -1027,13 +1189,13 @@ final class ComposeTodoScreenComponent: Component {
if self.isCompletableByOthers {
todoSettingsSectionItems.append(AnyComponentWithIdentity(id: "editable", component: AnyComponent(ListActionItemComponent(
theme: environment.theme,
theme: theme,
title: AnyComponent(VStack([
AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(
string: "Allow Others to Add Tasks",
font: Font.regular(presentationData.listsFontSize.baseDisplaySize),
textColor: environment.theme.list.itemPrimaryTextColor
textColor: theme.list.itemPrimaryTextColor
)),
maximumNumberOfLines: 1
))),
@ -1054,7 +1216,7 @@ final class ComposeTodoScreenComponent: Component {
let todoSettingsSectionSize = self.todoSettingsSection.update(
transition: transition,
component: AnyComponent(ListSectionComponent(
theme: environment.theme,
theme: theme,
header: nil,
footer: nil,
items: todoSettingsSectionItems
@ -1164,7 +1326,7 @@ final class ComposeTodoScreenComponent: Component {
component: AnyComponent(EmojiSuggestionsComponent(
context: component.context,
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,
animationRenderer: component.context.animationRenderer,
files: value,
@ -1530,3 +1692,212 @@ public class ComposeTodoScreen: ViewControllerComponentContainer, AttachmentCont
return true
}
}
private final class ReorderGestureRecognizer: UIGestureRecognizer {
private let shouldBegin: (CGPoint) -> (allowed: Bool, requiresLongPress: Bool, id: AnyHashable?, item: ComponentView<Empty>?)
private let willBegin: (CGPoint) -> Void
private let began: (AnyHashable) -> Void
private let ended: () -> Void
private let moved: (CGPoint) -> Void
private let isActiveUpdated: (Bool) -> Void
private var initialLocation: CGPoint?
private var longTapTimer: SwiftSignalKit.Timer?
private var longPressTimer: SwiftSignalKit.Timer?
private var id: AnyHashable?
private var itemView: ComponentView<Empty>?
public init(shouldBegin: @escaping (CGPoint) -> (allowed: Bool, requiresLongPress: Bool, id: AnyHashable?, item: ComponentView<Empty>?), willBegin: @escaping (CGPoint) -> Void, began: @escaping (AnyHashable) -> Void, ended: @escaping () -> Void, moved: @escaping (CGPoint) -> Void, isActiveUpdated: @escaping (Bool) -> Void) {
self.shouldBegin = shouldBegin
self.willBegin = willBegin
self.began = began
self.ended = ended
self.moved = moved
self.isActiveUpdated = isActiveUpdated
super.init(target: nil, action: nil)
}
deinit {
self.longTapTimer?.invalidate()
self.longPressTimer?.invalidate()
}
private func startLongTapTimer() {
self.longTapTimer?.invalidate()
let longTapTimer = SwiftSignalKit.Timer(timeout: 0.25, repeat: false, completion: { [weak self] in
self?.longTapTimerFired()
}, queue: Queue.mainQueue())
self.longTapTimer = longTapTimer
longTapTimer.start()
}
private func stopLongTapTimer() {
self.itemView = nil
self.longTapTimer?.invalidate()
self.longTapTimer = nil
}
private func startLongPressTimer() {
self.longPressTimer?.invalidate()
let longPressTimer = SwiftSignalKit.Timer(timeout: 0.6, repeat: false, completion: { [weak self] in
self?.longPressTimerFired()
}, queue: Queue.mainQueue())
self.longPressTimer = longPressTimer
longPressTimer.start()
}
private func stopLongPressTimer() {
self.itemView = nil
self.longPressTimer?.invalidate()
self.longPressTimer = nil
}
override public func reset() {
super.reset()
self.itemView = nil
self.stopLongTapTimer()
self.stopLongPressTimer()
self.initialLocation = nil
self.isActiveUpdated(false)
}
private func longTapTimerFired() {
guard let location = self.initialLocation else {
return
}
self.longTapTimer?.invalidate()
self.longTapTimer = nil
self.willBegin(location)
}
private func longPressTimerFired() {
guard let _ = self.initialLocation else {
return
}
self.isActiveUpdated(true)
self.state = .began
self.longPressTimer?.invalidate()
self.longPressTimer = nil
self.longTapTimer?.invalidate()
self.longTapTimer = nil
if let id = self.id {
self.began(id)
}
self.isActiveUpdated(true)
}
override public func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
super.touchesBegan(touches, with: event)
if self.numberOfTouches > 1 {
self.isActiveUpdated(false)
self.state = .failed
self.ended()
return
}
if self.state == .possible {
if let location = touches.first?.location(in: self.view) {
let (allowed, requiresLongPress, id, itemView) = self.shouldBegin(location)
if allowed {
self.isActiveUpdated(true)
self.id = id
self.itemView = itemView
self.initialLocation = location
if requiresLongPress {
self.startLongTapTimer()
self.startLongPressTimer()
} else {
self.state = .began
if let id = self.id {
self.began(id)
}
}
} else {
self.isActiveUpdated(false)
self.state = .failed
}
} else {
self.isActiveUpdated(false)
self.state = .failed
}
}
}
override public func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent) {
super.touchesEnded(touches, with: event)
self.initialLocation = nil
self.stopLongTapTimer()
if self.longPressTimer != nil {
self.stopLongPressTimer()
self.isActiveUpdated(false)
self.state = .failed
}
if self.state == .began || self.state == .changed {
self.isActiveUpdated(false)
self.ended()
self.state = .failed
}
}
override public func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent) {
super.touchesCancelled(touches, with: event)
self.initialLocation = nil
self.stopLongTapTimer()
if self.longPressTimer != nil {
self.isActiveUpdated(false)
self.stopLongPressTimer()
self.state = .failed
}
if self.state == .began || self.state == .changed {
self.isActiveUpdated(false)
self.ended()
self.state = .failed
}
}
override public func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent) {
super.touchesMoved(touches, with: event)
if (self.state == .began || self.state == .changed), let initialLocation = self.initialLocation, let location = touches.first?.location(in: self.view) {
self.state = .changed
let offset = CGPoint(x: location.x - initialLocation.x, y: location.y - initialLocation.y)
self.moved(offset)
} else if let touch = touches.first, let initialTapLocation = self.initialLocation, self.longPressTimer != nil {
let touchLocation = touch.location(in: self.view)
let dX = touchLocation.x - initialTapLocation.x
let dY = touchLocation.y - initialTapLocation.y
if dX * dX + dY * dY > 3.0 * 3.0 {
self.stopLongTapTimer()
self.stopLongPressTimer()
self.initialLocation = nil
self.isActiveUpdated(false)
self.state = .failed
}
}
}
}
private func generateReorderingBackgroundImage(backgroundColor: UIColor) -> UIImage? {
return generateImage(CGSize(width: 64.0, height: 64.0), contextGenerator: { size, context in
context.clear(CGRect(origin: .zero, size: size))
context.addPath(UIBezierPath(roundedRect: CGRect(x: 10, y: 10, width: 44, height: 44), cornerRadius: 10).cgPath)
context.setShadow(offset: CGSize(width: 0.0, height: 0.0), blur: 24.0, color: UIColor(white: 0.0, alpha: 0.35).cgColor)
context.setFillColor(backgroundColor.cgColor)
context.fillPath()
})?.stretchableImage(withLeftCapWidth: 32, topCapHeight: 32)
}

View File

@ -257,6 +257,7 @@ public final class ListComposePollOptionComponent: Component {
private let textField = ComponentView<Empty>()
private var modeSelector: ComponentView<Empty>?
private var reorderIconView: UIImageView?
private var checkView: CheckView?
@ -454,6 +455,43 @@ public final class ListComposePollOptionComponent: Component {
checkView?.removeFromSuperview()
})
}
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 {
var modeSelectorTransition = transition
@ -501,7 +539,7 @@ public final class ListComposePollOptionComponent: Component {
environment: {},
containerSize: modeSelectorSize
)
let modeSelectorFrame = CGRect(origin: CGPoint(x: size.width - 4.0 - modeSelectorSize.width, y: floor((size.height - modeSelectorSize.height) * 0.5)), size: modeSelectorSize)
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 {
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)
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(
transition: .immediate,

View File

@ -9,20 +9,17 @@ public final class ToastContentComponent: Component {
public let content: AnyComponent<Empty>
public let insets: UIEdgeInsets
public let iconSpacing: CGFloat
public let action: (() -> Void)?
public init(
icon: AnyComponent<Empty>,
content: AnyComponent<Empty>,
insets: UIEdgeInsets = UIEdgeInsets(top: 10.0, left: 10.0, bottom: 10.0, right: 10.0),
iconSpacing: CGFloat = 10.0,
action: (() -> Void)? = nil
iconSpacing: CGFloat = 10.0
) {
self.icon = icon
self.content = content
self.insets = insets
self.iconSpacing = iconSpacing
self.action = action
}
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 {
var contentHeight: CGFloat = 0.0
if self.component == nil {
if let _ = component.action {
self.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.tapped)))
}
}
self.component = component
let leftInset: CGFloat = component.insets.left

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
strongSelf.updateItemNodesHighlightedStates(animated: initial)
strongSelf.contentData?.scrolledToMessageIdValue = ScrolledToMessageId(id: mappedId, allowedReplacementDirection: [])

View File

@ -340,13 +340,15 @@ extension ChatControllerImpl {
}
var quote: (string: String, offset: Int?)?
var todoTaskId: Int32?
var setupReply = false
if case let .id(_, params) = messageLocation {
quote = params.quote.flatMap { quote in (string: quote.string, offset: quote.offset) }
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 {
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:
for attribute in message.attributes {
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
}
}
@ -4840,7 +4848,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G
guard let self else {
return
}
self.dismissAllUndoControllers()
self.dismissAllTooltips()
//TODO:localize
if !self.context.isPremium {
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) {
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())
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) }, 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? {