mirror of
https://github.com/Swiftgram/Telegram-iOS.git
synced 2025-08-01 07:57:01 +00:00
Merge commit 'f59abe16896937b2dd8f885a859f9352cd76bf28'
This commit is contained in:
commit
f11ebd9559
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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",
|
||||
|
@ -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()
|
||||
})
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
289
submodules/GalleryUI/Sources/Items/VideoAdComponent.swift
Normal file
289
submodules/GalleryUI/Sources/Items/VideoAdComponent.swift
Normal 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()
|
||||
})
|
||||
}
|
@ -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 {
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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? {
|
||||
|
@ -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
|
||||
|
@ -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()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
@ -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 {
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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: [])
|
||||
|
@ -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, {
|
||||
|
@ -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(
|
||||
|
@ -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? {
|
||||
|
Loading…
x
Reference in New Issue
Block a user