Swiftgram/submodules/CalendarMessageScreen/Sources/CalendarMessageScreen.swift
2021-11-27 00:04:30 +04:00

1996 lines
88 KiB
Swift

import Foundation
import UIKit
import Display
import AsyncDisplayKit
import SwiftSignalKit
import Postbox
import TelegramCore
import AccountContext
import TelegramPresentationData
import ComponentFlow
import PhotoResources
import DirectMediaImageCache
import TelegramStringFormatting
import TooltipUI
private final class NullActionClass: NSObject, CAAction {
@objc func run(forKey event: String, object anObject: Any, arguments dict: [AnyHashable : Any]?) {
}
}
private let nullAction = NullActionClass()
private class SimpleLayer: CALayer {
override func action(forKey event: String) -> CAAction? {
return nullAction
}
func update(size: CGSize) {
}
}
private enum SelectionTransition {
case begin
case change
case end
}
private final class MediaPreviewView: SimpleLayer {
private let context: AccountContext
private let message: EngineMessage
private let media: EngineMedia
private let imageCache: DirectMediaImageCache
private var requestedImage: Bool = false
private var disposable: Disposable?
init(context: AccountContext, message: EngineMessage, media: EngineMedia, imageCache: DirectMediaImageCache) {
self.context = context
self.message = message
self.media = media
self.imageCache = imageCache
super.init()
self.contentsGravity = .resize
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
self.disposable?.dispose()
}
func updateLayout(size: CGSize, synchronousLoads: Bool) {
let processImage: (UIImage) -> UIImage = { image in
return generateImage(size, rotatedContext: { size, context in
context.clear(CGRect(origin: CGPoint(), size: size))
context.addEllipse(in: CGRect(origin: CGPoint(), size: size))
context.clip()
UIGraphicsPushContext(context)
image.draw(in: CGRect(origin: CGPoint(), size: size))
UIGraphicsPopContext()
})!
}
if !self.requestedImage {
self.requestedImage = true
if let result = self.imageCache.getImage(message: self.message._asMessage(), media: self.media._asMedia(), width: 100, possibleWidths: [100], synchronous: false) {
if let image = result.image {
self.contents = processImage(image).cgImage
}
if let signal = result.loadSignal {
self.disposable = (signal
|> map { image in
return image.flatMap(processImage)
}
|> deliverOnMainQueue).start(next: { [weak self] image in
guard let strongSelf = self else {
return
}
if let image = image {
if strongSelf.contents != nil {
let tempView = SimpleLayer()
tempView.contents = strongSelf.contents
tempView.frame = strongSelf.bounds
tempView.contentsGravity = strongSelf.contentsGravity
strongSelf.addSublayer(tempView)
tempView.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak tempView] _ in
tempView?.removeFromSuperlayer()
})
}
strongSelf.contents = image.cgImage
}
})
}
}
}
}
}
private func normalizeDayIndex(index: Int) -> Int {
switch index {
case 1:
return 6
case 2:
return 0
case 3:
return 1
case 4:
return 2
case 5:
return 3
case 6:
return 4
case 7:
return 5
default:
preconditionFailure()
}
}
private func gridDayOffset(firstDayOfWeek: Int, firstWeekdayOfMonth: Int) -> Int {
let monthStartsWithDay = normalizeDayIndex(index: firstWeekdayOfMonth)
let weekStartsWithDay = normalizeDayIndex(index: firstDayOfWeek)
return (monthStartsWithDay - weekStartsWithDay + 7) % 7
}
private func gridDayName(index: Int, firstDayOfWeek: Int, strings: PresentationStrings) -> String {
let adjustedIndex = (index + firstDayOfWeek) % 7
switch adjustedIndex {
case 1:
return strings.Calendar_ShortSun
case 2:
return strings.Calendar_ShortMon
case 3:
return strings.Calendar_ShortTue
case 4:
return strings.Calendar_ShortWed
case 5:
return strings.Calendar_ShortThu
case 6:
return strings.Calendar_ShortFri
case 0:
return strings.Calendar_ShortSat
default:
return ""
}
}
private class Scroller: UIScrollView {
override init(frame: CGRect) {
super.init(frame: frame)
if #available(iOSApplicationExtension 11.0, iOS 11.0, *) {
self.contentInsetAdjustmentBehavior = .never
}
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func touchesShouldCancel(in view: UIView) -> Bool {
return true
}
@objc func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
return false
}
}
private final class ImageCache: Equatable {
static func ==(lhs: ImageCache, rhs: ImageCache) -> Bool {
return lhs === rhs
}
private struct FilledCircle: Hashable {
var diameter: CGFloat
var innerDiameter: CGFloat?
var color: UInt32
}
private struct Text: Hashable {
var fontSize: CGFloat
var isSemibold: Bool
var color: UInt32
var string: String
}
private struct MonthSelection: Hashable {
var leftRadius: CGFloat
var rightRadius: CGFloat
var maxRadius: CGFloat
var color: UInt32
}
private var items: [AnyHashable: UIImage] = [:]
func filledCircle(diameter: CGFloat, innerDiameter: CGFloat?, color: UIColor) -> UIImage {
let key = AnyHashable(FilledCircle(diameter: diameter, innerDiameter: innerDiameter, color: color.argb))
if let image = self.items[key] {
return image
}
let image = generateImage(CGSize(width: diameter, height: diameter), rotatedContext: { size, context in
context.clear(CGRect(origin: CGPoint(), size: size))
context.setFillColor(color.cgColor)
context.fillEllipse(in: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: size.width, height: size.height)))
if let innerDiameter = innerDiameter {
context.setBlendMode(.copy)
context.setFillColor(UIColor.clear.cgColor)
context.fillEllipse(in: CGRect(origin: CGPoint(x: (size.width - innerDiameter) / 2.0, y: (size.height - innerDiameter) / 2.0), size: CGSize(width: innerDiameter, height: innerDiameter)))
}
})!.stretchableImage(withLeftCapWidth: Int(diameter) / 2, topCapHeight: Int(diameter) / 2)
self.items[key] = image
return image
}
func text(fontSize: CGFloat, isSemibold: Bool, color: UIColor, string: String) -> UIImage {
let key = AnyHashable(Text(fontSize: fontSize, isSemibold: isSemibold, color: color.argb, string: string))
if let image = self.items[key] {
return image
}
let font: UIFont
if isSemibold {
font = Font.semibold(fontSize)
} else {
font = Font.regular(fontSize)
}
let attributedString = NSAttributedString(string: string, font: font, textColor: color)
var rect = attributedString.boundingRect(with: CGSize(width: 1000.0, height: 1000.0), options: .usesLineFragmentOrigin, context: nil)
if string == "1" {
rect.origin.x -= 1.0
}
let image = generateImage(CGSize(width: ceil(rect.width), height: ceil(rect.height)), rotatedContext: { size, context in
context.clear(CGRect(origin: CGPoint(), size: size))
UIGraphicsPushContext(context)
attributedString.draw(in: rect)
UIGraphicsPopContext()
})!
self.items[key] = image
return image
}
func monthSelection(leftRadius: CGFloat, rightRadius: CGFloat, maxRadius: CGFloat, color: UIColor) -> UIImage {
let key = AnyHashable(MonthSelection(leftRadius: leftRadius, rightRadius: rightRadius, maxRadius: maxRadius, color: color.argb))
if let image = self.items[key] {
return image
}
let image = generateImage(CGSize(width: maxRadius, height: maxRadius), rotatedContext: { size, context in
context.clear(CGRect(origin: CGPoint(), size: size))
context.setFillColor(color.cgColor)
UIGraphicsPushContext(context)
context.clip(to: CGRect(origin: CGPoint(), size: CGSize(width: size.width / 2.0, height: size.height)))
UIBezierPath(roundedRect: CGRect(origin: CGPoint(), size: size), cornerRadius: leftRadius).fill()
context.resetClip()
context.clip(to: CGRect(origin: CGPoint(x: size.width / 2.0, y: 0.0), size: CGSize(width: size.width - size.width / 2.0, height: size.height)))
UIBezierPath(roundedRect: CGRect(origin: CGPoint(), size: size), cornerRadius: rightRadius).fill()
UIGraphicsPopContext()
})!.stretchableImage(withLeftCapWidth: Int(maxRadius / 2.0), topCapHeight: Int(maxRadius / 2.0))
self.items[key] = image
return image
}
}
private final class DayEnvironment: Equatable {
let imageCache: ImageCache
let directImageCache: DirectMediaImageCache
var selectionDelayCoordination: Int = 0
init(imageCache: ImageCache, directImageCache: DirectMediaImageCache) {
self.imageCache = imageCache
self.directImageCache = directImageCache
}
static func ==(lhs: DayEnvironment, rhs: DayEnvironment) -> Bool {
return lhs === rhs
}
}
private final class ImageComponent: Component {
let image: UIImage?
init(
image: UIImage?
) {
self.image = image
}
static func ==(lhs: ImageComponent, rhs: ImageComponent) -> Bool {
if lhs.image !== rhs.image {
return false
}
return true
}
final class View: UIImageView {
init() {
super.init(frame: CGRect())
}
required init?(coder aDecoder: NSCoder) {
preconditionFailure()
}
func update(component: ImageComponent, availableSize: CGSize, environment: Environment<Empty>, transition: Transition) -> CGSize {
self.image = component.image
return availableSize
}
}
func makeView() -> View {
return View()
}
func update(view: View, availableSize: CGSize, environment: Environment<Empty>, transition: Transition) -> CGSize {
return view.update(component: self, availableSize: availableSize, environment: environment, transition: transition)
}
}
private final class DayComponent: Component {
typealias EnvironmentType = DayEnvironment
enum DaySelection {
case none
case edge
case middle
}
let title: String
let isCurrent: Bool
let isEnabled: Bool
let theme: PresentationTheme
let context: AccountContext
let timestamp: Int32
let media: DayMedia?
let selection: DaySelection
let isSelecting: Bool
let action: () -> Void
init(
title: String,
isCurrent: Bool,
isEnabled: Bool,
theme: PresentationTheme,
context: AccountContext,
timestamp: Int32,
media: DayMedia?,
selection: DaySelection,
isSelecting: Bool,
action: @escaping () -> Void
) {
self.title = title
self.isCurrent = isCurrent
self.isEnabled = isEnabled
self.theme = theme
self.context = context
self.timestamp = timestamp
self.media = media
self.selection = selection
self.isSelecting = isSelecting
self.action = action
}
static func ==(lhs: DayComponent, rhs: DayComponent) -> Bool {
if lhs.title != rhs.title {
return false
}
if lhs.isCurrent != rhs.isCurrent {
return false
}
if lhs.isEnabled != rhs.isEnabled {
return false
}
if lhs.theme !== rhs.theme {
return false
}
if lhs.context !== rhs.context {
return false
}
if lhs.media != rhs.media {
return false
}
if lhs.timestamp != rhs.timestamp {
return false
}
if lhs.selection != rhs.selection {
return false
}
if lhs.isSelecting != rhs.isSelecting {
return false
}
return true
}
final class View: HighlightTrackingButton {
private let highlightView: SimpleLayer
private var selectionView: SimpleLayer?
private let titleView: SimpleLayer
private var mediaPreviewView: MediaPreviewView?
private var action: (() -> Void)?
private var currentMedia: DayMedia?
private var currentSelection: DaySelection?
private(set) var timestamp: Int32?
private(set) var index: MessageIndex?
private var isHighlightingEnabled: Bool = false
init() {
self.highlightView = SimpleLayer()
self.titleView = SimpleLayer()
super.init(frame: CGRect())
self.layer.addSublayer(self.highlightView)
self.layer.addSublayer(self.titleView)
self.addTarget(self, action: #selector(self.pressed), for: .touchUpInside)
self.highligthedChanged = { [weak self] highligthed in
guard let strongSelf = self, let mediaPreviewView = strongSelf.mediaPreviewView else {
return
}
if strongSelf.isHighlightingEnabled && highligthed {
mediaPreviewView.opacity = 0.8
} else {
let transition: ContainedViewLayoutTransition = .animated(duration: 0.2, curve: .easeInOut)
transition.updateAlpha(layer: mediaPreviewView, alpha: 1.0)
}
}
}
required init?(coder aDecoder: NSCoder) {
preconditionFailure()
}
@objc private func pressed() {
self.action?()
}
func update(component: DayComponent, availableSize: CGSize, environment: Environment<DayEnvironment>, transition: Transition) -> CGSize {
let isFirstTime = self.action == nil
self.action = component.action
self.timestamp = component.timestamp
self.index = component.media?.message.index
self.isHighlightingEnabled = component.isEnabled && component.media != nil && !component.isSelecting
let previousSelection = self.currentSelection ?? component.selection
let previousSelected = previousSelection != .none
let isSelected = component.selection != .none
self.currentSelection = component.selection
let diameter = min(availableSize.width, availableSize.height)
let contentFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - diameter) / 2.0), y: floor((availableSize.height - diameter) / 2.0)), size: CGSize(width: diameter, height: diameter))
let dayEnvironment = environment[DayEnvironment.self].value
if component.media != nil {
self.highlightView.contents = dayEnvironment.imageCache.filledCircle(diameter: diameter, innerDiameter: nil, color: UIColor(white: 0.0, alpha: 0.2)).cgImage
} else {
self.highlightView.contents = nil
}
var animateTitle = false
var animateMediaIn = false
if self.currentMedia != component.media {
self.currentMedia = component.media
if let mediaPreviewView = self.mediaPreviewView {
self.mediaPreviewView = nil
mediaPreviewView.removeFromSuperlayer()
} else {
animateMediaIn = !isFirstTime
}
if let media = component.media {
let mediaPreviewView = MediaPreviewView(context: component.context, message: media.message, media: media.media, imageCache: dayEnvironment.directImageCache)
self.mediaPreviewView = mediaPreviewView
self.layer.insertSublayer(mediaPreviewView, below: self.highlightView)
}
}
let titleColor: UIColor
let titleFontSize: CGFloat
let titleFontIsSemibold: Bool
if component.media != nil {
if component.theme.overallDarkAppearance {
titleColor = component.theme.list.itemPrimaryTextColor
} else {
titleColor = component.theme.list.itemCheckColors.foregroundColor
}
titleFontSize = 17.0
titleFontIsSemibold = true
} else {
titleFontSize = 17.0
switch component.selection {
case .middle, .edge:
titleFontIsSemibold = true
default:
titleFontIsSemibold = component.isCurrent
}
if case .edge = component.selection {
if component.theme.overallDarkAppearance {
titleColor = component.theme.list.itemPrimaryTextColor
} else {
titleColor = component.theme.list.itemCheckColors.foregroundColor
}
} else {
if component.isCurrent {
titleColor = component.theme.list.itemAccentColor
} else if component.isEnabled {
titleColor = component.theme.list.itemPrimaryTextColor
} else {
titleColor = component.theme.list.itemDisabledTextColor
}
}
}
switch component.selection {
case .edge:
let selectionView: SimpleLayer
if let current = self.selectionView {
selectionView = current
} else {
selectionView = SimpleLayer()
self.selectionView = selectionView
self.layer.insertSublayer(selectionView, below: self.titleView)
}
selectionView.frame = contentFrame
if self.mediaPreviewView != nil {
selectionView.contents = dayEnvironment.imageCache.filledCircle(diameter: diameter, innerDiameter: diameter - 2.0 * 2.0, color: component.theme.list.itemCheckColors.fillColor).cgImage
} else {
selectionView.contents = dayEnvironment.imageCache.filledCircle(diameter: diameter, innerDiameter: nil, color: component.theme.list.itemCheckColors.fillColor).cgImage
}
case .middle, .none:
if let selectionView = self.selectionView {
self.selectionView = nil
if let _ = transition.userData(SelectionTransition.self), previousSelected != isSelected {
selectionView.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false, completion: { [weak selectionView] _ in
selectionView?.removeFromSuperlayer()
})
} else {
selectionView.removeFromSuperlayer()
}
}
}
let minimizedContentScale: CGFloat = (contentFrame.width - 8.0) / contentFrame.width
let contentScale: CGFloat
switch component.selection {
case .edge, .middle:
contentScale = minimizedContentScale
case .none:
contentScale = 1.0
}
let titleImage = dayEnvironment.imageCache.text(fontSize: titleFontSize, isSemibold: titleFontIsSemibold, color: titleColor, string: component.title)
if animateMediaIn {
animateTitle = true
}
self.highlightView.bounds = CGRect(origin: CGPoint(), size: contentFrame.size)
self.highlightView.position = CGPoint(x: contentFrame.midX, y: contentFrame.midY)
if let mediaPreviewView = self.mediaPreviewView {
mediaPreviewView.bounds = CGRect(origin: CGPoint(), size: contentFrame.size)
mediaPreviewView.position = CGPoint(x: contentFrame.midX, y: contentFrame.midY)
mediaPreviewView.updateLayout(size: contentFrame.size, synchronousLoads: false)
mediaPreviewView.transform = CATransform3DMakeScale(contentScale, contentScale, 1.0)
if animateMediaIn {
mediaPreviewView.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
self.highlightView.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
}
}
self.highlightView.transform = CATransform3DMakeScale(contentScale, contentScale, 1.0)
if let _ = transition.userData(SelectionTransition.self), previousSelected != isSelected {
if self.mediaPreviewView == nil {
animateTitle = true
}
if isSelected {
if component.selection == .edge {
let scaleIn = self.layer.makeAnimation(from: 1.0 as NSNumber, to: 0.75 as NSNumber, keyPath: "transform.scale", timingFunction: CAMediaTimingFunctionName.easeOut.rawValue, duration: 0.1)
let scaleOut = self.layer.springAnimation(from: 0.75 as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: 0.5)
self.layer.animateGroup([scaleIn, scaleOut], key: "selection")
if let selectionView = self.selectionView {
if self.mediaPreviewView != nil {
let shapeLayer = CAShapeLayer()
let lineWidth: CGFloat = 2.0
shapeLayer.path = UIBezierPath(arcCenter: CGPoint(x: diameter / 2.0, y: diameter / 2.0), radius: diameter / 2.0 - lineWidth / 2.0, startAngle: -CGFloat.pi / 2.0, endAngle: 2 * CGFloat.pi - CGFloat.pi / 2.0, clockwise: true).cgPath
shapeLayer.frame = selectionView.frame
shapeLayer.strokeColor = component.theme.list.itemCheckColors.fillColor.cgColor
shapeLayer.fillColor = UIColor.clear.cgColor
shapeLayer.lineWidth = lineWidth
shapeLayer.lineCap = .round
selectionView.isHidden = true
self.layer.insertSublayer(shapeLayer, above: selectionView)
shapeLayer.animate(from: 0.0 as NSNumber, to: 1.0 as NSNumber, keyPath: "strokeEnd", timingFunction: CAMediaTimingFunctionName.easeOut.rawValue, duration: 0.25, delay: 0.1, completion: { [weak selectionView, weak shapeLayer] _ in
shapeLayer?.removeFromSuperlayer()
selectionView?.isHidden = false
})
} else {
selectionView.animateAlpha(from: 0.0, to: 1.0, duration: 0.15)
}
}
} else {
if let mediaPreviewView = self.mediaPreviewView {
mediaPreviewView.animateScale(from: 1.0, to: contentScale, duration: 0.2)
}
self.highlightView.animateScale(from: 1.0, to: contentScale, duration: 0.2)
}
} else {
if let mediaPreviewView = self.mediaPreviewView {
mediaPreviewView.animateScale(from: minimizedContentScale, to: contentScale, duration: 0.2)
}
self.highlightView.animateScale(from: minimizedContentScale, to: contentScale, duration: 0.2)
}
}
if animateTitle {
let previousTitleView = SimpleLayer()
previousTitleView.contents = self.titleView.contents
previousTitleView.frame = self.titleView.frame
self.titleView.superlayer?.insertSublayer(previousTitleView, above: self.titleView)
previousTitleView.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak previousTitleView] _ in
previousTitleView?.removeFromSuperlayer()
})
self.titleView.animateAlpha(from: 0.0, to: 1.0, duration: 0.16)
}
self.titleView.contents = titleImage.cgImage
let titleSize = titleImage.size
self.highlightView.frame = CGRect(origin: CGPoint(x: contentFrame.midX - contentFrame.width * contentScale / 2.0, y: contentFrame.midY - contentFrame.width * contentScale / 2.0), size: CGSize(width: contentFrame.width * contentScale, height: contentFrame.height * contentScale))
self.titleView.frame = CGRect(origin: CGPoint(x: floorToScreenPixels((availableSize.width - titleSize.width) / 2.0), y: floorToScreenPixels((availableSize.height - titleSize.height) / 2.0)), size: titleSize)
return availableSize
}
}
func makeView() -> View {
return View()
}
func update(view: View, availableSize: CGSize, environment: Environment<DayEnvironment>, transition: Transition) -> CGSize {
return view.update(component: self, availableSize: availableSize, environment: environment, transition: transition)
}
}
private final class MonthComponent: CombinedComponent {
typealias EnvironmentType = DayEnvironment
let context: AccountContext
let model: MonthModel
let foregroundColor: UIColor
let strings: PresentationStrings
let theme: PresentationTheme
let dayAction: (Int32) -> Void
let monthAction: (ClosedRange<Int32>) -> Void
let selectedDays: ClosedRange<Int32>?
init(
context: AccountContext,
model: MonthModel,
foregroundColor: UIColor,
strings: PresentationStrings,
theme: PresentationTheme,
dayAction: @escaping (Int32) -> Void,
monthAction: @escaping (ClosedRange<Int32>) -> Void,
selectedDays: ClosedRange<Int32>?
) {
self.context = context
self.model = model
self.foregroundColor = foregroundColor
self.strings = strings
self.theme = theme
self.dayAction = dayAction
self.monthAction = monthAction
self.selectedDays = selectedDays
}
static func ==(lhs: MonthComponent, rhs: MonthComponent) -> Bool {
if lhs.context !== rhs.context {
return false
}
if lhs.model != rhs.model {
return false
}
if lhs.foregroundColor != rhs.foregroundColor {
return false
}
if lhs.strings !== rhs.strings {
return false
}
if lhs.theme !== rhs.theme {
return false
}
if lhs.selectedDays != rhs.selectedDays {
return false
}
return true
}
static var body: Body {
let title = Child(Text.self)
let weekdayTitles = ChildMap(environment: Empty.self, keyedBy: Int.self)
let days = ChildMap(environment: DayEnvironment.self, keyedBy: Int.self)
let selections = ChildMap(environment: Empty.self, keyedBy: Int.self)
return { context in
let sideInset: CGFloat = 14.0
let titleWeekdaysSpacing: CGFloat = 18.0
let weekdayDaySpacing: CGFloat = 14.0
let weekdaySize: CGFloat = 46.0
let weekdaySpacing: CGFloat = 6.0
let usableWeekdayWidth = floor((context.availableSize.width - sideInset * 2.0 - weekdaySpacing * 6.0) / 7.0)
let weekdayWidth = floor((context.availableSize.width - sideInset * 2.0) / 7.0)
let monthName = stringForMonth(strings: context.component.strings, month: Int32(context.component.model.index - 1), ofYear: Int32(context.component.model.year - 1900))
let title = title.update(
component: Text(
text: monthName,
font: Font.semibold(17.0),
color: context.component.foregroundColor
),
availableSize: CGSize(width: context.availableSize.width - sideInset * 2.0, height: 100.0),
transition: .immediate
)
let updatedWeekdayTitles = (0 ..< 7).map { index in
return weekdayTitles[index].update(
component: AnyComponent(Text(
text: gridDayName(index: index, firstDayOfWeek: context.component.model.firstWeekday, strings: context.component.strings),
font: Font.regular(10.0),
color: context.component.foregroundColor
)),
availableSize: CGSize(width: 100.0, height: 100.0),
transition: .immediate
)
}
let updatedDays = (0 ..< context.component.model.numberOfDays).map { index -> _UpdatedChildComponent in
let dayOfMonth = index + 1
let isCurrent = context.component.model.currentYear == context.component.model.year && context.component.model.currentMonth == context.component.model.index && context.component.model.currentDayOfMonth == dayOfMonth
var isEnabled = true
if context.component.model.currentYear == context.component.model.year {
if context.component.model.currentMonth == context.component.model.index {
if dayOfMonth > context.component.model.currentDayOfMonth {
isEnabled = false
}
} else if context.component.model.index > context.component.model.currentMonth {
isEnabled = false
}
} else if context.component.model.year > context.component.model.currentYear {
isEnabled = false
}
let dayTimestamp = Int32(context.component.model.firstDay.timeIntervalSince1970) + 24 * 60 * 60 * Int32(index)
let dayAction = context.component.dayAction
let daySelection: DayComponent.DaySelection
if let selectedDays = context.component.selectedDays, selectedDays.contains(dayTimestamp) {
if selectedDays.lowerBound == dayTimestamp || selectedDays.upperBound == dayTimestamp {
daySelection = .edge
} else {
daySelection = .middle
}
} else {
daySelection = .none
}
return days[index].update(
component: AnyComponent(DayComponent(
title: "\(dayOfMonth)",
isCurrent: isCurrent,
isEnabled: isEnabled,
theme: context.component.theme,
context: context.component.context,
timestamp: dayTimestamp,
media: context.component.model.mediaByDay[index],
selection: daySelection,
isSelecting: context.component.selectedDays != nil,
action: {
if isEnabled {
dayAction(dayTimestamp)
}
}
)),
environment: {
context.environment[DayEnvironment.self]
},
availableSize: CGSize(width: usableWeekdayWidth, height: weekdaySize),
transition: context.transition
)
}
let titleFrame = CGRect(origin: CGPoint(x: floor((context.availableSize.width - title.size.width) / 2.0), y: 0.0), size: title.size)
let monthAction = context.component.monthAction
let firstDayStart = Int32(context.component.model.firstDay.timeIntervalSince1970)
let lastDayStart = firstDayStart + 24 * 60 * 60 * Int32(context.component.model.numberOfDays - 1)
context.add(title
.position(CGPoint(x: titleFrame.midX, y: titleFrame.midY))
.gesture(.tap {
monthAction(firstDayStart ... lastDayStart)
})
)
let baseWeekdayTitleY = titleFrame.maxY + titleWeekdaysSpacing
var maxWeekdayY = baseWeekdayTitleY
for i in 0 ..< updatedWeekdayTitles.count {
let weekdaySize = updatedWeekdayTitles[i].size
let weekdayFrame = CGRect(origin: CGPoint(x: sideInset + CGFloat(i) * weekdayWidth + floor((weekdayWidth - weekdaySize.width) / 2.0), y: baseWeekdayTitleY), size: weekdaySize)
maxWeekdayY = max(maxWeekdayY, weekdayFrame.maxY)
context.add(updatedWeekdayTitles[i]
.position(CGPoint(x: weekdayFrame.midX, y: weekdayFrame.midY))
)
}
let baseDayY = maxWeekdayY + weekdayDaySpacing
var maxDayY = baseDayY
struct LineSelection {
var range: ClosedRange<Int>
var leftTimestamp: Int32
var rightTimestamp: Int32
}
var selectionsByLine: [Int: LineSelection] = [:]
for i in 0 ..< updatedDays.count {
let gridIndex = gridDayOffset(firstDayOfWeek: context.component.model.firstWeekday, firstWeekdayOfMonth: context.component.model.firstDayWeekday) + i
let rowIndex = gridIndex % 7
let lineIndex = gridIndex / 7
if let selectedDays = context.component.selectedDays {
let dayTimestamp = Int32(context.component.model.firstDay.timeIntervalSince1970) + 24 * 60 * 60 * Int32(i)
if selectedDays.contains(dayTimestamp) {
if var currentSelection = selectionsByLine[lineIndex] {
if rowIndex < currentSelection.range.lowerBound {
currentSelection.range = rowIndex ... currentSelection.range.upperBound
currentSelection.leftTimestamp = dayTimestamp
} else {
currentSelection.range = currentSelection.range.lowerBound ... rowIndex
currentSelection.rightTimestamp = dayTimestamp
}
selectionsByLine[lineIndex] = currentSelection
} else {
selectionsByLine[lineIndex] = LineSelection(
range: rowIndex ... rowIndex,
leftTimestamp: dayTimestamp,
rightTimestamp: dayTimestamp
)
}
}
}
}
if let selectedDays = context.component.selectedDays {
for (lineIndex, selection) in selectionsByLine.sorted(by: { $0.key < $1.key }) {
if selection.leftTimestamp == selection.rightTimestamp && selection.leftTimestamp == selectedDays.lowerBound && selection.rightTimestamp == selectedDays.upperBound {
continue
}
let dayEnvironment = context.environment[DayEnvironment.self].value
let dayItemSize = updatedDays[0].size
let selectionRadius: CGFloat = min(dayItemSize.width, dayItemSize.height)
let deltaWidth = floor((weekdayWidth - selectionRadius) / 2.0)
let deltaHeight = floor((weekdaySize - selectionRadius) / 2.0)
let minX = sideInset + CGFloat(selection.range.lowerBound) * weekdayWidth + deltaWidth
let maxX = sideInset + CGFloat(selection.range.upperBound + 1) * weekdayWidth - deltaWidth
let minY = baseDayY + CGFloat(lineIndex) * (weekdaySize + weekdaySpacing) + deltaHeight
let maxY = minY + selectionRadius
let monthSelectionColor = context.component.theme.list.itemCheckColors.fillColor.withMultipliedAlpha(0.1)
let selectionRect = CGRect(origin: CGPoint(x: minX, y: minY), size: CGSize(width: maxX - minX, height: maxY - minY))
let selection = selections[lineIndex].update(
component: AnyComponent(ImageComponent(image: dayEnvironment.imageCache.monthSelection(leftRadius: selectionRadius, rightRadius: selectionRadius, maxRadius: selectionRadius, color: monthSelectionColor))),
availableSize: selectionRect.size,
transition: .immediate
)
let delayIndex = dayEnvironment.selectionDelayCoordination
context.add(selection
.position(CGPoint(x: selectionRect.midX, y: selectionRect.midY))
.appear(Transition.Appear { _, view, transition in
if case .none = transition.animation {
return
}
let delay = Double(min(delayIndex, 6)) * 0.1
view.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.05, delay: delay)
view.layer.animateFrame(from: CGRect(origin: view.frame.origin, size: CGSize(width: selectionRadius, height: view.frame.height)), to: view.frame, duration: 0.25, delay: delay, timingFunction: kCAMediaTimingFunctionSpring)
})
.disappear(Transition.Disappear { view, transition, completion in
if case .none = transition.animation {
completion()
return
}
view.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { _ in
completion()
})
})
)
dayEnvironment.selectionDelayCoordination += 1
}
}
for i in 0 ..< updatedDays.count {
let gridIndex = gridDayOffset(firstDayOfWeek: context.component.model.firstWeekday, firstWeekdayOfMonth: context.component.model.firstDayWeekday) + i
let rowIndex = gridIndex % 7
let lineIndex = gridIndex / 7
let gridX = sideInset + CGFloat(rowIndex) * weekdayWidth
let gridY = baseDayY + CGFloat(lineIndex) * (weekdaySize + weekdaySpacing)
let dayItemSize = updatedDays[i].size
let dayFrame = CGRect(origin: CGPoint(x: gridX + floor((weekdayWidth - dayItemSize.width) / 2.0), y: gridY + floor((weekdaySize - dayItemSize.height) / 2.0)), size: dayItemSize)
maxDayY = max(maxDayY, gridY + weekdaySize)
context.add(updatedDays[i]
.position(CGPoint(x: dayFrame.midX, y: dayFrame.midY))
)
}
return CGSize(width: context.availableSize.width, height: maxDayY)
}
}
}
private struct DayMedia: Equatable {
var message: EngineMessage
var media: EngineMedia
static func ==(lhs: DayMedia, rhs: DayMedia) -> Bool {
if lhs.message.id != rhs.message.id {
return false
}
return true
}
}
private struct MonthModel: Equatable {
var year: Int
var index: Int
var numberOfDays: Int
var firstDay: Date
var firstDayWeekday: Int
var firstWeekday: Int
var currentYear: Int
var currentMonth: Int
var currentDayOfMonth: Int
var mediaByDay: [Int: DayMedia]
init(
year: Int,
index: Int,
numberOfDays: Int,
firstDay: Date,
firstDayWeekday: Int,
firstWeekday: Int,
currentYear: Int,
currentMonth: Int,
currentDayOfMonth: Int,
mediaByDay: [Int: DayMedia]
) {
self.year = year
self.index = index
self.numberOfDays = numberOfDays
self.firstDay = firstDay
self.firstDayWeekday = firstDayWeekday
self.firstWeekday = firstWeekday
self.currentYear = currentYear
self.currentMonth = currentMonth
self.currentDayOfMonth = currentDayOfMonth
self.mediaByDay = mediaByDay
}
}
private func monthMetadata(calendar: Calendar, for baseDate: Date, currentYear: Int, currentMonth: Int, currentDayOfMonth: Int) -> MonthModel? {
guard let numberOfDaysInMonth = calendar.range(of: .day, in: .month, for: baseDate)?.count, let firstDayOfMonth = calendar.date(from: calendar.dateComponents([.year, .month], from: baseDate)) else {
return nil
}
let year = calendar.component(.year, from: firstDayOfMonth)
let month = calendar.component(.month, from: firstDayOfMonth)
let firstDayWeekday = calendar.component(.weekday, from: firstDayOfMonth)
let firstWeekday = calendar.firstWeekday
return MonthModel(
year: year,
index: month,
numberOfDays: numberOfDaysInMonth,
firstDay: firstDayOfMonth,
firstDayWeekday: firstDayWeekday,
firstWeekday: firstWeekday,
currentYear: currentYear,
currentMonth: currentMonth,
currentDayOfMonth: currentDayOfMonth,
mediaByDay: [:]
)
}
public final class CalendarMessageScreen: ViewController {
private final class Node: ViewControllerTracingNode, UIScrollViewDelegate {
struct SelectionState {
var dayRange: ClosedRange<Int32>?
}
private weak var controller: CalendarMessageScreen?
private let context: AccountContext
private let peerId: PeerId
private let initialTimestamp: Int32
private let enableMessageRangeDeletion: Bool
private let canNavigateToEmptyDays: Bool
private let navigateToOffset: (Int, Int32) -> Void
private let previewDay: (Int32, MessageIndex?, ASDisplayNode, CGRect, ContextGesture) -> Void
private var presentationData: PresentationData
private var scrollView: Scroller
private let calendarSource: SparseMessageCalendar
private var months: [MonthModel] = []
private var monthViews: [Int: ComponentHostView<DayEnvironment>] = [:]
private let contextGestureContainerNode: ContextControllerSourceNode
private let dayEnvironment: DayEnvironment
private var validLayout: (layout: ContainerViewLayout, navigationHeight: CGFloat)?
private var scrollLayout: (width: CGFloat, contentHeight: CGFloat, frames: [Int: CGRect])?
private var calendarState: SparseMessageCalendar.State?
private var isLoadingMoreDisposable: Disposable?
private var stateDisposable: Disposable?
private weak var currentGestureDayView: DayComponent.View?
private var selectionToolbarNode: ToolbarNode?
private(set) var selectionState: SelectionState?
private var ignoreContentOffset: Bool = false
init(
controller: CalendarMessageScreen,
context: AccountContext,
peerId: PeerId,
calendarSource: SparseMessageCalendar,
initialTimestamp: Int32,
enableMessageRangeDeletion: Bool,
canNavigateToEmptyDays: Bool,
navigateToOffset: @escaping (Int, Int32) -> Void,
previewDay: @escaping (Int32, MessageIndex?, ASDisplayNode, CGRect, ContextGesture) -> Void
) {
self.controller = controller
self.context = context
self.peerId = peerId
self.initialTimestamp = initialTimestamp
self.enableMessageRangeDeletion = enableMessageRangeDeletion
self.canNavigateToEmptyDays = canNavigateToEmptyDays
self.calendarSource = calendarSource
self.navigateToOffset = navigateToOffset
self.previewDay = previewDay
self.presentationData = context.sharedContext.currentPresentationData.with { $0 }
self.contextGestureContainerNode = ContextControllerSourceNode()
self.scrollView = Scroller()
self.scrollView.showsVerticalScrollIndicator = true
self.scrollView.showsHorizontalScrollIndicator = false
self.scrollView.scrollsToTop = false
self.scrollView.delaysContentTouches = false
self.scrollView.canCancelContentTouches = true
if #available(iOS 11.0, *) {
self.scrollView.contentInsetAdjustmentBehavior = .never
}
self.scrollView.layer.transform = CATransform3DMakeRotation(CGFloat.pi, 0.0, 0.0, 1.0)
self.scrollView.disablesInteractiveModalDismiss = true
if self.presentationData.theme.overallDarkAppearance {
self.scrollView.indicatorStyle = .white
} else {
self.scrollView.indicatorStyle = .black
}
self.dayEnvironment = DayEnvironment(imageCache: ImageCache(), directImageCache: DirectMediaImageCache(account: context.account))
super.init()
self.contextGestureContainerNode.shouldBegin = { [weak self] point in
guard let strongSelf = self else {
return false
}
guard let result = strongSelf.contextGestureContainerNode.view.hitTest(point, with: nil) as? UIButton else {
return false
}
guard let dayView = result as? DayComponent.View else {
return false
}
strongSelf.currentGestureDayView = dayView
return true
}
self.contextGestureContainerNode.customActivationProgress = { [weak self] progress, update in
guard let strongSelf = self, let currentGestureDayView = strongSelf.currentGestureDayView else {
return
}
let itemLayer = currentGestureDayView.layer
let targetContentRect = CGRect(origin: CGPoint(), size: itemLayer.bounds.size)
let scaleSide = itemLayer.bounds.width
let minScale: CGFloat = max(0.7, (scaleSide - 15.0) / scaleSide)
let currentScale = 1.0 * (1.0 - progress) + minScale * progress
let originalCenterOffsetX: CGFloat = itemLayer.bounds.width / 2.0 - targetContentRect.midX
let scaledCenterOffsetX: CGFloat = originalCenterOffsetX * currentScale
let originalCenterOffsetY: CGFloat = itemLayer.bounds.height / 2.0 - targetContentRect.midY
let scaledCenterOffsetY: CGFloat = originalCenterOffsetY * currentScale
let scaleMidX: CGFloat = scaledCenterOffsetX - originalCenterOffsetX
let scaleMidY: CGFloat = scaledCenterOffsetY - originalCenterOffsetY
switch update {
case .update:
let sublayerTransform = CATransform3DTranslate(CATransform3DScale(CATransform3DIdentity, currentScale, currentScale, 1.0), scaleMidX, scaleMidY, 0.0)
itemLayer.sublayerTransform = sublayerTransform
case .begin:
let sublayerTransform = CATransform3DTranslate(CATransform3DScale(CATransform3DIdentity, currentScale, currentScale, 1.0), scaleMidX, scaleMidY, 0.0)
itemLayer.sublayerTransform = sublayerTransform
case .ended:
let sublayerTransform = CATransform3DTranslate(CATransform3DScale(CATransform3DIdentity, currentScale, currentScale, 1.0), scaleMidX, scaleMidY, 0.0)
let previousTransform = itemLayer.sublayerTransform
itemLayer.sublayerTransform = sublayerTransform
itemLayer.animate(from: NSValue(caTransform3D: previousTransform), to: NSValue(caTransform3D: sublayerTransform), keyPath: "sublayerTransform", timingFunction: CAMediaTimingFunctionName.easeOut.rawValue, duration: 0.2)
}
}
self.contextGestureContainerNode.activated = { [weak self] gesture, _ in
guard let strongSelf = self, let currentGestureDayView = strongSelf.currentGestureDayView else {
return
}
strongSelf.currentGestureDayView = nil
currentGestureDayView.isUserInteractionEnabled = false
currentGestureDayView.isUserInteractionEnabled = true
if currentGestureDayView.index == nil && !strongSelf.canNavigateToEmptyDays {
return
}
if let timestamp = currentGestureDayView.timestamp {
strongSelf.previewDay(timestamp, currentGestureDayView.index, strongSelf, currentGestureDayView.convert(currentGestureDayView.bounds, to: strongSelf.view), gesture)
}
}
let calendar = Calendar.current
let baseDate = Date()
let currentYear = calendar.component(.year, from: baseDate)
let currentMonth = calendar.component(.month, from: baseDate)
let currentDayOfMonth = calendar.component(.day, from: baseDate)
for i in 0 ..< 12 * 20 {
guard let firstDayOfMonth = calendar.date(from: calendar.dateComponents([.year, .month], from: baseDate)) else {
break
}
guard let monthBaseDate = calendar.date(byAdding: .month, value: -i, to: firstDayOfMonth) else {
break
}
guard let monthModel = monthMetadata(calendar: calendar, for: monthBaseDate, currentYear: currentYear, currentMonth: currentMonth, currentDayOfMonth: currentDayOfMonth) else {
break
}
let firstDayTimestamp = Int32(monthModel.firstDay.timeIntervalSince1970)
let lastDayTimestamp = firstDayTimestamp + 24 * 60 * 60 * Int32(monthModel.numberOfDays)
if let minTimestamp = calendarSource.minTimestamp, minTimestamp > lastDayTimestamp {
break
}
if monthModel.year < 2013 {
break
}
if monthModel.year == 2013 {
if monthModel.index < 8 {
break
}
}
self.months.append(monthModel)
}
self.backgroundColor = self.presentationData.theme.list.plainBackgroundColor
self.scrollView.delegate = self
self.addSubnode(self.contextGestureContainerNode)
self.contextGestureContainerNode.view.addSubview(self.scrollView)
self.isLoadingMoreDisposable = (self.calendarSource.isLoadingMore
|> distinctUntilChanged
|> filter { !$0 }
|> deliverOnMainQueue).start(next: { [weak self] _ in
guard let strongSelf = self else {
return
}
strongSelf.calendarSource.loadMore()
})
self.stateDisposable = (self.calendarSource.state
|> deliverOnMainQueue).start(next: { [weak self] state in
guard let strongSelf = self else {
return
}
strongSelf.calendarState = state
strongSelf.reloadMediaInfo()
})
}
deinit {
self.isLoadingMoreDisposable?.dispose()
self.stateDisposable?.dispose()
}
func toggleSelectionMode() {
var transition: Transition = .immediate
if self.selectionState == nil {
self.selectionState = SelectionState(dayRange: nil)
} else {
self.selectionState = nil
transition = Transition(animation: .curve(duration: 0.25, curve: .easeInOut))
transition = transition.withUserData(SelectionTransition.end)
}
self.contextGestureContainerNode.isGestureEnabled = self.selectionState == nil
self.updateSelectionState(transition: transition)
}
func selectDay(timestamp: Int32) {
if let selectionState = self.selectionState, selectionState.dayRange == timestamp ... timestamp {
self.selectionState = SelectionState(dayRange: nil)
} else {
self.selectionState = SelectionState(dayRange: timestamp ... timestamp)
}
self.contextGestureContainerNode.isGestureEnabled = self.selectionState == nil
if let (layout, navigationHeight) = self.validLayout {
self.containerLayoutUpdated(layout: layout, navigationHeight: navigationHeight, transition: .animated(duration: 0.5, curve: .spring), componentsTransition: .immediate)
}
}
func openClearHistory(timestamp: Int32) {
self.selectionState = SelectionState(dayRange: timestamp ... timestamp)
self.selectionToolbarActionSelected()
}
func containerLayoutUpdated(layout: ContainerViewLayout, navigationHeight: CGFloat, transition: ContainedViewLayoutTransition, componentsTransition: Transition) {
let isFirstLayout = self.validLayout == nil
self.validLayout = (layout, navigationHeight)
var tabBarHeight: CGFloat
var options: ContainerViewLayoutInsetOptions = []
if layout.metrics.widthClass == .regular {
options.insert(.input)
}
let bottomInset: CGFloat = layout.insets(options: options).bottom
if !layout.safeInsets.left.isZero {
tabBarHeight = 34.0 + bottomInset
} else {
tabBarHeight = 49.0 + bottomInset
}
let tabBarFrame = CGRect(origin: CGPoint(x: 0.0, y: layout.size.height - tabBarHeight), size: CGSize(width: layout.size.width, height: tabBarHeight))
if let selectionState = self.selectionState {
let selectionToolbarNode: ToolbarNode
let toolbarText: String
var selectedCount = 0
if let dayRange = selectionState.dayRange {
for i in 0 ..< self.months.count {
let firstDayTimestamp = Int32(self.months[i].firstDay.timeIntervalSince1970)
for day in 0 ..< self.months[i].numberOfDays {
let dayTimestamp = firstDayTimestamp + 24 * 60 * 60 * Int32(day)
if dayRange.contains(dayTimestamp) {
selectedCount += 1
}
}
}
}
if selectedCount == 0 {
toolbarText = self.presentationData.strings.DialogList_ClearHistoryConfirmation
} else if selectedCount == 1 {
toolbarText = self.presentationData.strings.MessageCalendar_ClearHistoryForThisDay
} else {
toolbarText = self.presentationData.strings.MessageCalendar_ClearHistoryForTheseDays
}
if let currrent = self.selectionToolbarNode {
selectionToolbarNode = currrent
transition.updateFrame(node: selectionToolbarNode, frame: tabBarFrame)
selectionToolbarNode.updateLayout(size: tabBarFrame.size, leftInset: layout.safeInsets.left, rightInset: layout.safeInsets.right, additionalSideInsets: layout.additionalInsets, bottomInset: bottomInset, toolbar: Toolbar(leftAction: nil, rightAction: nil, middleAction: ToolbarAction(title: toolbarText, isEnabled: true, color: .custom(self.selectionState?.dayRange != nil ? self.presentationData.theme.list.itemDestructiveColor : self.presentationData.theme.list.itemDisabledTextColor))), transition: transition)
} else {
selectionToolbarNode = ToolbarNode(
theme: TabBarControllerTheme(
rootControllerTheme: self.presentationData.theme),
displaySeparator: true,
left: {
},
right: {
},
middle: { [weak self] in
self?.selectionToolbarActionSelected()
}
)
selectionToolbarNode.frame = tabBarFrame
selectionToolbarNode.updateLayout(size: tabBarFrame.size, leftInset: layout.safeInsets.left, rightInset: layout.safeInsets.right, additionalSideInsets: layout.additionalInsets, bottomInset: bottomInset, toolbar: Toolbar(leftAction: nil, rightAction: nil, middleAction: ToolbarAction(title: toolbarText, isEnabled: true, color: .custom(self.selectionState?.dayRange != nil ? self.presentationData.theme.list.itemDestructiveColor : self.presentationData.theme.list.itemDisabledTextColor))), transition: .immediate)
self.addSubnode(selectionToolbarNode)
self.selectionToolbarNode = selectionToolbarNode
transition.animatePositionAdditive(node: selectionToolbarNode, offset: CGPoint(x: 0.0, y: tabBarFrame.height))
}
} else if let selectionToolbarNode = self.selectionToolbarNode {
self.selectionToolbarNode = nil
transition.updatePosition(node: selectionToolbarNode, position: CGPoint(x: selectionToolbarNode.position.x, y: selectionToolbarNode.position.y + tabBarFrame.height), completion: { [weak selectionToolbarNode] _ in
selectionToolbarNode?.removeFromSupernode()
})
}
let _ = self.updateScrollLayoutIfNeeded()
let previousInset = self.scrollView.contentInset.top
let updatedInset = self.selectionToolbarNode?.bounds.height ?? 0.0
if previousInset != updatedInset {
let delta = updatedInset - previousInset
self.ignoreContentOffset = true
let contentOffset = self.scrollView.contentOffset
self.scrollView.contentInset = UIEdgeInsets(top: updatedInset, left: 0.0, bottom: 0.0, right: 0.0)
var updatedContentOffset = CGPoint(x: contentOffset.x, y: contentOffset.y - delta)
if updatedContentOffset.y > self.scrollView.contentSize.height - self.scrollView.bounds.height {
updatedContentOffset.y = self.scrollView.contentSize.height - self.scrollView.bounds.height
}
if updatedContentOffset.y < -self.scrollView.contentInset.top {
updatedContentOffset.y = -self.scrollView.contentInset.top
}
self.scrollView.contentOffset = updatedContentOffset
self.ignoreContentOffset = false
transition.animateOffsetAdditive(layer: self.scrollView.layer, offset: contentOffset.y - updatedContentOffset.y)
}
if isFirstLayout {
let initialDate = Date(timeIntervalSince1970: TimeInterval(self.initialTimestamp))
var initialMonthIndex: Int?
if self.months.count > 1 {
for i in 0 ..< self.months.count - 1 {
if initialDate >= self.months[i].firstDay {
initialMonthIndex = i
break
}
}
}
if let initialMonthIndex = initialMonthIndex, let frame = self.scrollLayout?.frames[initialMonthIndex] {
var contentOffset = floor(frame.midY - self.scrollView.bounds.height / 2.0)
if contentOffset < 0 {
contentOffset = 0
}
if contentOffset > self.scrollView.contentSize.height - self.scrollView.bounds.height {
contentOffset = self.scrollView.contentSize.height - self.scrollView.bounds.height
}
self.ignoreContentOffset = true
self.scrollView.setContentOffset(CGPoint(x: 0.0, y: contentOffset), animated: false)
self.ignoreContentOffset = false
}
} else {
}
updateMonthViews(transition: componentsTransition)
}
private func selectionToolbarActionSelected() {
if self.selectionState?.dayRange == nil {
if let selectionToolbarNode = self.selectionToolbarNode {
let toolbarFrame = selectionToolbarNode.view.convert(selectionToolbarNode.bounds, to: self.view)
self.controller?.present(TooltipScreen(account: self.context.account, text: self.presentationData.strings.MessageCalendar_EmptySelectionTooltip, style: .default, icon: .none, location: .point(toolbarFrame.insetBy(dx: 0.0, dy: 10.0), .bottom), shouldDismissOnTouch: { point in
return .dismiss(consume: false)
}), in: .current)
}
return
}
guard let selectionState = self.selectionState, let dayRange = selectionState.dayRange else {
return
}
var selectedCount = 0
var minTimestamp: Int32?
var maxTimestamp: Int32?
for i in 0 ..< self.months.count {
let firstDayTimestamp = Int32(self.months[i].firstDay.timeIntervalSince1970)
for day in 0 ..< self.months[i].numberOfDays {
let dayTimestamp = firstDayTimestamp + 24 * 60 * 60 * Int32(day)
let nextDayTimestamp = dayTimestamp + 24 * 60 * 60
let minDayTimestamp = dayTimestamp
let maxDayTimestamp = nextDayTimestamp
if dayRange.contains(dayTimestamp) {
if let currentMinTimestamp = minTimestamp {
minTimestamp = min(minDayTimestamp, currentMinTimestamp)
} else {
minTimestamp = minDayTimestamp
}
if let currentMaxTimestamp = maxTimestamp {
maxTimestamp = max(maxDayTimestamp, currentMaxTimestamp)
} else {
maxTimestamp = maxDayTimestamp
}
selectedCount += 1
}
}
}
guard let minTimestampValue = minTimestamp, let maxTimestampValue = maxTimestamp else {
return
}
if selectedCount == 0 {
return
}
enum ClearType {
case savedMessages
case secretChat
case group
case channel
case user
}
struct ClearInfo {
var canClearForMyself: ClearType?
var canClearForEveryone: ClearType?
var mainPeer: Peer
}
let peerId = self.peerId
if peerId.namespace == Namespaces.Peer.CloudUser || peerId.namespace == Namespaces.Peer.SecretChat {
} else {
return
}
let _ = (self.context.account.postbox.transaction { transaction -> ClearInfo? in
guard let chatPeer = transaction.getPeer(peerId) else {
return nil
}
let canClearForMyself: ClearType?
let canClearForEveryone: ClearType?
if peerId == self.context.account.peerId {
canClearForMyself = .savedMessages
canClearForEveryone = nil
} else if chatPeer is TelegramSecretChat {
canClearForMyself = .secretChat
canClearForEveryone = nil
} else if let group = chatPeer as? TelegramGroup {
switch group.role {
case .creator:
canClearForMyself = .group
canClearForEveryone = nil
case .admin, .member:
canClearForMyself = .group
canClearForEveryone = nil
}
} else if let channel = chatPeer as? TelegramChannel {
if channel.hasPermission(.deleteAllMessages) {
if case .group = channel.info {
canClearForEveryone = .group
} else {
canClearForEveryone = .channel
}
} else {
canClearForEveryone = nil
}
canClearForMyself = nil
} else {
canClearForMyself = .user
if let user = chatPeer as? TelegramUser, user.botInfo != nil {
canClearForEveryone = nil
} else {
canClearForEveryone = .user
}
}
return ClearInfo(
canClearForMyself: canClearForMyself,
canClearForEveryone: canClearForEveryone,
mainPeer: chatPeer
)
}
|> deliverOnMainQueue).start(next: { [weak self] info in
guard let strongSelf = self, let info = info else {
return
}
let actionSheet = ActionSheetController(presentationData: strongSelf.presentationData)
var items: [ActionSheetItem] = []
let beginClear: (InteractiveHistoryClearingType) -> Void = { type in
guard let strongSelf = self else {
return
}
strongSelf.controller?.completedWithRemoveMessagesInRange?(minTimestampValue ... maxTimestampValue, type, selectedCount, strongSelf.calendarSource)
strongSelf.controller?.dismiss(completion: nil)
}
if let _ = info.canClearForMyself ?? info.canClearForEveryone {
items.append(ActionSheetTextItem(title: strongSelf.presentationData.strings.MessageCalendar_DeleteAlertText(Int32(selectedCount))))
if let canClearForEveryone = info.canClearForEveryone {
let text: String
let confirmationText: String
switch canClearForEveryone {
case .user:
text = strongSelf.presentationData.strings.ChatList_DeleteForEveryone(EnginePeer(info.mainPeer).compactDisplayTitle).string
confirmationText = strongSelf.presentationData.strings.ChatList_DeleteForEveryoneConfirmationText
default:
text = strongSelf.presentationData.strings.Conversation_DeleteMessagesForEveryone
confirmationText = strongSelf.presentationData.strings.ChatList_DeleteForAllMembersConfirmationText
}
let _ = confirmationText
items.append(ActionSheetButtonItem(title: text, color: .destructive, action: { [weak actionSheet] in
actionSheet?.dismissAnimated()
beginClear(.forEveryone)
}))
}
if let canClearForMyself = info.canClearForMyself {
let text: String
switch canClearForMyself {
case .savedMessages, .secretChat:
text = strongSelf.presentationData.strings.Conversation_DeleteManyMessages
default:
text = strongSelf.presentationData.strings.ChatList_DeleteForCurrentUser
}
items.append(ActionSheetButtonItem(title: text, color: .destructive, action: { [weak actionSheet] in
actionSheet?.dismissAnimated()
beginClear(.forLocalPeer)
}))
}
}
actionSheet.setItemGroups([ActionSheetItemGroup(items: items), ActionSheetItemGroup(items: [
ActionSheetButtonItem(title: strongSelf.presentationData.strings.Common_Cancel, color: .accent, font: .bold, action: { [weak actionSheet] in
actionSheet?.dismissAnimated()
})
])])
strongSelf.controller?.present(actionSheet, in: .window(.root))
})
}
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
self.contextGestureContainerNode.cancelGesture()
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
if !self.ignoreContentOffset {
if let indicator = scrollView.value(forKey: "_verticalScrollIndicator") as? UIView {
indicator.transform = CGAffineTransform(scaleX: -1.0, y: 1.0)
}
self.updateMonthViews(transition: .immediate)
}
}
func updateScrollLayoutIfNeeded() -> Bool {
guard let (layout, navigationHeight) = self.validLayout else {
return false
}
if self.scrollLayout?.width == layout.size.width {
return false
}
var contentHeight: CGFloat = layout.intrinsicInsets.bottom
var frames: [Int: CGRect] = [:]
let measureView = ComponentHostView<DayEnvironment>()
for i in 0 ..< self.months.count {
let monthSize = measureView.update(
transition: .immediate,
component: AnyComponent(MonthComponent(
context: self.context,
model: self.months[i],
foregroundColor: .black,
strings: self.presentationData.strings,
theme: self.presentationData.theme,
dayAction: { _ in
},
monthAction: { _ in
},
selectedDays: nil
)),
environment: {
self.dayEnvironment
},
containerSize: CGSize(width: layout.size.width, height: 10000.0
))
let monthFrame = CGRect(origin: CGPoint(x: 0.0, y: contentHeight), size: monthSize)
contentHeight += monthSize.height
if i != self.months.count {
contentHeight += 16.0
}
frames[i] = monthFrame
}
self.scrollLayout = (layout.size.width, contentHeight, frames)
self.contextGestureContainerNode.frame = CGRect(origin: CGPoint(x: 0.0, y: navigationHeight), size: CGSize(width: layout.size.width, height: layout.size.height - navigationHeight))
self.scrollView.frame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: layout.size.width, height: layout.size.height - navigationHeight))
self.scrollView.contentSize = CGSize(width: layout.size.width, height: contentHeight)
self.scrollView.scrollIndicatorInsets = UIEdgeInsets(top: max(layout.intrinsicInsets.bottom, self.scrollView.contentInset.top), left: 0.0, bottom: 0.0, right: layout.size.width - 3.0 - 6.0)
return true
}
func updateMonthViews(transition: Transition) {
guard let (width, _, frames) = self.scrollLayout else {
return
}
self.dayEnvironment.selectionDelayCoordination = 0
let visibleRect = self.scrollView.bounds.insetBy(dx: 0.0, dy: -200.0)
var validMonths = Set<Int>()
for i in (0 ..< self.months.count).reversed() {
guard let monthFrame = frames[i] else {
continue
}
if !visibleRect.intersects(monthFrame) {
continue
}
validMonths.insert(i)
var monthTransition = transition
let monthView: ComponentHostView<DayEnvironment>
if let current = self.monthViews[i] {
monthView = current
} else {
monthTransition = .immediate
monthView = ComponentHostView()
monthView.layer.transform = CATransform3DMakeRotation(CGFloat.pi, 0.0, 0.0, 1.0)
self.monthViews[i] = monthView
self.scrollView.addSubview(monthView)
}
let _ = monthView.update(
transition: monthTransition,
component: AnyComponent(MonthComponent(
context: self.context,
model: self.months[i],
foregroundColor: self.presentationData.theme.list.itemPrimaryTextColor,
strings: self.presentationData.strings,
theme: self.presentationData.theme,
dayAction: { [weak self] timestamp in
guard let strongSelf = self else {
return
}
if var selectionState = strongSelf.selectionState {
var transition = Transition(animation: .curve(duration: 0.2, curve: .spring))
if let dayRange = selectionState.dayRange {
if dayRange.lowerBound == timestamp || dayRange.upperBound == timestamp {
selectionState.dayRange = nil
transition = transition.withUserData(SelectionTransition.end)
} else if dayRange.lowerBound == dayRange.upperBound {
if timestamp < dayRange.lowerBound {
selectionState.dayRange = timestamp ... dayRange.upperBound
} else {
selectionState.dayRange = dayRange.lowerBound ... timestamp
}
transition = transition.withUserData(SelectionTransition.change)
} else {
selectionState.dayRange = timestamp ... timestamp
transition = transition.withUserData(SelectionTransition.change)
}
} else {
selectionState.dayRange = timestamp ... timestamp
transition = transition.withUserData(SelectionTransition.begin)
}
strongSelf.selectionState = selectionState
strongSelf.updateSelectionState(transition: transition)
} else if let calendarState = strongSelf.calendarState {
outer: for month in strongSelf.months {
let firstDayTimestamp = Int32(month.firstDay.timeIntervalSince1970)
for day in 0 ..< month.numberOfDays {
let dayTimestamp = firstDayTimestamp + 24 * 60 * 60 * Int32(day)
if dayTimestamp == timestamp {
if month.mediaByDay[day] != nil || strongSelf.canNavigateToEmptyDays {
var offset = 0
for key in calendarState.messagesByDay.keys.sorted(by: { $0 > $1 }) {
if key == dayTimestamp {
break
} else if let item = calendarState.messagesByDay[key] {
offset += item.count
}
}
strongSelf.navigateToOffset(offset, dayTimestamp)
}
break outer
}
}
}
}
},
monthAction: { [weak self] range in
guard let strongSelf = self else {
return
}
guard var selectionState = strongSelf.selectionState else {
return
}
var transition = Transition(animation: .curve(duration: 0.2, curve: .spring))
if let dayRange = selectionState.dayRange {
if dayRange == range {
selectionState.dayRange = nil
transition = transition.withUserData(SelectionTransition.end)
} else {
selectionState.dayRange = range
transition = transition.withUserData(SelectionTransition.change)
}
} else {
selectionState.dayRange = range
transition = transition.withUserData(SelectionTransition.begin)
}
strongSelf.selectionState = selectionState
strongSelf.updateSelectionState(transition: transition)
},
selectedDays: self.selectionState?.dayRange
)),
environment: {
self.dayEnvironment
},
containerSize: CGSize(width: width, height: 10000.0
))
monthView.frame = monthFrame
}
var removeMonths: [Int] = []
for (index, view) in self.monthViews {
if !validMonths.contains(index) {
view.removeFromSuperview()
removeMonths.append(index)
}
}
for index in removeMonths {
self.monthViews.removeValue(forKey: index)
}
}
private func updateSelectionState(transition: Transition) {
var title = self.presentationData.strings.MessageCalendar_Title
if let selectionState = self.selectionState, let dayRange = selectionState.dayRange {
var selectedCount = 0
for i in 0 ..< self.months.count {
let firstDayTimestamp = Int32(self.months[i].firstDay.timeIntervalSince1970)
for day in 0 ..< self.months[i].numberOfDays {
let dayTimestamp = firstDayTimestamp + 24 * 60 * 60 * Int32(day)
if dayRange.contains(dayTimestamp) {
selectedCount += 1
}
}
}
if selectedCount != 0 {
title = self.presentationData.strings.MessageCalendar_DaysSelectedTitle(Int32(selectedCount))
}
}
self.controller?.navigationItem.title = title
if let (layout, navigationHeight) = self.validLayout {
self.containerLayoutUpdated(layout: layout, navigationHeight: navigationHeight, transition: .animated(duration: 0.5, curve: .spring), componentsTransition: transition)
}
}
private func reloadMediaInfo() {
guard let calendarState = self.calendarState else {
return
}
var messageMap: [Message] = []
for (_, entry) in calendarState.messagesByDay {
messageMap.append(entry.message)
}
var updatedMedia: [Int: [Int: DayMedia]] = [:]
for i in 0 ..< self.months.count {
if updatedMedia[i] == nil {
updatedMedia[i] = [:]
}
let firstDayTimestamp = Int32(self.months[i].firstDay.timeIntervalSince1970)
for day in 0 ..< self.months[i].numberOfDays {
let dayTimestamp = firstDayTimestamp + 24 * 60 * 60 * Int32(day)
let nextDayTimestamp = dayTimestamp + 24 * 60 * 60
for message in messageMap {
if message.timestamp >= dayTimestamp && message.timestamp < nextDayTimestamp {
mediaLoop: for media in message.media {
switch media {
case _ as TelegramMediaImage, _ as TelegramMediaFile:
updatedMedia[i]![day] = DayMedia(message: EngineMessage(message), media: EngineMedia(media))
break mediaLoop
default:
break
}
}
break
}
}
}
}
for (monthIndex, mediaByDay) in updatedMedia {
self.months[monthIndex].mediaByDay = mediaByDay
}
self.updateMonthViews(transition: .immediate)
}
}
private var node: Node {
return self.displayNode as! Node
}
private let context: AccountContext
private let peerId: PeerId
private let calendarSource: SparseMessageCalendar
private let initialTimestamp: Int32
private let enableMessageRangeDeletion: Bool
private let canNavigateToEmptyDays: Bool
private let navigateToDay: (CalendarMessageScreen, Int, Int32) -> Void
private let previewDay: (Int32, MessageIndex?, ASDisplayNode, CGRect, ContextGesture) -> Void
private var presentationData: PresentationData
public var completedWithRemoveMessagesInRange: ((ClosedRange<Int32>, InteractiveHistoryClearingType, Int, SparseMessageCalendar) -> Void)?
public init(
context: AccountContext,
peerId: PeerId,
calendarSource: SparseMessageCalendar,
initialTimestamp: Int32,
enableMessageRangeDeletion: Bool,
canNavigateToEmptyDays: Bool,
navigateToDay: @escaping (CalendarMessageScreen, Int, Int32) -> Void,
previewDay: @escaping (Int32, MessageIndex?, ASDisplayNode, CGRect, ContextGesture) -> Void
) {
self.context = context
self.peerId = peerId
self.calendarSource = calendarSource
self.initialTimestamp = initialTimestamp
self.enableMessageRangeDeletion = enableMessageRangeDeletion
self.canNavigateToEmptyDays = canNavigateToEmptyDays
self.navigateToDay = navigateToDay
self.previewDay = previewDay
self.presentationData = self.context.sharedContext.currentPresentationData.with { $0 }
super.init(navigationBarPresentationData: NavigationBarPresentationData(presentationData: self.presentationData))
self.navigationPresentation = .modal
self.navigationItem.setLeftBarButton(UIBarButtonItem(title: self.presentationData.strings.Common_Cancel, style: .plain, target: self, action: #selector(dismissPressed)), animated: false)
self.navigationItem.setTitle(self.presentationData.strings.MessageCalendar_Title, animated: false)
if self.enableMessageRangeDeletion {
if peerId.namespace == Namespaces.Peer.CloudUser || peerId.namespace == Namespaces.Peer.SecretChat {
self.navigationItem.setRightBarButton(UIBarButtonItem(title: self.presentationData.strings.Common_Select, style: .plain, target: self, action: #selector(self.toggleSelectPressed)), animated: false)
}
}
}
required public init(coder aDecoder: NSCoder) {
preconditionFailure()
}
@objc private func dismissPressed() {
self.dismiss()
}
@objc fileprivate func toggleSelectPressed() {
if !self.enableMessageRangeDeletion {
return
}
self.node.toggleSelectionMode()
if self.node.selectionState != nil {
self.navigationItem.setRightBarButton(UIBarButtonItem(title: self.presentationData.strings.Common_Done, style: .done, target: self, action: #selector(self.toggleSelectPressed)), animated: true)
} else {
self.navigationItem.setRightBarButton(UIBarButtonItem(title: self.presentationData.strings.Common_Select, style: .plain, target: self, action: #selector(self.toggleSelectPressed)), animated: true)
}
}
public func selectDay(timestamp: Int32) {
self.node.selectDay(timestamp: timestamp)
if self.node.selectionState != nil {
self.navigationItem.setRightBarButton(UIBarButtonItem(title: self.presentationData.strings.Common_Done, style: .done, target: self, action: #selector(self.toggleSelectPressed)), animated: true)
}
}
public func openClearHistory(timestamp: Int32) {
self.node.openClearHistory(timestamp: timestamp)
}
override public func loadDisplayNode() {
self.displayNode = Node(
controller: self,
context: self.context,
peerId: self.peerId,
calendarSource: self.calendarSource,
initialTimestamp: self.initialTimestamp,
enableMessageRangeDeletion: self.enableMessageRangeDeletion,
canNavigateToEmptyDays: self.canNavigateToEmptyDays,
navigateToOffset: { [weak self] index, timestamp in
guard let strongSelf = self else {
return
}
strongSelf.navigateToDay(strongSelf, index, timestamp)
},
previewDay: self.previewDay
)
self.displayNodeDidLoad()
}
override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) {
super.containerLayoutUpdated(layout, transition: transition)
self.node.containerLayoutUpdated(layout: layout, navigationHeight: self.navigationLayout(layout: layout).navigationFrame.maxY, transition: transition, componentsTransition: .immediate)
}
}