mirror of
https://github.com/Swiftgram/Telegram-iOS.git
synced 2025-06-15 21:45:19 +00:00
1940 lines
86 KiB
Swift
1940 lines
86 KiB
Swift
import Foundation
|
|
import UIKit
|
|
import Display
|
|
import AsyncDisplayKit
|
|
import SwiftSignalKit
|
|
import TelegramCore
|
|
import AccountContext
|
|
import TelegramPresentationData
|
|
import ComponentFlow
|
|
import PhotoResources
|
|
import DirectMediaImageCache
|
|
import TelegramStringFormatting
|
|
import TooltipUI
|
|
|
|
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).startStrict(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
|
|
}
|
|
}).strict()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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 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: EngineMessage.Index?
|
|
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, state: EmptyComponentState, 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(Image(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, ASScrollViewDelegate {
|
|
struct SelectionState {
|
|
var dayRange: ClosedRange<Int32>?
|
|
}
|
|
|
|
private weak var controller: CalendarMessageScreen?
|
|
private let context: AccountContext
|
|
private let peerId: EnginePeer.Id
|
|
private let initialTimestamp: Int32
|
|
private let enableMessageRangeDeletion: Bool
|
|
private let canNavigateToEmptyDays: Bool
|
|
private let navigateToOffset: (Int, Int32) -> Void
|
|
private let previewDay: (Int32, EngineMessage.Index?, 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: EnginePeer.Id,
|
|
calendarSource: SparseMessageCalendar,
|
|
initialTimestamp: Int32,
|
|
enableMessageRangeDeletion: Bool,
|
|
canNavigateToEmptyDays: Bool,
|
|
navigateToOffset: @escaping (Int, Int32) -> Void,
|
|
previewDay: @escaping (Int32, EngineMessage.Index?, 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.wrappedScrollViewDelegate
|
|
self.addSubnode(self.contextGestureContainerNode)
|
|
self.contextGestureContainerNode.view.addSubview(self.scrollView)
|
|
|
|
self.isLoadingMoreDisposable = (self.calendarSource.isLoadingMore
|
|
|> distinctUntilChanged
|
|
|> filter { !$0 }
|
|
|> deliverOnMainQueue).startStrict(next: { [weak self] _ in
|
|
guard let strongSelf = self else {
|
|
return
|
|
}
|
|
strongSelf.calendarSource.loadMore()
|
|
}).strict()
|
|
|
|
self.stateDisposable = (self.calendarSource.state
|
|
|> deliverOnMainQueue).startStrict(next: { [weak self] state in
|
|
guard let strongSelf = self else {
|
|
return
|
|
}
|
|
strongSelf.calendarState = state
|
|
strongSelf.reloadMediaInfo()
|
|
}).strict()
|
|
}
|
|
|
|
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: ToolbarTheme(
|
|
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, sharedContext: self.context.sharedContext, text: .plain(text: self.presentationData.strings.MessageCalendar_EmptySelectionTooltip), style: .default, icon: .none, location: .point(toolbarFrame.insetBy(dx: 0.0, dy: 10.0), .bottom), shouldDismissOnTouch: { _, _ 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: EnginePeer
|
|
}
|
|
|
|
let peerId = self.peerId
|
|
if peerId.namespace == Namespaces.Peer.CloudUser || peerId.namespace == Namespaces.Peer.SecretChat {
|
|
} else {
|
|
return
|
|
}
|
|
|
|
let _ = (self.context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: peerId))
|
|
|> map { chatPeer -> ClearInfo? in
|
|
guard let chatPeer = chatPeer else {
|
|
return nil
|
|
}
|
|
|
|
let canClearForMyself: ClearType?
|
|
let canClearForEveryone: ClearType?
|
|
|
|
if peerId == self.context.account.peerId {
|
|
canClearForMyself = .savedMessages
|
|
canClearForEveryone = nil
|
|
} else if case .secretChat = chatPeer {
|
|
canClearForMyself = .secretChat
|
|
canClearForEveryone = nil
|
|
} else if case let .legacyGroup(group) = chatPeer {
|
|
switch group.role {
|
|
case .creator:
|
|
canClearForMyself = .group
|
|
canClearForEveryone = nil
|
|
case .admin, .member:
|
|
canClearForMyself = .group
|
|
canClearForEveryone = nil
|
|
}
|
|
} else if case let .channel(channel) = chatPeer {
|
|
if channel.hasPermission(.deleteAllMessages) {
|
|
if case .group = channel.info {
|
|
canClearForEveryone = .group
|
|
} else {
|
|
canClearForEveryone = .channel
|
|
}
|
|
} else {
|
|
canClearForEveryone = nil
|
|
}
|
|
canClearForMyself = nil
|
|
} else {
|
|
canClearForMyself = .user
|
|
|
|
if case let .user(user) = chatPeer, user.botInfo != nil {
|
|
canClearForEveryone = nil
|
|
} else {
|
|
canClearForEveryone = .user
|
|
}
|
|
}
|
|
|
|
return ClearInfo(
|
|
canClearForMyself: canClearForMyself,
|
|
canClearForEveryone: canClearForEveryone,
|
|
mainPeer: chatPeer
|
|
)
|
|
}
|
|
|> deliverOnMainQueue).startStandalone(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(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: [EngineMessage] = []
|
|
for (_, entry) in calendarState.messagesByDay {
|
|
messageMap.append(EngineMessage(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: 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: EnginePeer.Id
|
|
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, EngineMessage.Index?, ASDisplayNode, CGRect, ContextGesture) -> Void
|
|
|
|
private var presentationData: PresentationData
|
|
|
|
public var completedWithRemoveMessagesInRange: ((ClosedRange<Int32>, InteractiveHistoryClearingType, Int, SparseMessageCalendar) -> Void)?
|
|
|
|
public init(
|
|
context: AccountContext,
|
|
peerId: EnginePeer.Id,
|
|
calendarSource: SparseMessageCalendar,
|
|
initialTimestamp: Int32,
|
|
enableMessageRangeDeletion: Bool,
|
|
canNavigateToEmptyDays: Bool,
|
|
navigateToDay: @escaping (CalendarMessageScreen, Int, Int32) -> Void,
|
|
previewDay: @escaping (Int32, EngineMessage.Index?, 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)
|
|
}
|
|
}
|