mirror of
https://github.com/Swiftgram/Telegram-iOS.git
synced 2025-06-15 21:45:19 +00:00
4752 lines
216 KiB
Swift
4752 lines
216 KiB
Swift
import AsyncDisplayKit
|
|
import AVFoundation
|
|
import Display
|
|
import TelegramCore
|
|
import SwiftSignalKit
|
|
import Postbox
|
|
import TelegramPresentationData
|
|
import AccountContext
|
|
import ContextUI
|
|
import PhotoResources
|
|
import RadialStatusNode
|
|
import TelegramStringFormatting
|
|
import GridMessageSelectionNode
|
|
import UniversalMediaPlayer
|
|
import ListMessageItem
|
|
import ChatMessageInteractiveMediaBadge
|
|
import SparseItemGrid
|
|
import ShimmerEffect
|
|
import QuartzCore
|
|
import DirectMediaImageCache
|
|
import ComponentFlow
|
|
import TelegramNotices
|
|
import TelegramUIPreferences
|
|
import CheckNode
|
|
import AppBundle
|
|
import InvisibleInkDustNode
|
|
import MediaPickerUI
|
|
import StoryContainerScreen
|
|
import EmptyStateIndicatorComponent
|
|
import UIKitRuntimeUtils
|
|
import PeerInfoPaneNode
|
|
import ShareController
|
|
import UndoUI
|
|
import PlainButtonComponent
|
|
import ComponentDisplayAdapters
|
|
import MediaEditorScreen
|
|
import AvatarNode
|
|
import LocationUI
|
|
import CoreLocation
|
|
import Geocoding
|
|
import ItemListUI
|
|
import MultilineTextComponent
|
|
import LocationUI
|
|
import TabSelectorComponent
|
|
import LanguageSelectionScreen
|
|
|
|
private let mediaBadgeBackgroundColor = UIColor(white: 0.0, alpha: 0.6)
|
|
private let mediaBadgeTextColor = UIColor.white
|
|
|
|
private final class VisualMediaItemInteraction {
|
|
let openItem: (EngineStoryItem) -> Void
|
|
let openItemContextActions: (EngineStoryItem, ASDisplayNode, CGRect, ContextGesture?) -> Void
|
|
let toggleSelection: (Int32, Bool) -> Void
|
|
|
|
var hiddenStories = Set<StoryId>()
|
|
var selectedIds: Set<Int32>?
|
|
|
|
init(
|
|
openItem: @escaping (EngineStoryItem) -> Void,
|
|
openItemContextActions: @escaping (EngineStoryItem, ASDisplayNode, CGRect, ContextGesture?) -> Void,
|
|
toggleSelection: @escaping (Int32, Bool) -> Void
|
|
) {
|
|
self.openItem = openItem
|
|
self.openItemContextActions = openItemContextActions
|
|
self.toggleSelection = toggleSelection
|
|
}
|
|
}
|
|
|
|
private final class VisualMediaHoleAnchor: SparseItemGrid.HoleAnchor {
|
|
let storyId: StoryId
|
|
override var id: AnyHashable {
|
|
return AnyHashable(self.storyId)
|
|
}
|
|
|
|
let indexValue: Int
|
|
override var index: Int {
|
|
return self.indexValue
|
|
}
|
|
|
|
let localMonthTimestamp: Int32
|
|
override var tag: Int32 {
|
|
return self.localMonthTimestamp
|
|
}
|
|
|
|
init(index: Int, storyId: StoryId, localMonthTimestamp: Int32) {
|
|
self.indexValue = index
|
|
self.storyId = storyId
|
|
self.localMonthTimestamp = localMonthTimestamp
|
|
}
|
|
}
|
|
|
|
private final class VisualMediaItem: SparseItemGrid.Item {
|
|
let indexValue: Int
|
|
override var index: Int {
|
|
return self.indexValue
|
|
}
|
|
override var isReorderable: Bool {
|
|
return self.isReorderableValue
|
|
}
|
|
let localMonthTimestamp: Int32
|
|
let peer: PeerReference
|
|
let storyId: StoryId
|
|
let story: EngineStoryItem
|
|
let authorPeer: EnginePeer?
|
|
let isPinned: Bool
|
|
let isReorderableValue: Bool
|
|
|
|
override var id: AnyHashable {
|
|
return AnyHashable(self.storyId)
|
|
}
|
|
|
|
override var tag: Int32 {
|
|
return self.localMonthTimestamp
|
|
}
|
|
|
|
override var holeAnchor: SparseItemGrid.HoleAnchor {
|
|
return VisualMediaHoleAnchor(index: self.index, storyId: self.storyId, localMonthTimestamp: self.localMonthTimestamp)
|
|
}
|
|
|
|
init(index: Int, peer: PeerReference, storyId: StoryId, story: EngineStoryItem, authorPeer: EnginePeer?, isPinned: Bool, localMonthTimestamp: Int32, isReorderable: Bool) {
|
|
self.indexValue = index
|
|
self.peer = peer
|
|
self.storyId = storyId
|
|
self.story = story
|
|
self.authorPeer = authorPeer
|
|
self.isPinned = isPinned
|
|
self.localMonthTimestamp = localMonthTimestamp
|
|
self.isReorderableValue = isReorderable
|
|
}
|
|
}
|
|
|
|
private struct Month: Equatable {
|
|
var packedValue: Int32
|
|
|
|
init(packedValue: Int32) {
|
|
self.packedValue = packedValue
|
|
}
|
|
|
|
init(localTimestamp: Int32) {
|
|
var time: time_t = time_t(localTimestamp)
|
|
var timeinfo: tm = tm()
|
|
gmtime_r(&time, &timeinfo)
|
|
|
|
let year = UInt32(timeinfo.tm_year)
|
|
let month = UInt32(timeinfo.tm_mon)
|
|
|
|
self.packedValue = Int32(bitPattern: year | (month << 16))
|
|
}
|
|
|
|
var year: Int32 {
|
|
return Int32(bitPattern: (UInt32(bitPattern: self.packedValue) >> 0) & 0xffff)
|
|
}
|
|
|
|
var month: Int32 {
|
|
return Int32(bitPattern: (UInt32(bitPattern: self.packedValue) >> 16) & 0xffff)
|
|
}
|
|
}
|
|
|
|
private let durationFont: UIFont = {
|
|
Font.semibold(11.0)
|
|
}()
|
|
|
|
private let avatarFont: UIFont = {
|
|
avatarPlaceholderFont(size: 10.0)
|
|
}()
|
|
|
|
private let minDurationImage: UIImage = {
|
|
let image = generateImage(CGSize(width: 20.0, height: 20.0), rotatedContext: { size, context in
|
|
context.clear(CGRect(origin: CGPoint(), size: size))
|
|
context.setFillColor(UIColor(white: 0.0, alpha: 0.5).cgColor)
|
|
context.fillEllipse(in: CGRect(origin: CGPoint(), size: size))
|
|
if let image = UIImage(bundleImageName: "Chat/GridPlayIcon") {
|
|
UIGraphicsPushContext(context)
|
|
image.draw(in: CGRect(origin: CGPoint(x: (size.width - image.size.width) / 2.0, y: (size.height - image.size.height) / 2.0), size: image.size))
|
|
UIGraphicsPopContext()
|
|
}
|
|
})
|
|
return image!
|
|
}()
|
|
|
|
private let leftShadowImage: UIImage = {
|
|
let baseImage = UIImage(bundleImageName: "Peer Info/MediaGridShadow")!
|
|
let image = generateImage(baseImage.size, rotatedContext: { size, context in
|
|
context.clear(CGRect(origin: CGPoint(), size: size))
|
|
|
|
context.translateBy(x: size.width / 2.0, y: size.height / 2.0)
|
|
context.scaleBy(x: -1.0, y: 1.0)
|
|
context.translateBy(x: -size.width / 2.0, y: -size.height / 2.0)
|
|
|
|
UIGraphicsPushContext(context)
|
|
baseImage.draw(in: CGRect(origin: CGPoint(), size: size))
|
|
UIGraphicsPopContext()
|
|
})
|
|
return image!
|
|
}()
|
|
|
|
private let rightShadowImage: UIImage = {
|
|
let baseImage = UIImage(bundleImageName: "Peer Info/MediaGridShadow")!
|
|
let image = generateImage(baseImage.size, rotatedContext: { size, context in
|
|
context.clear(CGRect(origin: CGPoint(), size: size))
|
|
|
|
UIGraphicsPushContext(context)
|
|
baseImage.draw(in: CGRect(origin: CGPoint(), size: size))
|
|
UIGraphicsPopContext()
|
|
})
|
|
return image!
|
|
}()
|
|
|
|
private let topRightShadowImage: UIImage = {
|
|
let baseImage = UIImage(bundleImageName: "Peer Info/MediaGridShadow")!
|
|
let image = generateImage(baseImage.size, rotatedContext: { size, context in
|
|
context.clear(CGRect(origin: CGPoint(), size: size))
|
|
|
|
context.translateBy(x: size.width / 2.0, y: size.height / 2.0)
|
|
context.scaleBy(x: 1.0, y: -1.0)
|
|
context.translateBy(x: -size.width / 2.0, y: -size.height / 2.0)
|
|
|
|
UIGraphicsPushContext(context)
|
|
baseImage.draw(in: CGRect(origin: CGPoint(), size: size))
|
|
UIGraphicsPopContext()
|
|
})
|
|
return image!
|
|
}()
|
|
|
|
private let topLeftShadowImage: UIImage = {
|
|
let baseImage = UIImage(bundleImageName: "Peer Info/MediaGridShadow")!
|
|
let image = generateImage(baseImage.size, rotatedContext: { size, context in
|
|
context.clear(CGRect(origin: CGPoint(), size: size))
|
|
|
|
context.translateBy(x: size.width / 2.0, y: size.height / 2.0)
|
|
context.scaleBy(x: -1.0, y: -1.0)
|
|
context.translateBy(x: -size.width / 2.0, y: -size.height / 2.0)
|
|
|
|
UIGraphicsPushContext(context)
|
|
baseImage.draw(in: CGRect(origin: CGPoint(), size: size))
|
|
UIGraphicsPopContext()
|
|
})
|
|
return image!
|
|
}()
|
|
|
|
private let viewCountImage: UIImage = {
|
|
let baseImage = UIImage(bundleImageName: "Peer Info/MediaGridViewCount")!
|
|
let image = generateImage(baseImage.size, rotatedContext: { size, context in
|
|
context.clear(CGRect(origin: CGPoint(), size: size))
|
|
|
|
UIGraphicsPushContext(context)
|
|
baseImage.draw(in: CGRect(origin: CGPoint(), size: size))
|
|
UIGraphicsPopContext()
|
|
})
|
|
return image!
|
|
}()
|
|
|
|
private let privacyTypeImageScaleFactor: CGFloat = {
|
|
return 0.9
|
|
}()
|
|
|
|
private let topRightIconPinnedImage: UIImage = {
|
|
let baseImage = generateTintedImage(image: UIImage(bundleImageName: "Chat/Message/Pinned"), color: .white)!
|
|
let imageSize = CGSize(width: floor(baseImage.size.width * 1.0), height: floor(baseImage.size.width * 1.0))
|
|
let image = generateImage(CGSize(width: imageSize.width + 4.0, height: imageSize.height + 4.0), rotatedContext: { size, context in
|
|
context.clear(CGRect(origin: CGPoint(), size: size))
|
|
|
|
UIGraphicsPushContext(context)
|
|
baseImage.draw(in: CGRect(origin: CGPoint(x: 0.0, y: 4.0), size: imageSize))
|
|
UIGraphicsPopContext()
|
|
})
|
|
return image!
|
|
}()
|
|
|
|
private let privacyTypeContactsImage: UIImage = {
|
|
let baseImage = UIImage(bundleImageName: "Stories/PrivacyContacts")!
|
|
let imageSize = CGSize(width: floor(baseImage.size.width * privacyTypeImageScaleFactor), height: floor(baseImage.size.width * privacyTypeImageScaleFactor))
|
|
let image = generateImage(imageSize, rotatedContext: { size, context in
|
|
context.clear(CGRect(origin: CGPoint(), size: size))
|
|
|
|
UIGraphicsPushContext(context)
|
|
baseImage.draw(in: CGRect(origin: CGPoint(), size: size))
|
|
UIGraphicsPopContext()
|
|
})
|
|
return image!
|
|
}()
|
|
|
|
private let privacyTypeCloseFriendsImage: UIImage = {
|
|
let baseImage = UIImage(bundleImageName: "Stories/PrivacyCloseFriends")!
|
|
let imageSize = CGSize(width: floor(baseImage.size.width * privacyTypeImageScaleFactor), height: floor(baseImage.size.width * privacyTypeImageScaleFactor))
|
|
let image = generateImage(imageSize, rotatedContext: { size, context in
|
|
context.clear(CGRect(origin: CGPoint(), size: size))
|
|
|
|
UIGraphicsPushContext(context)
|
|
baseImage.draw(in: CGRect(origin: CGPoint(), size: size))
|
|
UIGraphicsPopContext()
|
|
})
|
|
return image!
|
|
}()
|
|
|
|
private let privacyTypeSelectedImage: UIImage = {
|
|
let baseImage = UIImage(bundleImageName: "Stories/PrivacySelectedContacts")!
|
|
let imageSize = CGSize(width: floor(baseImage.size.width * privacyTypeImageScaleFactor), height: floor(baseImage.size.width * privacyTypeImageScaleFactor))
|
|
let image = generateImage(imageSize, rotatedContext: { size, context in
|
|
context.clear(CGRect(origin: CGPoint(), size: size))
|
|
|
|
UIGraphicsPushContext(context)
|
|
baseImage.draw(in: CGRect(origin: CGPoint(), size: size))
|
|
UIGraphicsPopContext()
|
|
})
|
|
return image!
|
|
}()
|
|
|
|
private enum ItemTopRightIcon {
|
|
case privacyContacts
|
|
case privacyCloseFriends
|
|
case privacySelected
|
|
case pinned
|
|
}
|
|
|
|
private final class DurationLayer: SimpleLayer {
|
|
private var authorPeerId: EnginePeer.Id?
|
|
private var avatarLayer: SimpleLayer?
|
|
private var disposable: Disposable?
|
|
|
|
override init() {
|
|
super.init()
|
|
|
|
self.contentsGravity = .topRight
|
|
self.contentsScale = UIScreenScale
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
deinit {
|
|
self.disposable?.dispose()
|
|
}
|
|
|
|
override func action(forKey event: String) -> CAAction? {
|
|
return nullAction
|
|
}
|
|
|
|
func update(viewCount: Int32, isMin: Bool) {
|
|
if isMin {
|
|
self.contents = nil
|
|
} else {
|
|
let countString: String
|
|
if viewCount > 1000000 {
|
|
countString = "\(viewCount / 1000000)M"
|
|
} else if viewCount > 1000 {
|
|
countString = "\(viewCount / 1000)K"
|
|
} else {
|
|
countString = "\(viewCount)"
|
|
}
|
|
let string = NSAttributedString(string: countString, font: durationFont, textColor: .white)
|
|
let bounds = string.boundingRect(with: CGSize(width: 100.0, height: 100.0), options: .usesLineFragmentOrigin, context: nil)
|
|
let textSize = CGSize(width: ceil(bounds.width), height: ceil(bounds.height))
|
|
let sideInset: CGFloat = 6.0
|
|
let verticalInset: CGFloat = 2.0
|
|
let iconSpacing: CGFloat = -3.0
|
|
let image = generateImage(CGSize(width: viewCountImage.size.width + iconSpacing + textSize.width + sideInset * 2.0, height: textSize.height + verticalInset * 2.0), rotatedContext: { size, context in
|
|
context.clear(CGRect(origin: CGPoint(), size: size))
|
|
|
|
context.setBlendMode(.normal)
|
|
|
|
context.setShadow(offset: CGSize(width: 0.0, height: 0.0), blur: 2.5, color: UIColor(rgb: 0x000000, alpha: 0.22).cgColor)
|
|
|
|
UIGraphicsPushContext(context)
|
|
|
|
viewCountImage.draw(in: CGRect(origin: CGPoint(x: 0.0, y: (size.height - viewCountImage.size.height) * 0.5), size: viewCountImage.size))
|
|
|
|
string.draw(in: bounds.offsetBy(dx: sideInset + viewCountImage.size.width + iconSpacing, dy: verticalInset))
|
|
UIGraphicsPopContext()
|
|
})
|
|
self.contents = image?.cgImage
|
|
}
|
|
}
|
|
|
|
func update(duration: Int32, isMin: Bool) {
|
|
if isMin {
|
|
self.contents = minDurationImage.cgImage
|
|
} else {
|
|
let string = NSAttributedString(string: stringForDuration(duration), font: durationFont, textColor: .white)
|
|
let bounds = string.boundingRect(with: CGSize(width: 100.0, height: 100.0), options: .usesLineFragmentOrigin, context: nil)
|
|
let textSize = CGSize(width: ceil(bounds.width), height: ceil(bounds.height))
|
|
let sideInset: CGFloat = 6.0
|
|
let verticalInset: CGFloat = 2.0
|
|
let image = generateImage(CGSize(width: textSize.width + sideInset * 2.0, height: textSize.height + verticalInset * 2.0), rotatedContext: { size, context in
|
|
context.clear(CGRect(origin: CGPoint(), size: size))
|
|
|
|
context.setBlendMode(.normal)
|
|
|
|
context.setShadow(offset: CGSize(width: 0.0, height: 0.0), blur: 2.5, color: UIColor(rgb: 0x000000, alpha: 0.22).cgColor)
|
|
|
|
UIGraphicsPushContext(context)
|
|
string.draw(in: bounds.offsetBy(dx: sideInset, dy: verticalInset))
|
|
UIGraphicsPopContext()
|
|
})
|
|
self.contents = image?.cgImage
|
|
}
|
|
}
|
|
|
|
func update(topRightIcon: ItemTopRightIcon, isMin: Bool) {
|
|
if isMin {
|
|
self.contents = nil
|
|
} else {
|
|
let iconImage: UIImage
|
|
switch topRightIcon {
|
|
case .pinned:
|
|
iconImage = topRightIconPinnedImage
|
|
case .privacyContacts:
|
|
iconImage = privacyTypeContactsImage
|
|
case .privacyCloseFriends:
|
|
iconImage = privacyTypeCloseFriendsImage
|
|
case .privacySelected:
|
|
iconImage = privacyTypeSelectedImage
|
|
}
|
|
|
|
let sideInset: CGFloat = 0.0
|
|
let verticalInset: CGFloat = 0.0
|
|
let image = generateImage(CGSize(width: iconImage.size.width + sideInset * 2.0, height: iconImage.size.height + verticalInset * 2.0), rotatedContext: { size, context in
|
|
context.clear(CGRect(origin: CGPoint(), size: size))
|
|
|
|
context.setBlendMode(.normal)
|
|
|
|
context.setShadow(offset: CGSize(width: 0.0, height: 0.0), blur: 2.5, color: UIColor(rgb: 0x000000, alpha: 0.22).cgColor)
|
|
|
|
UIGraphicsPushContext(context)
|
|
|
|
iconImage.draw(in: CGRect(origin: CGPoint(x: (size.width - iconImage.size.width) * 0.5, y: (size.height - iconImage.size.height) * 0.5), size: iconImage.size))
|
|
|
|
UIGraphicsPopContext()
|
|
})
|
|
self.contents = image?.cgImage
|
|
}
|
|
}
|
|
|
|
func copyAuthor(from other: DurationLayer) {
|
|
self.contents = other.contents
|
|
|
|
let avatarLayer: SimpleLayer
|
|
if let current = self.avatarLayer {
|
|
avatarLayer = current
|
|
} else {
|
|
avatarLayer = SimpleLayer()
|
|
self.avatarLayer = avatarLayer
|
|
self.addSublayer(avatarLayer)
|
|
|
|
avatarLayer.frame = CGRect(origin: CGPoint(x: -11.0, y: 2.0), size: CGSize(width: 13.0, height: 13.0))
|
|
avatarLayer.cornerRadius = 13.0 * 0.5
|
|
avatarLayer.masksToBounds = true
|
|
}
|
|
avatarLayer.contents = other.avatarLayer?.contents
|
|
}
|
|
|
|
func update(directMediaImageCache: DirectMediaImageCache, author: EnginePeer, constrainedWidth: CGFloat, synchronous: SparseItemGrid.Synchronous) {
|
|
let avatarLayer: SimpleLayer
|
|
if let current = self.avatarLayer {
|
|
avatarLayer = current
|
|
} else {
|
|
avatarLayer = SimpleLayer()
|
|
self.avatarLayer = avatarLayer
|
|
self.addSublayer(avatarLayer)
|
|
|
|
avatarLayer.frame = CGRect(origin: CGPoint(x: -11.0, y: 2.0), size: CGSize(width: 13.0, height: 13.0))
|
|
avatarLayer.cornerRadius = 13.0 * 0.5
|
|
avatarLayer.masksToBounds = true
|
|
}
|
|
|
|
if self.authorPeerId != author.id {
|
|
let string = NSAttributedString(string: author.debugDisplayTitle, font: durationFont, textColor: .white)
|
|
let bounds = string.boundingRect(with: CGSize(width: constrainedWidth - 24.0, height: 20.0), options: [.usesLineFragmentOrigin, .truncatesLastVisibleLine], context: nil)
|
|
let textSize = CGSize(width: ceil(bounds.width), height: ceil(bounds.height))
|
|
let sideInset: CGFloat = 6.0
|
|
let verticalInset: CGFloat = 2.0
|
|
let image = generateImage(CGSize(width: textSize.width + sideInset * 2.0, height: textSize.height + verticalInset * 2.0), rotatedContext: { size, context in
|
|
context.clear(CGRect(origin: CGPoint(), size: size))
|
|
|
|
context.setBlendMode(.normal)
|
|
|
|
context.setShadow(offset: CGSize(width: 0.0, height: 0.0), blur: 2.5, color: UIColor(rgb: 0x000000, alpha: 0.22).cgColor)
|
|
|
|
UIGraphicsPushContext(context)
|
|
string.draw(with: bounds.offsetBy(dx: sideInset, dy: verticalInset), options: [.usesLineFragmentOrigin, .truncatesLastVisibleLine], context: nil)
|
|
UIGraphicsPopContext()
|
|
})
|
|
self.contents = image?.cgImage
|
|
|
|
if let smallProfileImage = author.smallProfileImage, let peerReference = PeerReference(author._asPeer()) {
|
|
if let result = directMediaImageCache.getAvatarImage(peer: peerReference, resource: MediaResourceReference.avatar(peer: peerReference, resource: smallProfileImage.resource), immediateThumbnail: smallProfileImage.immediateThumbnailData, size: 24, includeBlurred: true, synchronous: synchronous == .full) {
|
|
if let image = result.image {
|
|
avatarLayer.contents = image.cgImage
|
|
} else if let image = result.blurredImage {
|
|
avatarLayer.contents = image.cgImage
|
|
}
|
|
if let loadSignal = result.loadSignal {
|
|
self.disposable?.dispose()
|
|
self.disposable = (loadSignal
|
|
|> deliverOnMainQueue).start(next: { [weak self] image in
|
|
guard let self else {
|
|
return
|
|
}
|
|
self.avatarLayer?.contents = image?.cgImage
|
|
})
|
|
}
|
|
}
|
|
} else {
|
|
self.avatarLayer?.contents = generateImage(CGSize(width: 24.0, height: 24.0), rotatedContext: { size, context in
|
|
context.clear(CGRect(origin: CGPoint(), size: size))
|
|
|
|
drawPeerAvatarLetters(context: context, size: size, font: avatarFont, letters: author.displayLetters, peerId: author.id, nameColor: author.nameColor)
|
|
})?.cgImage
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private final class ItemLayer: CALayer, SparseItemGridLayer {
|
|
struct Params: Equatable {
|
|
let size: CGSize
|
|
let viewCount: Int32?
|
|
let duration: Int32?
|
|
let topRightIcon: ItemTopRightIcon?
|
|
let authorId: EnginePeer.Id?
|
|
let isMin: Bool
|
|
let minFactor: CGFloat
|
|
|
|
init(size: CGSize, viewCount: Int32?, duration: Int32?, topRightIcon: ItemTopRightIcon?, authorId: EnginePeer.Id?, isMin: Bool, minFactor: CGFloat) {
|
|
self.size = size
|
|
self.viewCount = viewCount
|
|
self.duration = duration
|
|
self.topRightIcon = topRightIcon
|
|
self.authorId = authorId
|
|
self.isMin = isMin
|
|
self.minFactor = minFactor
|
|
}
|
|
}
|
|
|
|
var item: VisualMediaItem?
|
|
var viewCountLayer: DurationLayer?
|
|
var durationLayer: DurationLayer?
|
|
var privacyTypeLayer: DurationLayer?
|
|
var authorLayer: DurationLayer?
|
|
var leftShadowLayer: SimpleLayer?
|
|
var rightShadowLayer: SimpleLayer?
|
|
var topRightShadowLayer: SimpleLayer?
|
|
var topLeftShadowLayer: SimpleLayer?
|
|
var minFactor: CGFloat = 1.0
|
|
var selectionLayer: GridMessageSelectionLayer?
|
|
var dustLayer: MediaDustLayer?
|
|
var disposable: Disposable?
|
|
|
|
var currentParams: Params?
|
|
|
|
var hasContents: Bool = false
|
|
|
|
override init() {
|
|
super.init()
|
|
|
|
self.contentsGravity = .resize
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
override init(layer: Any) {
|
|
super.init(layer: layer)
|
|
}
|
|
|
|
deinit {
|
|
self.disposable?.dispose()
|
|
}
|
|
|
|
func getContents() -> Any? {
|
|
return self.contents
|
|
}
|
|
|
|
func setContents(_ contents: Any?) {
|
|
if let image = contents as? UIImage {
|
|
self.contents = image.cgImage
|
|
}
|
|
}
|
|
|
|
func setSpoilerContents(_ contents: Any?) {
|
|
if let image = contents as? UIImage {
|
|
self.dustLayer?.contents = image.cgImage
|
|
}
|
|
}
|
|
|
|
override func action(forKey event: String) -> CAAction? {
|
|
return nullAction
|
|
}
|
|
|
|
func bind(item: VisualMediaItem) {
|
|
self.item = item
|
|
}
|
|
|
|
func updateDuration(size: CGSize, viewCount: Int32?, duration: Int32?, topRightIcon: ItemTopRightIcon?, author: EnginePeer?, isMin: Bool, minFactor: CGFloat, directMediaImageCache: DirectMediaImageCache, synchronous: SparseItemGrid.Synchronous) {
|
|
let params = Params(size: size, viewCount: viewCount, duration: duration, topRightIcon: topRightIcon, authorId: author?.id, isMin: isMin, minFactor: minFactor)
|
|
if self.currentParams == params {
|
|
return
|
|
}
|
|
self.currentParams = params
|
|
|
|
self.minFactor = minFactor
|
|
|
|
if let viewCount {
|
|
if let viewCountLayer = self.viewCountLayer {
|
|
viewCountLayer.update(viewCount: viewCount, isMin: isMin)
|
|
} else {
|
|
let viewCountLayer = DurationLayer()
|
|
viewCountLayer.contentsGravity = .topLeft
|
|
viewCountLayer.update(viewCount: viewCount, isMin: isMin)
|
|
self.addSublayer(viewCountLayer)
|
|
viewCountLayer.frame = CGRect(origin: CGPoint(x: 7.0, y: self.bounds.height - 4.0), size: CGSize())
|
|
viewCountLayer.transform = CATransform3DMakeScale(minFactor, minFactor, 1.0)
|
|
self.viewCountLayer = viewCountLayer
|
|
}
|
|
} else if let viewCountLayer = self.viewCountLayer {
|
|
self.viewCountLayer = nil
|
|
viewCountLayer.removeFromSuperlayer()
|
|
}
|
|
|
|
if let duration {
|
|
if let durationLayer = self.durationLayer {
|
|
durationLayer.update(duration: duration, isMin: isMin)
|
|
} else {
|
|
let durationLayer = DurationLayer()
|
|
durationLayer.update(duration: duration, isMin: isMin)
|
|
self.addSublayer(durationLayer)
|
|
durationLayer.frame = CGRect(origin: CGPoint(x: self.bounds.width - 3.0, y: self.bounds.height - 4.0), size: CGSize())
|
|
durationLayer.transform = CATransform3DMakeScale(minFactor, minFactor, 1.0)
|
|
self.durationLayer = durationLayer
|
|
}
|
|
} else if let durationLayer = self.durationLayer {
|
|
self.durationLayer = nil
|
|
durationLayer.removeFromSuperlayer()
|
|
}
|
|
|
|
if let topRightIcon {
|
|
if let privacyTypeLayer = self.privacyTypeLayer {
|
|
privacyTypeLayer.update(topRightIcon: topRightIcon, isMin: isMin)
|
|
} else {
|
|
let privacyTypeLayer = DurationLayer()
|
|
privacyTypeLayer.contentsGravity = .bottomRight
|
|
privacyTypeLayer.update(topRightIcon: topRightIcon, isMin: isMin)
|
|
self.addSublayer(privacyTypeLayer)
|
|
privacyTypeLayer.frame = CGRect(origin: CGPoint(x: self.bounds.width - 2.0, y: 3.0), size: CGSize())
|
|
privacyTypeLayer.transform = CATransform3DMakeScale(minFactor, minFactor, 1.0)
|
|
self.privacyTypeLayer = privacyTypeLayer
|
|
}
|
|
} else if let privacyTypeLayer = self.privacyTypeLayer {
|
|
self.privacyTypeLayer = nil
|
|
privacyTypeLayer.removeFromSuperlayer()
|
|
}
|
|
|
|
if let author {
|
|
if let authorLayer = self.authorLayer {
|
|
authorLayer.update(directMediaImageCache: directMediaImageCache, author: author, constrainedWidth: size.width, synchronous: synchronous)
|
|
} else {
|
|
let authorLayer = DurationLayer()
|
|
authorLayer.contentsGravity = .bottomLeft
|
|
authorLayer.update(directMediaImageCache: directMediaImageCache, author: author, constrainedWidth: size.width, synchronous: synchronous)
|
|
self.addSublayer(authorLayer)
|
|
authorLayer.frame = CGRect(origin: CGPoint(x: 17.0, y: 3.0), size: CGSize())
|
|
authorLayer.transform = CATransform3DMakeScale(minFactor, minFactor, 1.0)
|
|
self.authorLayer = authorLayer
|
|
}
|
|
} else if let authorLayer = self.authorLayer {
|
|
self.authorLayer = nil
|
|
authorLayer.removeFromSuperlayer()
|
|
}
|
|
|
|
let size = self.bounds.size
|
|
|
|
if self.viewCountLayer != nil {
|
|
if self.leftShadowLayer == nil {
|
|
let leftShadowLayer = SimpleLayer()
|
|
self.leftShadowLayer = leftShadowLayer
|
|
self.insertSublayer(leftShadowLayer, at: 0)
|
|
leftShadowLayer.contents = leftShadowImage.cgImage
|
|
let shadowSize = CGSize(width: min(size.width, leftShadowImage.size.width), height: min(size.height, leftShadowImage.size.height))
|
|
leftShadowLayer.frame = CGRect(origin: CGPoint(x: 0.0, y: size.height - shadowSize.height), size: shadowSize)
|
|
}
|
|
} else {
|
|
if let leftShadowLayer = self.leftShadowLayer {
|
|
self.leftShadowLayer = nil
|
|
leftShadowLayer.removeFromSuperlayer()
|
|
}
|
|
}
|
|
|
|
if self.durationLayer != nil {
|
|
if self.rightShadowLayer == nil {
|
|
let rightShadowLayer = SimpleLayer()
|
|
self.rightShadowLayer = rightShadowLayer
|
|
self.insertSublayer(rightShadowLayer, at: 0)
|
|
rightShadowLayer.contents = rightShadowImage.cgImage
|
|
let shadowSize = CGSize(width: min(size.width, rightShadowImage.size.width), height: min(size.height, rightShadowImage.size.height))
|
|
rightShadowLayer.frame = CGRect(origin: CGPoint(x: size.width - shadowSize.width, y: size.height - shadowSize.height), size: shadowSize)
|
|
}
|
|
} else {
|
|
if let rightShadowLayer = self.rightShadowLayer {
|
|
self.rightShadowLayer = nil
|
|
rightShadowLayer.removeFromSuperlayer()
|
|
}
|
|
}
|
|
|
|
if self.privacyTypeLayer != nil {
|
|
if self.topRightShadowLayer == nil {
|
|
let topRightShadowLayer = SimpleLayer()
|
|
self.topRightShadowLayer = topRightShadowLayer
|
|
self.insertSublayer(topRightShadowLayer, at: 0)
|
|
topRightShadowLayer.contents = topRightShadowImage.cgImage
|
|
let shadowSize = CGSize(width: min(size.width, topRightShadowImage.size.width), height: min(size.height, topRightShadowImage.size.height))
|
|
topRightShadowLayer.frame = CGRect(origin: CGPoint(x: size.width - shadowSize.width, y: 0.0), size: shadowSize)
|
|
}
|
|
} else {
|
|
if let topRightShadowLayer = self.topRightShadowLayer {
|
|
self.topRightShadowLayer = nil
|
|
topRightShadowLayer.removeFromSuperlayer()
|
|
}
|
|
}
|
|
|
|
if self.authorLayer != nil {
|
|
if self.topLeftShadowLayer == nil {
|
|
let topLeftShadowLayer = SimpleLayer()
|
|
self.topLeftShadowLayer = topLeftShadowLayer
|
|
self.insertSublayer(topLeftShadowLayer, at: 0)
|
|
topLeftShadowLayer.contents = topLeftShadowImage.cgImage
|
|
let shadowSize = CGSize(width: min(size.width, topLeftShadowImage.size.width), height: min(size.height, topLeftShadowImage.size.height))
|
|
topLeftShadowLayer.frame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: shadowSize)
|
|
}
|
|
} else {
|
|
if let topLeftShadowLayer = self.topLeftShadowLayer {
|
|
self.topLeftShadowLayer = nil
|
|
topLeftShadowLayer.removeFromSuperlayer()
|
|
}
|
|
}
|
|
}
|
|
|
|
func updateSelection(theme: CheckNodeTheme, isSelected: Bool?, animated: Bool) {
|
|
if let isSelected = isSelected {
|
|
if let selectionLayer = self.selectionLayer {
|
|
selectionLayer.updateSelected(isSelected, animated: animated)
|
|
} else {
|
|
let selectionLayer = GridMessageSelectionLayer(theme: theme)
|
|
selectionLayer.updateSelected(isSelected, animated: false)
|
|
self.selectionLayer = selectionLayer
|
|
self.addSublayer(selectionLayer)
|
|
if !self.bounds.isEmpty {
|
|
selectionLayer.frame = CGRect(origin: CGPoint(), size: self.bounds.size)
|
|
selectionLayer.updateLayout(size: self.bounds.size)
|
|
if animated {
|
|
selectionLayer.animateIn()
|
|
}
|
|
}
|
|
}
|
|
} else if let selectionLayer = self.selectionLayer {
|
|
self.selectionLayer = nil
|
|
if animated {
|
|
selectionLayer.animateOut { [weak selectionLayer] in
|
|
selectionLayer?.removeFromSuperlayer()
|
|
}
|
|
} else {
|
|
selectionLayer.removeFromSuperlayer()
|
|
}
|
|
}
|
|
|
|
if let privacyTypeLayer = self.privacyTypeLayer {
|
|
let privacyAlpha: Float = isSelected == nil ? 1.0 : 0.0
|
|
if privacyAlpha != privacyTypeLayer.opacity {
|
|
let previousAlpha = privacyTypeLayer.opacity
|
|
privacyTypeLayer.opacity = privacyAlpha
|
|
privacyTypeLayer.animateAlpha(from: CGFloat(previousAlpha), to: CGFloat(privacyAlpha), duration: 0.2)
|
|
}
|
|
}
|
|
|
|
if let authorLayer = self.authorLayer {
|
|
let authorAlpha: Float = isSelected == nil ? 1.0 : 0.0
|
|
if authorAlpha != authorLayer.opacity {
|
|
let previousAlpha = authorLayer.opacity
|
|
authorLayer.opacity = authorAlpha
|
|
authorLayer.animateAlpha(from: CGFloat(previousAlpha), to: CGFloat(authorAlpha), duration: 0.2)
|
|
}
|
|
}
|
|
}
|
|
|
|
func updateHasSpoiler(hasSpoiler: Bool) {
|
|
if hasSpoiler {
|
|
if let _ = self.dustLayer {
|
|
} else {
|
|
let dustLayer = MediaDustLayer()
|
|
self.dustLayer = dustLayer
|
|
self.addSublayer(dustLayer)
|
|
if !self.bounds.isEmpty {
|
|
dustLayer.frame = CGRect(origin: CGPoint(), size: self.bounds.size)
|
|
dustLayer.updateLayout(size: self.bounds.size)
|
|
}
|
|
}
|
|
} else if let dustLayer = self.dustLayer {
|
|
self.dustLayer = nil
|
|
dustLayer.removeFromSuperlayer()
|
|
}
|
|
}
|
|
|
|
func unbind() {
|
|
self.item = nil
|
|
}
|
|
|
|
func needsShimmer() -> Bool {
|
|
return !self.hasContents
|
|
}
|
|
|
|
func update(size: CGSize, insets: UIEdgeInsets, displayItem: SparseItemGridDisplayItem, binding: SparseItemGridBinding, item: SparseItemGrid.Item?) {
|
|
if let viewCountLayer = self.viewCountLayer {
|
|
viewCountLayer.frame = CGRect(origin: CGPoint(x: 7.0, y: size.height - 4.0), size: CGSize())
|
|
}
|
|
if let durationLayer = self.durationLayer {
|
|
durationLayer.frame = CGRect(origin: CGPoint(x: size.width - 3.0, y: size.height - 4.0), size: CGSize())
|
|
}
|
|
if let privacyTypeLayer = self.privacyTypeLayer {
|
|
privacyTypeLayer.frame = CGRect(origin: CGPoint(x: size.width - 2.0, y: 3.0), size: CGSize())
|
|
}
|
|
if let authorLayer = self.authorLayer {
|
|
authorLayer.frame = CGRect(origin: CGPoint(x: 17.0, y: 3.0), size: CGSize())
|
|
}
|
|
|
|
if let leftShadowLayer = self.leftShadowLayer {
|
|
let shadowSize = CGSize(width: min(size.width, leftShadowImage.size.width), height: min(size.height, leftShadowImage.size.height))
|
|
leftShadowLayer.frame = CGRect(origin: CGPoint(x: 0.0, y: size.height - shadowSize.height), size: shadowSize)
|
|
}
|
|
|
|
if let rightShadowLayer = self.rightShadowLayer {
|
|
let shadowSize = CGSize(width: min(size.width, rightShadowImage.size.width), height: min(size.height, rightShadowImage.size.height))
|
|
rightShadowLayer.frame = CGRect(origin: CGPoint(x: size.width - shadowSize.width, y: size.height - shadowSize.height), size: shadowSize)
|
|
}
|
|
|
|
if let topRightShadowLayer = self.topRightShadowLayer {
|
|
let shadowSize = CGSize(width: min(size.width, topRightShadowImage.size.width), height: min(size.height, topRightShadowImage.size.height))
|
|
topRightShadowLayer.frame = CGRect(origin: CGPoint(x: size.width - shadowSize.width, y: 0.0), size: shadowSize)
|
|
}
|
|
|
|
if let topLeftShadowLayer = self.topLeftShadowLayer {
|
|
let shadowSize = CGSize(width: min(size.width, topLeftShadowImage.size.width), height: min(size.height, topLeftShadowImage.size.height))
|
|
topLeftShadowLayer.frame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: shadowSize)
|
|
}
|
|
|
|
if let binding = binding as? SparseItemGridBindingImpl, let item = item as? VisualMediaItem, let previousItem = self.item {
|
|
if previousItem.story.media.id != item.story.media.id {
|
|
binding.bindLayers(items: [item], layers: [displayItem], size: size, insets: insets, synchronous: .none)
|
|
} else {
|
|
if let layer = displayItem.layer as? ItemLayer {
|
|
var selectedMedia: Media?
|
|
if let image = item.story.media._asMedia() as? TelegramMediaImage {
|
|
selectedMedia = image
|
|
} else if let file = item.story.media._asMedia() as? TelegramMediaFile {
|
|
selectedMedia = file
|
|
}
|
|
|
|
if let selectedMedia {
|
|
binding.updateLayerData(story: item.story, item: item, selectedMedia: selectedMedia, layer: layer, synchronous: .none)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private final class ItemTransitionView: UIView {
|
|
private weak var itemLayer: CALayer?
|
|
private var copyDurationLayer: SimpleLayer?
|
|
private var copyViewCountLayer: SimpleLayer?
|
|
private var copyPrivacyTypeLayer: SimpleLayer?
|
|
private var copyAuthorLayer: SimpleLayer?
|
|
private var copyLeftShadowLayer: SimpleLayer?
|
|
private var copyRightShadowLayer: SimpleLayer?
|
|
private var copyTopRightShadowLayer: SimpleLayer?
|
|
private var copyTopLeftShadowLayer: SimpleLayer?
|
|
|
|
private var viewCountLayerBottomLeftPosition: CGPoint?
|
|
private var durationLayerBottomLeftPosition: CGPoint?
|
|
private var privacyTypeLayerTopRightPosition: CGPoint?
|
|
private var authorLayerTopLeftPosition: CGPoint?
|
|
|
|
var selectionLayer: GridMessageSelectionLayer?
|
|
|
|
init(itemLayer: CALayer?) {
|
|
self.itemLayer = itemLayer
|
|
|
|
super.init(frame: CGRect())
|
|
|
|
if let itemLayer {
|
|
self.layer.contentsRect = itemLayer.contentsRect
|
|
|
|
var viewCountLayer: CALayer?
|
|
var durationLayer: CALayer?
|
|
var privacyTypeLayer: CALayer?
|
|
var authorLayer: CALayer?
|
|
var leftShadowLayer: CALayer?
|
|
var rightShadowLayer: CALayer?
|
|
var topRightShadowLayer: CALayer?
|
|
var topLeftShadowLayer: CALayer?
|
|
if let itemLayer = itemLayer as? ItemLayer {
|
|
viewCountLayer = itemLayer.viewCountLayer
|
|
durationLayer = itemLayer.durationLayer
|
|
privacyTypeLayer = itemLayer.privacyTypeLayer
|
|
authorLayer = itemLayer.authorLayer
|
|
leftShadowLayer = itemLayer.leftShadowLayer
|
|
rightShadowLayer = itemLayer.rightShadowLayer
|
|
topRightShadowLayer = itemLayer.topRightShadowLayer
|
|
topLeftShadowLayer = itemLayer.topLeftShadowLayer
|
|
self.layer.contents = itemLayer.contents
|
|
}
|
|
|
|
if let leftShadowLayer {
|
|
let copyLayer = SimpleLayer()
|
|
copyLayer.contents = leftShadowLayer.contents
|
|
copyLayer.contentsRect = leftShadowLayer.contentsRect
|
|
copyLayer.contentsGravity = leftShadowLayer.contentsGravity
|
|
copyLayer.contentsScale = leftShadowLayer.contentsScale
|
|
copyLayer.frame = leftShadowLayer.frame
|
|
self.layer.addSublayer(copyLayer)
|
|
self.copyLeftShadowLayer = copyLayer
|
|
}
|
|
|
|
if let rightShadowLayer {
|
|
let copyLayer = SimpleLayer()
|
|
copyLayer.contents = rightShadowLayer.contents
|
|
copyLayer.contentsRect = rightShadowLayer.contentsRect
|
|
copyLayer.contentsGravity = rightShadowLayer.contentsGravity
|
|
copyLayer.contentsScale = rightShadowLayer.contentsScale
|
|
copyLayer.frame = rightShadowLayer.frame
|
|
self.layer.addSublayer(copyLayer)
|
|
self.copyRightShadowLayer = copyLayer
|
|
}
|
|
|
|
if let topRightShadowLayer {
|
|
let copyLayer = SimpleLayer()
|
|
copyLayer.contents = topRightShadowLayer.contents
|
|
copyLayer.contentsRect = topRightShadowLayer.contentsRect
|
|
copyLayer.contentsGravity = topRightShadowLayer.contentsGravity
|
|
copyLayer.contentsScale = topRightShadowLayer.contentsScale
|
|
copyLayer.frame = topRightShadowLayer.frame
|
|
self.layer.addSublayer(copyLayer)
|
|
self.copyTopRightShadowLayer = copyLayer
|
|
}
|
|
|
|
if let topLeftShadowLayer {
|
|
let copyLayer = SimpleLayer()
|
|
copyLayer.contents = topLeftShadowLayer.contents
|
|
copyLayer.contentsRect = topLeftShadowLayer.contentsRect
|
|
copyLayer.contentsGravity = topLeftShadowLayer.contentsGravity
|
|
copyLayer.contentsScale = topLeftShadowLayer.contentsScale
|
|
copyLayer.frame = topLeftShadowLayer.frame
|
|
self.layer.addSublayer(copyLayer)
|
|
self.copyTopLeftShadowLayer = copyLayer
|
|
}
|
|
|
|
if let viewCountLayer {
|
|
let copyViewCountLayer = SimpleLayer()
|
|
copyViewCountLayer.contents = viewCountLayer.contents
|
|
copyViewCountLayer.contentsRect = viewCountLayer.contentsRect
|
|
copyViewCountLayer.contentsGravity = viewCountLayer.contentsGravity
|
|
copyViewCountLayer.contentsScale = viewCountLayer.contentsScale
|
|
copyViewCountLayer.frame = viewCountLayer.frame
|
|
self.layer.addSublayer(copyViewCountLayer)
|
|
self.copyViewCountLayer = copyViewCountLayer
|
|
|
|
self.viewCountLayerBottomLeftPosition = CGPoint(x: viewCountLayer.frame.minX, y: itemLayer.bounds.height - viewCountLayer.frame.maxY)
|
|
}
|
|
|
|
if let privacyTypeLayer {
|
|
let copyPrivacyTypeLayer = SimpleLayer()
|
|
copyPrivacyTypeLayer.contents = privacyTypeLayer.contents
|
|
copyPrivacyTypeLayer.contentsRect = privacyTypeLayer.contentsRect
|
|
copyPrivacyTypeLayer.contentsGravity = privacyTypeLayer.contentsGravity
|
|
copyPrivacyTypeLayer.contentsScale = privacyTypeLayer.contentsScale
|
|
copyPrivacyTypeLayer.frame = privacyTypeLayer.frame
|
|
self.layer.addSublayer(copyPrivacyTypeLayer)
|
|
self.copyPrivacyTypeLayer = copyPrivacyTypeLayer
|
|
|
|
self.privacyTypeLayerTopRightPosition = CGPoint(x: itemLayer.bounds.width - privacyTypeLayer.frame.maxX, y: privacyTypeLayer.frame.minY)
|
|
}
|
|
|
|
if let authorLayer = authorLayer as? DurationLayer {
|
|
let copyAuthorLayer = DurationLayer()
|
|
copyAuthorLayer.contentsRect = authorLayer.contentsRect
|
|
copyAuthorLayer.contentsGravity = authorLayer.contentsGravity
|
|
copyAuthorLayer.contentsScale = authorLayer.contentsScale
|
|
copyAuthorLayer.frame = authorLayer.frame
|
|
copyAuthorLayer.copyAuthor(from: authorLayer)
|
|
self.layer.addSublayer(copyAuthorLayer)
|
|
self.copyAuthorLayer = copyAuthorLayer
|
|
|
|
self.authorLayerTopLeftPosition = CGPoint(x: authorLayer.frame.minX, y: authorLayer.frame.minY)
|
|
}
|
|
|
|
if let durationLayer {
|
|
let copyDurationLayer = SimpleLayer()
|
|
copyDurationLayer.contents = durationLayer.contents
|
|
copyDurationLayer.contentsRect = durationLayer.contentsRect
|
|
copyDurationLayer.contentsGravity = durationLayer.contentsGravity
|
|
copyDurationLayer.contentsScale = durationLayer.contentsScale
|
|
copyDurationLayer.frame = durationLayer.frame
|
|
self.layer.addSublayer(copyDurationLayer)
|
|
self.copyDurationLayer = copyDurationLayer
|
|
|
|
self.durationLayerBottomLeftPosition = CGPoint(x: itemLayer.bounds.width - durationLayer.frame.maxX, y: itemLayer.bounds.height - durationLayer.frame.maxY)
|
|
}
|
|
}
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
func update(state: StoryContainerScreen.TransitionState, transition: ComponentTransition) {
|
|
let size = state.sourceSize.interpolate(to: state.destinationSize, amount: state.progress)
|
|
|
|
if let copyDurationLayer = self.copyDurationLayer, let durationLayerBottomLeftPosition = self.durationLayerBottomLeftPosition {
|
|
transition.setFrame(layer: copyDurationLayer, frame: CGRect(origin: CGPoint(x: size.width - durationLayerBottomLeftPosition.x - copyDurationLayer.bounds.width, y: size.height - durationLayerBottomLeftPosition.y - copyDurationLayer.bounds.height), size: copyDurationLayer.bounds.size))
|
|
}
|
|
|
|
if let copyViewCountLayer = self.copyViewCountLayer, let viewCountLayerBottomLeftPosition = self.viewCountLayerBottomLeftPosition {
|
|
transition.setFrame(layer: copyViewCountLayer, frame: CGRect(origin: CGPoint(x: viewCountLayerBottomLeftPosition.x, y: size.height - viewCountLayerBottomLeftPosition.y - copyViewCountLayer.bounds.height), size: copyViewCountLayer.bounds.size))
|
|
}
|
|
|
|
if let privacyTypeLayer = self.copyPrivacyTypeLayer, let privacyTypeLayerTopRightPosition = self.privacyTypeLayerTopRightPosition {
|
|
transition.setFrame(layer: privacyTypeLayer, frame: CGRect(origin: CGPoint(x: size.width - privacyTypeLayerTopRightPosition.x, y: privacyTypeLayerTopRightPosition.y), size: privacyTypeLayer.bounds.size))
|
|
}
|
|
|
|
if let authorLayer = self.copyAuthorLayer, let authorLayerTopLeftPosition = self.authorLayerTopLeftPosition {
|
|
transition.setFrame(layer: authorLayer, frame: CGRect(origin: CGPoint(x: authorLayerTopLeftPosition.x, y: authorLayerTopLeftPosition.y), size: authorLayer.bounds.size))
|
|
}
|
|
|
|
if let copyLeftShadowLayer = self.copyLeftShadowLayer {
|
|
transition.setFrame(layer: copyLeftShadowLayer, frame: CGRect(origin: CGPoint(x: 0.0, y: size.height - copyLeftShadowLayer.bounds.height), size: copyLeftShadowLayer.bounds.size))
|
|
}
|
|
|
|
if let copyRightShadowLayer = self.copyRightShadowLayer {
|
|
transition.setFrame(layer: copyRightShadowLayer, frame: CGRect(origin: CGPoint(x: size.width - copyRightShadowLayer.bounds.width, y: size.height - copyRightShadowLayer.bounds.height), size: copyRightShadowLayer.bounds.size))
|
|
}
|
|
|
|
if let copyTopRightShadowLayer = self.copyTopRightShadowLayer {
|
|
transition.setFrame(layer: copyTopRightShadowLayer, frame: CGRect(origin: CGPoint(x: size.width - copyTopRightShadowLayer.bounds.width, y: 0.0), size: copyTopRightShadowLayer.bounds.size))
|
|
}
|
|
|
|
if let copyTopLeftShadowLayer = self.copyTopLeftShadowLayer {
|
|
transition.setFrame(layer: copyTopLeftShadowLayer, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: copyTopLeftShadowLayer.bounds.size))
|
|
}
|
|
}
|
|
|
|
func updateSelection(theme: CheckNodeTheme, isSelected: Bool?, animated: Bool) {
|
|
if let isSelected = isSelected {
|
|
if let selectionLayer = self.selectionLayer {
|
|
selectionLayer.updateSelected(isSelected, animated: animated)
|
|
} else {
|
|
let selectionLayer = GridMessageSelectionLayer(theme: theme)
|
|
selectionLayer.updateSelected(isSelected, animated: false)
|
|
self.selectionLayer = selectionLayer
|
|
self.layer.addSublayer(selectionLayer)
|
|
if !self.bounds.isEmpty {
|
|
selectionLayer.frame = CGRect(origin: CGPoint(), size: self.bounds.size)
|
|
selectionLayer.updateLayout(size: self.bounds.size)
|
|
if animated {
|
|
selectionLayer.animateIn()
|
|
}
|
|
}
|
|
}
|
|
} else if let selectionLayer = self.selectionLayer {
|
|
self.selectionLayer = nil
|
|
if animated {
|
|
selectionLayer.animateOut { [weak selectionLayer] in
|
|
selectionLayer?.removeFromSuperlayer()
|
|
}
|
|
} else {
|
|
selectionLayer.removeFromSuperlayer()
|
|
}
|
|
}
|
|
|
|
if let copyPrivacyTypeLayer = self.copyPrivacyTypeLayer {
|
|
let privacyAlpha: Float = isSelected == nil ? 1.0 : 0.0
|
|
if privacyAlpha != copyPrivacyTypeLayer.opacity {
|
|
let previousAlpha = copyPrivacyTypeLayer.opacity
|
|
copyPrivacyTypeLayer.opacity = privacyAlpha
|
|
copyPrivacyTypeLayer.animateAlpha(from: CGFloat(previousAlpha), to: CGFloat(privacyAlpha), duration: 0.2)
|
|
}
|
|
}
|
|
|
|
if let copyAuthorLayer = self.copyAuthorLayer {
|
|
let privacyAlpha: Float = isSelected == nil ? 1.0 : 0.0
|
|
if privacyAlpha != copyAuthorLayer.opacity {
|
|
let previousAlpha = copyAuthorLayer.opacity
|
|
copyAuthorLayer.opacity = privacyAlpha
|
|
copyAuthorLayer.animateAlpha(from: CGFloat(previousAlpha), to: CGFloat(privacyAlpha), duration: 0.2)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private final class SparseItemGridBindingImpl: SparseItemGridBinding {
|
|
let context: AccountContext
|
|
let directMediaImageCache: DirectMediaImageCache
|
|
let captureProtected: Bool
|
|
let displayPrivacy: Bool
|
|
var strings: PresentationStrings
|
|
var chatPresentationData: ChatPresentationData
|
|
var checkNodeTheme: CheckNodeTheme
|
|
|
|
var itemInteraction: VisualMediaItemInteraction?
|
|
var loadHoleImpl: ((SparseItemGrid.HoleAnchor, SparseItemGrid.HoleLocation) -> Signal<Never, NoError>)?
|
|
var onTapImpl: ((VisualMediaItem, CALayer, CGPoint) -> Void)?
|
|
var onTagTapImpl: (() -> Void)?
|
|
var didScrollImpl: (() -> Void)?
|
|
var coveringInsetOffsetUpdatedImpl: ((ContainedViewLayoutTransition) -> Void)?
|
|
var scrollingOffsetUpdatedImpl: ((ContainedViewLayoutTransition) -> Void)?
|
|
var onBeginFastScrollingImpl: (() -> Void)?
|
|
var getShimmerColorsImpl: (() -> SparseItemGrid.ShimmerColors)?
|
|
var updateShimmerLayersImpl: ((SparseItemGridDisplayItem) -> Void)?
|
|
var reorderIfPossibleImpl: ((SparseItemGrid.Item, Int) -> Void)?
|
|
|
|
var revealedSpoilerMessageIds = Set<MessageId>()
|
|
|
|
private var shimmerImages: [CGFloat: UIImage] = [:]
|
|
|
|
init(context: AccountContext, directMediaImageCache: DirectMediaImageCache, captureProtected: Bool, displayPrivacy: Bool) {
|
|
self.context = context
|
|
self.directMediaImageCache = directMediaImageCache
|
|
self.captureProtected = false
|
|
self.displayPrivacy = displayPrivacy
|
|
|
|
let presentationData = self.context.sharedContext.currentPresentationData.with { $0 }
|
|
self.strings = presentationData.strings
|
|
|
|
let themeData = ChatPresentationThemeData(theme: presentationData.theme, wallpaper: presentationData.chatWallpaper)
|
|
self.chatPresentationData = ChatPresentationData(theme: themeData, fontSize: presentationData.chatFontSize, strings: presentationData.strings, dateTimeFormat: presentationData.dateTimeFormat, nameDisplayOrder: presentationData.nameDisplayOrder, disableAnimations: true, largeEmoji: presentationData.largeEmoji, chatBubbleCorners: presentationData.chatBubbleCorners, animatedEmojiScale: 1.0)
|
|
|
|
self.checkNodeTheme = CheckNodeTheme(theme: presentationData.theme, style: .overlay, hasInset: true)
|
|
}
|
|
|
|
func updatePresentationData(presentationData: PresentationData) {
|
|
self.strings = presentationData.strings
|
|
|
|
let themeData = ChatPresentationThemeData(theme: presentationData.theme, wallpaper: presentationData.chatWallpaper)
|
|
self.chatPresentationData = ChatPresentationData(theme: themeData, fontSize: presentationData.chatFontSize, strings: presentationData.strings, dateTimeFormat: presentationData.dateTimeFormat, nameDisplayOrder: presentationData.nameDisplayOrder, disableAnimations: true, largeEmoji: presentationData.largeEmoji, chatBubbleCorners: presentationData.chatBubbleCorners, animatedEmojiScale: 1.0)
|
|
|
|
self.checkNodeTheme = CheckNodeTheme(theme: presentationData.theme, style: .overlay, hasInset: true)
|
|
}
|
|
|
|
func getSeparatorColor() -> UIColor {
|
|
return self.chatPresentationData.theme.theme.list.itemPlainSeparatorColor
|
|
}
|
|
|
|
func createLayer(item: SparseItemGrid.Item) -> SparseItemGridLayer? {
|
|
if let item = item as? VisualMediaItem, item.story.isForwardingDisabled {
|
|
let layer = ItemLayer()
|
|
setLayerDisableScreenshots(layer, true)
|
|
return layer
|
|
} else {
|
|
return ItemLayer()
|
|
}
|
|
}
|
|
|
|
func createView() -> SparseItemGridView? {
|
|
return nil
|
|
}
|
|
|
|
func createShimmerLayer() -> SparseItemGridShimmerLayer? {
|
|
return nil
|
|
}
|
|
|
|
private static let widthSpecs: ([Int], [Int]) = {
|
|
let list: [(Int, Int)] = [
|
|
(50, 64),
|
|
(100, 150),
|
|
(140, 200),
|
|
(Int.max, 280)
|
|
]
|
|
return (list.map(\.0), list.map(\.1))
|
|
}()
|
|
|
|
func bindLayers(items: [SparseItemGrid.Item], layers: [SparseItemGridDisplayItem], size: CGSize, insets: UIEdgeInsets, synchronous: SparseItemGrid.Synchronous) {
|
|
for i in 0 ..< items.count {
|
|
guard let item = items[i] as? VisualMediaItem else {
|
|
continue
|
|
}
|
|
|
|
let displayItem = layers[i]
|
|
|
|
guard let layer = displayItem.layer as? ItemLayer else {
|
|
continue
|
|
}
|
|
if layer.bounds.isEmpty {
|
|
continue
|
|
}
|
|
|
|
var imageWidthSpec: Int = SparseItemGridBindingImpl.widthSpecs.1[0]
|
|
for i in 0 ..< SparseItemGridBindingImpl.widthSpecs.0.count {
|
|
if Int(layer.bounds.width) <= SparseItemGridBindingImpl.widthSpecs.0[i] {
|
|
imageWidthSpec = SparseItemGridBindingImpl.widthSpecs.1[i]
|
|
break
|
|
}
|
|
}
|
|
|
|
let story = item.story
|
|
let hasSpoiler = false
|
|
layer.updateHasSpoiler(hasSpoiler: hasSpoiler)
|
|
|
|
var selectedMedia: Media?
|
|
if let image = story.media._asMedia() as? TelegramMediaImage {
|
|
selectedMedia = image
|
|
} else if let file = story.media._asMedia() as? TelegramMediaFile {
|
|
selectedMedia = file
|
|
}
|
|
|
|
if let selectedMedia = selectedMedia {
|
|
if let result = directMediaImageCache.getImage(peer: item.peer, story: story, media: selectedMedia, width: imageWidthSpec, aspectRatio: 0.81, possibleWidths: SparseItemGridBindingImpl.widthSpecs.1, includeBlurred: hasSpoiler || displayItem.blurLayer != nil, synchronous: synchronous == .full) {
|
|
if let image = result.image {
|
|
layer.setContents(image)
|
|
|
|
/*switch synchronous {
|
|
case .none:
|
|
layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2, completion: { [weak self, weak layer, weak displayItem] _ in
|
|
layer?.hasContents = true
|
|
if let displayItem = displayItem {
|
|
self?.updateShimmerLayersImpl?(displayItem)
|
|
}
|
|
})
|
|
default:
|
|
layer.hasContents = true
|
|
}*/
|
|
layer.hasContents = true
|
|
}
|
|
if let image = result.blurredImage {
|
|
layer.setSpoilerContents(image)
|
|
|
|
if let blurLayer = displayItem.blurLayer {
|
|
blurLayer.contentsGravity = .resizeAspectFill
|
|
blurLayer.contents = result.blurredImage?.cgImage
|
|
}
|
|
}
|
|
if let loadSignal = result.loadSignal {
|
|
layer.disposable?.dispose()
|
|
let startTimestamp = CFAbsoluteTimeGetCurrent()
|
|
layer.disposable = (loadSignal
|
|
|> deliverOnMainQueue).start(next: { [weak self, weak layer, weak displayItem] image in
|
|
guard let layer = layer else {
|
|
return
|
|
}
|
|
let deltaTime = CFAbsoluteTimeGetCurrent() - startTimestamp
|
|
var synchronousValue: Bool
|
|
switch synchronous {
|
|
case .none, .full:
|
|
synchronousValue = false
|
|
case .semi:
|
|
synchronousValue = deltaTime < 0.1
|
|
}
|
|
if "".isEmpty {
|
|
synchronousValue = true
|
|
}
|
|
|
|
if let contents = layer.getContents(), !synchronousValue {
|
|
let copyLayer = ItemLayer()
|
|
copyLayer.contents = contents
|
|
copyLayer.contentsRect = layer.contentsRect
|
|
copyLayer.frame = layer.bounds
|
|
if let durationLayer = layer.durationLayer {
|
|
layer.insertSublayer(copyLayer, below: durationLayer)
|
|
} else {
|
|
layer.addSublayer(copyLayer)
|
|
}
|
|
copyLayer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak copyLayer] _ in
|
|
copyLayer?.removeFromSuperlayer()
|
|
})
|
|
|
|
layer.setContents(image)
|
|
layer.hasContents = true
|
|
if let displayItem = displayItem {
|
|
self?.updateShimmerLayersImpl?(displayItem)
|
|
}
|
|
} else {
|
|
layer.setContents(image)
|
|
|
|
if !synchronousValue {
|
|
layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2, completion: { [weak layer] _ in
|
|
layer?.hasContents = true
|
|
if let displayItem = displayItem {
|
|
self?.updateShimmerLayersImpl?(displayItem)
|
|
}
|
|
})
|
|
} else {
|
|
layer.hasContents = true
|
|
if let displayItem = displayItem {
|
|
self?.updateShimmerLayersImpl?(displayItem)
|
|
}
|
|
}
|
|
}
|
|
|
|
if let displayItem, let blurLayer = displayItem.blurLayer {
|
|
blurLayer.contentsGravity = .resizeAspectFill
|
|
blurLayer.contents = result.blurredImage?.cgImage
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
self.updateLayerData(story: story, item: item, selectedMedia: selectedMedia, layer: layer, synchronous: synchronous)
|
|
}
|
|
|
|
var isSelected: Bool?
|
|
if let selectedIds = self.itemInteraction?.selectedIds {
|
|
isSelected = selectedIds.contains(story.id)
|
|
}
|
|
layer.updateSelection(theme: self.checkNodeTheme, isSelected: isSelected, animated: false)
|
|
|
|
layer.bind(item: item)
|
|
}
|
|
}
|
|
|
|
func updateLayerData(story: EngineStoryItem, item: VisualMediaItem, selectedMedia: Media, layer: ItemLayer, synchronous: SparseItemGrid.Synchronous) {
|
|
var viewCount: Int32?
|
|
if let value = story.views?.seenCount {
|
|
viewCount = Int32(value)
|
|
}
|
|
|
|
var topRightIcon: ItemTopRightIcon?
|
|
if item.isPinned {
|
|
topRightIcon = .pinned
|
|
} else if self.displayPrivacy, let value = story.privacy {
|
|
switch value.base {
|
|
case .everyone:
|
|
break
|
|
case .contacts:
|
|
topRightIcon = .privacyContacts
|
|
case .closeFriends:
|
|
topRightIcon = .privacyCloseFriends
|
|
case .nobody:
|
|
topRightIcon = .privacySelected
|
|
}
|
|
}
|
|
|
|
var duration: Int32?
|
|
var isMin: Bool = false
|
|
if let file = selectedMedia as? TelegramMediaFile, !file.isAnimated {
|
|
if let durationValue = file.duration {
|
|
duration = Int32(durationValue)
|
|
}
|
|
isMin = layer.bounds.width < 80.0
|
|
}
|
|
|
|
layer.updateDuration(size: layer.bounds.size, viewCount: viewCount, duration: duration, topRightIcon: topRightIcon, author: item.authorPeer, isMin: isMin, minFactor: min(1.0, layer.bounds.height / 74.0), directMediaImageCache: self.directMediaImageCache, synchronous: synchronous)
|
|
}
|
|
|
|
func unbindLayer(layer: SparseItemGridLayer) {
|
|
guard let layer = layer as? ItemLayer else {
|
|
return
|
|
}
|
|
layer.unbind()
|
|
}
|
|
|
|
func scrollerTextForTag(tag: Int32) -> String? {
|
|
let month = Month(packedValue: tag)
|
|
return stringForMonth(strings: self.strings, month: month.month, ofYear: month.year)
|
|
}
|
|
|
|
func loadHole(anchor: SparseItemGrid.HoleAnchor, at location: SparseItemGrid.HoleLocation) -> Signal<Never, NoError> {
|
|
if let loadHoleImpl = self.loadHoleImpl {
|
|
return loadHoleImpl(anchor, location)
|
|
} else {
|
|
return .never()
|
|
}
|
|
}
|
|
|
|
func reorderIfPossible(item: SparseItemGrid.Item, toIndex: Int) {
|
|
if let reorderIfPossibleImpl = self.reorderIfPossibleImpl {
|
|
reorderIfPossibleImpl(item, toIndex)
|
|
}
|
|
}
|
|
|
|
func onTap(item: SparseItemGrid.Item, itemLayer: CALayer, point: CGPoint) {
|
|
guard let item = item as? VisualMediaItem else {
|
|
return
|
|
}
|
|
self.onTapImpl?(item, itemLayer, point)
|
|
}
|
|
|
|
func onTagTap() {
|
|
self.onTagTapImpl?()
|
|
}
|
|
|
|
func didScroll() {
|
|
self.didScrollImpl?()
|
|
}
|
|
|
|
func coveringInsetOffsetUpdated(transition: ContainedViewLayoutTransition) {
|
|
self.coveringInsetOffsetUpdatedImpl?(transition)
|
|
}
|
|
|
|
func scrollingOffsetUpdated(transition: ContainedViewLayoutTransition) {
|
|
self.scrollingOffsetUpdatedImpl?(transition)
|
|
}
|
|
|
|
func onBeginFastScrolling() {
|
|
self.onBeginFastScrollingImpl?()
|
|
}
|
|
|
|
func getShimmerColors() -> SparseItemGrid.ShimmerColors {
|
|
if let getShimmerColorsImpl = self.getShimmerColorsImpl {
|
|
return getShimmerColorsImpl()
|
|
} else {
|
|
return SparseItemGrid.ShimmerColors(background: 0xffffff, foreground: 0xffffff)
|
|
}
|
|
}
|
|
}
|
|
|
|
private final class StorySearchHeaderComponent: Component {
|
|
let theme: PresentationTheme
|
|
let strings: PresentationStrings
|
|
let count: Int
|
|
|
|
init(
|
|
theme: PresentationTheme,
|
|
strings: PresentationStrings,
|
|
count: Int
|
|
) {
|
|
self.theme = theme
|
|
self.strings = strings
|
|
self.count = count
|
|
}
|
|
|
|
static func ==(lhs: StorySearchHeaderComponent, rhs: StorySearchHeaderComponent) -> Bool {
|
|
if lhs.theme !== rhs.theme {
|
|
return false
|
|
}
|
|
if lhs.strings != rhs.strings {
|
|
return false
|
|
}
|
|
if lhs.count != rhs.count {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
final class View: UIView {
|
|
private let title = ComponentView<Empty>()
|
|
|
|
private var component: StorySearchHeaderComponent?
|
|
|
|
override init(frame: CGRect) {
|
|
super.init(frame: frame)
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
func update(component: StorySearchHeaderComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
|
|
if self.component?.theme !== component.theme {
|
|
self.backgroundColor = component.theme.chatList.sectionHeaderFillColor
|
|
}
|
|
|
|
let insets = UIEdgeInsets(top: 7.0, left: 16.0, bottom: 7.0, right: 16.0)
|
|
|
|
let titleString = component.strings.StoryList_GridHeaderLocationSearch(Int32(component.count))
|
|
|
|
let titleSize = self.title.update(
|
|
transition: .immediate,
|
|
component: AnyComponent(MultilineTextComponent(
|
|
text: .plain(NSAttributedString(string: titleString, font: Font.regular(13.0), textColor: component.theme.chatList.sectionHeaderTextColor))
|
|
)),
|
|
environment: {},
|
|
containerSize: CGSize(width: availableSize.width - insets.left - insets.right, height: 100.0)
|
|
)
|
|
if let titleView = self.title.view {
|
|
if titleView.superview == nil {
|
|
self.addSubview(titleView)
|
|
}
|
|
titleView.frame = CGRect(origin: CGPoint(x: insets.left, y: insets.top), size: titleSize)
|
|
}
|
|
|
|
return CGSize(width: availableSize.width, height: titleSize.height + insets.top + insets.bottom)
|
|
}
|
|
}
|
|
|
|
func makeView() -> View {
|
|
return View(frame: CGRect())
|
|
}
|
|
|
|
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
|
|
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
|
|
}
|
|
}
|
|
|
|
public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScrollViewDelegate, ASGestureRecognizerDelegate {
|
|
public enum Scope {
|
|
case peer(id: EnginePeer.Id, isSaved: Bool, isArchived: Bool)
|
|
case search(query: String)
|
|
case location(coordinates: MediaArea.Coordinates, venue: MediaArea.Venue)
|
|
case botPreview(id: EnginePeer.Id)
|
|
}
|
|
|
|
public struct ZoomLevel {
|
|
fileprivate var value: SparseItemGrid.ZoomLevel
|
|
|
|
init(_ value: SparseItemGrid.ZoomLevel) {
|
|
self.value = value
|
|
}
|
|
|
|
var rawValue: Int32 {
|
|
return Int32(self.value.rawValue)
|
|
}
|
|
|
|
public init(rawValue: Int32) {
|
|
self.value = SparseItemGrid.ZoomLevel(rawValue: Int(rawValue))
|
|
}
|
|
}
|
|
|
|
private struct MapInfoData: Equatable {
|
|
var location: TelegramMediaMap
|
|
var address: String?
|
|
var distance: Double?
|
|
var drivingTime: ExpectedTravelTime
|
|
var transitTime: ExpectedTravelTime
|
|
var walkingTime: ExpectedTravelTime
|
|
var hasEta: Bool
|
|
|
|
init(
|
|
location: TelegramMediaMap,
|
|
address: String?,
|
|
distance: Double?,
|
|
drivingTime: ExpectedTravelTime,
|
|
transitTime: ExpectedTravelTime,
|
|
walkingTime: ExpectedTravelTime,
|
|
hasEta: Bool
|
|
) {
|
|
self.location = location
|
|
self.address = address
|
|
self.distance = distance
|
|
self.drivingTime = drivingTime
|
|
self.transitTime = transitTime
|
|
self.walkingTime = walkingTime
|
|
self.hasEta = hasEta
|
|
}
|
|
}
|
|
|
|
private let context: AccountContext
|
|
private let scope: Scope
|
|
private let isProfileEmbedded: Bool
|
|
private let canManageStories: Bool
|
|
|
|
private let navigationController: () -> NavigationController?
|
|
|
|
public weak var parentController: ViewController?
|
|
|
|
private let contextGestureContainerNode: ContextControllerSourceNode
|
|
|
|
private var mapOptionsNode: LocationOptionsNode?
|
|
private var mapNode: LocationMapHeaderNode?
|
|
private var mapDisposable: Disposable?
|
|
|
|
private var locationViewState: LocationViewState = LocationViewState() {
|
|
didSet {
|
|
self.locationViewStatePromise.set(.single(self.locationViewState))
|
|
}
|
|
}
|
|
private let locationViewStatePromise = Promise<LocationViewState>(LocationViewState())
|
|
|
|
private var mapInfoData: MapInfoData?
|
|
private var mapInfoNode: LocationInfoListItemNode?
|
|
private var searchHeader: ComponentView<Empty>?
|
|
|
|
private var botPreviewLanguageTab: ComponentView<Empty>?
|
|
private var botPreviewFooter: ComponentView<Empty>?
|
|
|
|
private var barBackgroundLayer: SimpleLayer?
|
|
|
|
private let itemGrid: SparseItemGrid
|
|
private let itemGridBinding: SparseItemGridBindingImpl
|
|
private let directMediaImageCache: DirectMediaImageCache
|
|
private var items: SparseItemGrid.Items?
|
|
private var pinnedIds: Set<Int32> = Set()
|
|
private var reorderedIds: [StoryId]?
|
|
private var itemCount: Int?
|
|
private var didUpdateItemsOnce: Bool = false
|
|
|
|
private var selectionPanel: ComponentView<Empty>?
|
|
|
|
private var isDeceleratingAfterTracking = false
|
|
|
|
private var _itemInteraction: VisualMediaItemInteraction?
|
|
private var itemInteraction: VisualMediaItemInteraction {
|
|
return self._itemInteraction!
|
|
}
|
|
|
|
public var selectedIds: Set<Int32> {
|
|
return self.itemInteraction.selectedIds ?? Set()
|
|
}
|
|
private let selectedIdsPromise = ValuePromise<Set<Int32>>(Set())
|
|
public var updatedSelectedIds: Signal<Set<Int32>, NoError> {
|
|
return self.selectedIdsPromise.get()
|
|
}
|
|
|
|
private var isReordering: Bool = false
|
|
|
|
public var selectedItems: [Int32: EngineStoryItem] {
|
|
var result: [Int32: EngineStoryItem] = [:]
|
|
for id in self.selectedIds {
|
|
if let items = self.items {
|
|
for item in items.items {
|
|
if let item = item as? VisualMediaItem {
|
|
if item.story.id == id {
|
|
result[id] = item.story
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
public var isEmpty: Bool {
|
|
if let items = self.items, items.items.count != 0 {
|
|
return false
|
|
} else {
|
|
return true
|
|
}
|
|
}
|
|
|
|
public var isEmptyUpdated: (Bool) -> Void = { _ in }
|
|
|
|
public private(set) var isSelectionModeActive: Bool
|
|
|
|
private var currentParams: (size: CGSize, topInset: CGFloat, sideInset: CGFloat, bottomInset: CGFloat, deviceMetrics: DeviceMetrics, visibleHeight: CGFloat, isScrollingLockedAtTop: Bool, expandProgress: CGFloat, navigationHeight: CGFloat, presentationData: PresentationData)?
|
|
private var listBottomInset: CGFloat?
|
|
|
|
private let ready = Promise<Bool>()
|
|
private var didSetReady: Bool = false
|
|
public var isReady: Signal<Bool, NoError> {
|
|
return self.ready.get()
|
|
}
|
|
|
|
private let statusPromise = Promise<PeerInfoStatusData?>(nil)
|
|
public var status: Signal<PeerInfoStatusData?, NoError> {
|
|
self.statusPromise.get()
|
|
}
|
|
|
|
public var tabBarOffsetUpdated: ((ContainedViewLayoutTransition) -> Void)?
|
|
public var tabBarOffset: CGFloat {
|
|
if case .botPreview = self.scope {
|
|
return 0.0
|
|
} else {
|
|
return self.itemGrid.coveringInsetOffset
|
|
}
|
|
}
|
|
|
|
private var currentListState: StoryListContext.State?
|
|
private var listDisposable: Disposable?
|
|
private var hiddenMediaDisposable: Disposable?
|
|
private let updateDisposable = MetaDisposable()
|
|
|
|
private var currentBotPreviewLanguages: [StoryListContext.State.Language] = []
|
|
private var removedBotPreviewLanguages = Set<String>()
|
|
|
|
private var numberOfItemsToRequest: Int = 50
|
|
private var isRequestingView: Bool = false
|
|
private var isFirstHistoryView: Bool = true
|
|
|
|
private var decelerationAnimator: ConstantDisplayLinkAnimator?
|
|
|
|
private var animationTimer: SwiftSignalKit.Timer?
|
|
|
|
public private(set) var calendarSource: SparseMessageCalendar?
|
|
private var listSource: StoryListContext
|
|
|
|
private let maxBotPreviewCount: Int
|
|
|
|
private let defaultListSource: StoryListContext
|
|
private var cachedListSources: [String: StoryListContext] = [:]
|
|
|
|
public var currentBotPreviewLanguage: (id: String, name: String)? {
|
|
guard let listSource = self.listSource as? BotPreviewStoryListContext else {
|
|
return nil
|
|
}
|
|
guard let id = listSource.language else {
|
|
return nil
|
|
}
|
|
guard let language = self.currentBotPreviewLanguages.first(where: { $0.id == id }) else {
|
|
return nil
|
|
}
|
|
return (language.id, language.name)
|
|
}
|
|
|
|
public var openCurrentDate: (() -> Void)?
|
|
public var paneDidScroll: (() -> Void)?
|
|
public var emptyAction: (() -> Void)?
|
|
public var additionalEmptyAction: (() -> Void)?
|
|
|
|
public var ensureRectVisible: ((UIView, CGRect) -> Void)?
|
|
|
|
private weak var currentGestureItem: SparseItemGridDisplayItem?
|
|
|
|
private var presentationData: PresentationData
|
|
private var presentationDataDisposable: Disposable?
|
|
|
|
private weak var pendingOpenListContext: PeerStoryListContentContextImpl?
|
|
|
|
private var preloadArchiveListContext: StoryListContext?
|
|
|
|
private var emptyStateView: ComponentView<Empty>?
|
|
|
|
private weak var contextControllerToDismissOnSelection: ContextControllerProtocol?
|
|
private weak var tempContextContentItemNode: TempExtractedItemNode?
|
|
|
|
public var additionalNavigationHeight: CGFloat {
|
|
if self.locationViewState.displayingMapModeOptions {
|
|
return 38.0
|
|
}
|
|
return 0.0
|
|
}
|
|
|
|
public init(context: AccountContext, scope: Scope, captureProtected: Bool, isProfileEmbedded: Bool, canManageStories: Bool, navigationController: @escaping () -> NavigationController?, listContext: StoryListContext?) {
|
|
self.context = context
|
|
self.scope = scope
|
|
self.navigationController = navigationController
|
|
self.isProfileEmbedded = isProfileEmbedded
|
|
self.canManageStories = canManageStories
|
|
|
|
switch scope {
|
|
case let .peer(_, _, isArchived):
|
|
self.isSelectionModeActive = !isProfileEmbedded && isArchived
|
|
default:
|
|
self.isSelectionModeActive = false
|
|
}
|
|
|
|
self.presentationData = self.context.sharedContext.currentPresentationData.with { $0 }
|
|
|
|
self.contextGestureContainerNode = ContextControllerSourceNode()
|
|
self.itemGrid = SparseItemGrid(theme: self.presentationData.theme)
|
|
self.directMediaImageCache = DirectMediaImageCache(account: context.account)
|
|
|
|
self.itemGridBinding = SparseItemGridBindingImpl(
|
|
context: context,
|
|
directMediaImageCache: self.directMediaImageCache,
|
|
captureProtected: captureProtected,
|
|
displayPrivacy: isProfileEmbedded
|
|
)
|
|
|
|
if let listContext {
|
|
self.listSource = listContext
|
|
} else {
|
|
switch self.scope {
|
|
case let .peer(id, _, isArchived):
|
|
self.listSource = PeerStoryListContext(account: context.account, peerId: id, isArchived: isArchived)
|
|
case let .search(query):
|
|
self.listSource = SearchStoryListContext(account: context.account, source: .hashtag(query))
|
|
case let .location(coordinates, venue):
|
|
self.listSource = SearchStoryListContext(account: context.account, source: .mediaArea(.venue(coordinates: coordinates, venue: venue)))
|
|
case let .botPreview(id):
|
|
self.listSource = BotPreviewStoryListContext(account: context.account, engine: context.engine, peerId: id, language: nil, assumeEmpty: false)
|
|
}
|
|
}
|
|
self.defaultListSource = self.listSource
|
|
self.calendarSource = nil
|
|
|
|
var maxBotPreviewCount = 10
|
|
if let data = self.context.currentAppConfiguration.with({ $0 }).data, let value = data["bot_preview_medias_max"] as? Double {
|
|
maxBotPreviewCount = Int(value)
|
|
}
|
|
self.maxBotPreviewCount = maxBotPreviewCount
|
|
|
|
super.init()
|
|
|
|
if case .peer = self.scope {
|
|
let _ = (ApplicationSpecificNotice.getSharedMediaScrollingTooltip(accountManager: context.sharedContext.accountManager)
|
|
|> deliverOnMainQueue).start(next: { [weak self] count in
|
|
guard let strongSelf = self else {
|
|
return
|
|
}
|
|
if count < 1 {
|
|
strongSelf.itemGrid.updateScrollingAreaTooltip(tooltip: SparseItemGridScrollingArea.DisplayTooltip(animation: "anim_infotip", text: strongSelf.itemGridBinding.chatPresentationData.strings.SharedMedia_FastScrollTooltip, completed: {
|
|
guard let strongSelf = self else {
|
|
return
|
|
}
|
|
let _ = ApplicationSpecificNotice.incrementSharedMediaScrollingTooltip(accountManager: strongSelf.context.sharedContext.accountManager, count: 1).start()
|
|
}))
|
|
}
|
|
})
|
|
}
|
|
|
|
self.itemGridBinding.loadHoleImpl = { [weak self] hole, location in
|
|
guard let strongSelf = self else {
|
|
return .never()
|
|
}
|
|
return strongSelf.loadHole(anchor: hole, at: location)
|
|
}
|
|
|
|
self.itemGridBinding.onTapImpl = { [weak self] item, itemLayer, point in
|
|
guard let self else {
|
|
return
|
|
}
|
|
|
|
if self.isProfileEmbedded {
|
|
if let selectedIds = self.itemInteraction.selectedIds {
|
|
self.itemInteraction.toggleSelection(item.story.id, !selectedIds.contains(item.story.id))
|
|
return
|
|
}
|
|
} else {
|
|
if let selectedIds = self.itemInteraction.selectedIds, let itemLayer = itemLayer as? ItemLayer, let selectionLayer = itemLayer.selectionLayer {
|
|
if selectionLayer.checkLayer.frame.insetBy(dx: -4.0, dy: -4.0).contains(point) {
|
|
self.itemInteraction.toggleSelection(item.story.id, !selectedIds.contains(item.story.id))
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
if self.pendingOpenListContext != nil {
|
|
return
|
|
}
|
|
|
|
var splitIndexIntoDays = true
|
|
switch self.scope {
|
|
case .peer:
|
|
break
|
|
default:
|
|
splitIndexIntoDays = false
|
|
}
|
|
let listContext = PeerStoryListContentContextImpl(
|
|
context: self.context,
|
|
listContext: self.listSource,
|
|
initialId: item.storyId,
|
|
splitIndexIntoDays: splitIndexIntoDays
|
|
)
|
|
self.pendingOpenListContext = listContext
|
|
self.itemGrid.isUserInteractionEnabled = false
|
|
|
|
let _ = (listContext.state
|
|
|> take(1)
|
|
|> deliverOnMainQueue).start(next: { [weak self] _ in
|
|
guard let self, let navigationController = self.navigationController() else {
|
|
return
|
|
}
|
|
|
|
guard let pendingOpenListContext = self.pendingOpenListContext, pendingOpenListContext === listContext else {
|
|
return
|
|
}
|
|
self.pendingOpenListContext = nil
|
|
self.itemGrid.isUserInteractionEnabled = true
|
|
|
|
var transitionIn: StoryContainerScreen.TransitionIn?
|
|
|
|
let story = item.story
|
|
var foundItem: SparseItemGridDisplayItem?
|
|
var foundItemLayer: SparseItemGridLayer?
|
|
self.itemGrid.forEachVisibleItem { item in
|
|
guard let itemLayer = item.layer as? ItemLayer else {
|
|
return
|
|
}
|
|
foundItem = item
|
|
if let listItem = itemLayer.item, listItem.story.id == story.id {
|
|
foundItemLayer = itemLayer
|
|
}
|
|
}
|
|
if let foundItemLayer {
|
|
let itemRect = self.itemGrid.frameForItem(layer: foundItemLayer)
|
|
transitionIn = StoryContainerScreen.TransitionIn(
|
|
sourceView: self.view,
|
|
sourceRect: self.itemGrid.view.convert(itemRect, to: self.view),
|
|
sourceCornerRadius: 0.0,
|
|
sourceIsAvatar: false
|
|
)
|
|
|
|
if let blurLayer = foundItem?.blurLayer {
|
|
let transition = ComponentTransition(animation: .curve(duration: 0.25, curve: .easeInOut))
|
|
transition.setAlpha(layer: blurLayer, alpha: 0.0)
|
|
}
|
|
}
|
|
|
|
let storyContainerScreen = StoryContainerScreen(
|
|
context: self.context,
|
|
content: listContext,
|
|
transitionIn: transitionIn,
|
|
transitionOut: { [weak self] _, itemId in
|
|
guard let self else {
|
|
return nil
|
|
}
|
|
|
|
var foundItem: SparseItemGridDisplayItem?
|
|
var foundItemLayer: SparseItemGridLayer?
|
|
self.itemGrid.forEachVisibleItem { item in
|
|
guard let itemLayer = item.layer as? ItemLayer else {
|
|
return
|
|
}
|
|
foundItem = item
|
|
if let listItem = itemLayer.item, AnyHashable(listItem.story.id) == itemId {
|
|
foundItemLayer = itemLayer
|
|
}
|
|
}
|
|
if let foundItemLayer {
|
|
if let blurLayer = foundItem?.blurLayer {
|
|
let transition = ComponentTransition(animation: .curve(duration: 0.25, curve: .easeInOut))
|
|
transition.setAlpha(layer: blurLayer, alpha: 1.0)
|
|
}
|
|
|
|
let itemRect = self.itemGrid.frameForItem(layer: foundItemLayer)
|
|
return StoryContainerScreen.TransitionOut(
|
|
destinationView: self.view,
|
|
transitionView: StoryContainerScreen.TransitionView(
|
|
makeView: { [weak foundItemLayer] in
|
|
return ItemTransitionView(itemLayer: foundItemLayer)
|
|
},
|
|
updateView: { view, state, transition in
|
|
(view as? ItemTransitionView)?.update(state: state, transition: transition)
|
|
},
|
|
insertCloneTransitionView: { [weak self] view in
|
|
guard let self else {
|
|
return
|
|
}
|
|
self.addToTransitionSurface(view: view)
|
|
}
|
|
),
|
|
destinationRect: self.itemGrid.view.convert(itemRect, to: self.view),
|
|
destinationCornerRadius: 0.0,
|
|
destinationIsAvatar: false,
|
|
completed: {}
|
|
)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
)
|
|
storyContainerScreen.performReorderAction = { [weak self] in
|
|
guard let self else {
|
|
return
|
|
}
|
|
self.beginReordering()
|
|
}
|
|
|
|
self.hiddenMediaDisposable?.dispose()
|
|
self.hiddenMediaDisposable = (storyContainerScreen.focusedItem
|
|
|> deliverOnMainQueue).start(next: { [weak self] itemId in
|
|
guard let self else {
|
|
return
|
|
}
|
|
if let itemId {
|
|
let anyAmount = self.itemInteraction.hiddenStories.isEmpty
|
|
self.itemInteraction.hiddenStories = Set([itemId])
|
|
if let items = self.items, let item = items.items.first(where: { $0.id == AnyHashable(itemId.id) }) {
|
|
self.itemGrid.ensureItemVisible(index: item.index, anyAmount: anyAmount)
|
|
|
|
if !anyAmount {
|
|
var foundItemLayer: SparseItemGridLayer?
|
|
self.itemGrid.forEachVisibleItem { item in
|
|
guard let itemLayer = item.layer as? ItemLayer else {
|
|
return
|
|
}
|
|
if let listItem = itemLayer.item, listItem.story.id == itemId.id {
|
|
foundItemLayer = itemLayer
|
|
}
|
|
}
|
|
if let foundItemLayer {
|
|
self.ensureRectVisible?(self.view, self.itemGrid.frameForItem(layer: foundItemLayer))
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
self.itemInteraction.hiddenStories = Set()
|
|
}
|
|
|
|
self.updateHiddenItems()
|
|
})
|
|
|
|
navigationController.pushViewController(storyContainerScreen)
|
|
})
|
|
}
|
|
|
|
self.itemGridBinding.onTagTapImpl = { [weak self] in
|
|
guard let strongSelf = self else {
|
|
return
|
|
}
|
|
strongSelf.openCurrentDate?()
|
|
}
|
|
|
|
self.itemGridBinding.reorderIfPossibleImpl = { [weak self] item, toIndex in
|
|
guard let self else {
|
|
return
|
|
}
|
|
self.reorderIfPossible(item: item, toIndex: toIndex)
|
|
}
|
|
|
|
self.itemGridBinding.didScrollImpl = { [weak self] in
|
|
guard let strongSelf = self else {
|
|
return
|
|
}
|
|
strongSelf.paneDidScroll?()
|
|
strongSelf.cancelPreviewGestures()
|
|
|
|
if strongSelf.locationViewState.displayingMapModeOptions {
|
|
strongSelf.locationViewState.displayingMapModeOptions = false
|
|
strongSelf.parentController?.requestLayout(transition: .animated(duration: 0.4, curve: .spring))
|
|
}
|
|
}
|
|
|
|
self.itemGridBinding.coveringInsetOffsetUpdatedImpl = { [weak self] transition in
|
|
guard let self else {
|
|
return
|
|
}
|
|
self.tabBarOffsetUpdated?(transition)
|
|
}
|
|
|
|
self.itemGridBinding.scrollingOffsetUpdatedImpl = { [weak self] transition in
|
|
guard let self else {
|
|
return
|
|
}
|
|
self.gridScrollingOffsetUpdated(transition: transition)
|
|
}
|
|
|
|
var processedOnBeginFastScrolling = false
|
|
self.itemGridBinding.onBeginFastScrollingImpl = { [weak self] in
|
|
guard let strongSelf = self else {
|
|
return
|
|
}
|
|
if processedOnBeginFastScrolling {
|
|
return
|
|
}
|
|
processedOnBeginFastScrolling = true
|
|
|
|
let _ = (ApplicationSpecificNotice.getSharedMediaFastScrollingTooltip(accountManager: strongSelf.context.sharedContext.accountManager)
|
|
|> deliverOnMainQueue).start(next: { count in
|
|
guard let strongSelf = self else {
|
|
return
|
|
}
|
|
if count < 1 {
|
|
let _ = ApplicationSpecificNotice.incrementSharedMediaFastScrollingTooltip(accountManager: strongSelf.context.sharedContext.accountManager).start()
|
|
|
|
var currentNode: ASDisplayNode = strongSelf
|
|
var result: PeerInfoScreenNodeProtocol?
|
|
while true {
|
|
if let currentNode = currentNode as? PeerInfoScreenNodeProtocol {
|
|
result = currentNode
|
|
break
|
|
} else if let supernode = currentNode.supernode {
|
|
currentNode = supernode
|
|
} else {
|
|
break
|
|
}
|
|
}
|
|
if let result = result {
|
|
result.displaySharedMediaFastScrollingTooltip()
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
self.itemGridBinding.getShimmerColorsImpl = { [weak self] in
|
|
guard let strongSelf = self, let presentationData = strongSelf.currentParams?.presentationData else {
|
|
return SparseItemGrid.ShimmerColors(background: 0xffffff, foreground: 0xffffff)
|
|
}
|
|
|
|
if case .botPreview = scope {
|
|
let backgroundColor = presentationData.theme.list.plainBackgroundColor
|
|
let foregroundColor = presentationData.theme.list.itemBlocksBackgroundColor.withAlphaComponent(0.6)
|
|
|
|
return SparseItemGrid.ShimmerColors(background: backgroundColor.argb, foreground: foregroundColor.argb)
|
|
} else {
|
|
let backgroundColor = presentationData.theme.list.mediaPlaceholderColor
|
|
let foregroundColor = presentationData.theme.list.itemBlocksBackgroundColor.withAlphaComponent(0.6)
|
|
|
|
return SparseItemGrid.ShimmerColors(background: backgroundColor.argb, foreground: foregroundColor.argb)
|
|
}
|
|
}
|
|
|
|
self.itemGridBinding.updateShimmerLayersImpl = { [weak self] layer in
|
|
self?.itemGrid.updateShimmerLayers(item: layer)
|
|
}
|
|
|
|
self.itemGrid.cancelExternalContentGestures = { [weak self] in
|
|
self?.contextGestureContainerNode.cancelGesture()
|
|
}
|
|
|
|
self.itemGrid.zoomLevelUpdated = { [weak self] zoomLevel in
|
|
guard let strongSelf = self else {
|
|
return
|
|
}
|
|
let _ = strongSelf
|
|
//let _ = updateVisualMediaStoredState(engine: strongSelf.context.engine, peerId: strongSelf.peerId, messageTag: strongSelf.stateTag, state: VisualMediaStoredState(zoomLevel: Int32(zoomLevel.rawValue))).start()
|
|
}
|
|
|
|
self._itemInteraction = VisualMediaItemInteraction(
|
|
openItem: { [weak self] _ in
|
|
guard let self else {
|
|
return
|
|
}
|
|
let _ = self
|
|
},
|
|
openItemContextActions: { [weak self] item, sourceNode, sourceRect, gesture in
|
|
guard let self else {
|
|
return
|
|
}
|
|
let _ = self
|
|
},
|
|
toggleSelection: { [weak self] id, value in
|
|
guard let self, let itemInteraction = self._itemInteraction else {
|
|
return
|
|
}
|
|
|
|
if let parentController = self.parentController as? PeerInfoScreen {
|
|
parentController.toggleStorySelection(ids: [id], isSelected: value)
|
|
} else {
|
|
if var selectedIds = itemInteraction.selectedIds {
|
|
if value {
|
|
selectedIds.insert(id)
|
|
} else {
|
|
selectedIds.remove(id)
|
|
}
|
|
itemInteraction.selectedIds = selectedIds
|
|
self.selectedIdsPromise.set(selectedIds)
|
|
self.updateSelectedItems(animated: true)
|
|
}
|
|
}
|
|
}
|
|
)
|
|
if self.isSelectionModeActive {
|
|
self._itemInteraction?.selectedIds = Set()
|
|
}
|
|
self.itemGridBinding.itemInteraction = self._itemInteraction
|
|
|
|
self.contextGestureContainerNode.isGestureEnabled = self.isProfileEmbedded
|
|
self.contextGestureContainerNode.addSubnode(self.itemGrid)
|
|
self.addSubnode(self.contextGestureContainerNode)
|
|
|
|
if case .location = scope {
|
|
let mapNode = LocationMapHeaderNode(
|
|
presentationData: self.presentationData,
|
|
toggleMapModeSelection: { [weak self] in
|
|
guard let self else {
|
|
return
|
|
}
|
|
|
|
var state = self.locationViewState
|
|
state.displayingMapModeOptions = !state.displayingMapModeOptions
|
|
self.locationViewState = state
|
|
},
|
|
goToUserLocation: { [weak self] in
|
|
guard let self else {
|
|
return
|
|
}
|
|
|
|
var state = self.locationViewState
|
|
state.displayingMapModeOptions = false
|
|
state.selectedLocation = .user
|
|
switch state.trackingMode {
|
|
case .none:
|
|
state.trackingMode = .follow
|
|
case .follow:
|
|
state.trackingMode = .followWithHeading
|
|
case .followWithHeading:
|
|
state.trackingMode = .none
|
|
}
|
|
self.locationViewState = state
|
|
}
|
|
)
|
|
self.mapNode = mapNode
|
|
self.addSubnode(mapNode)
|
|
}
|
|
|
|
self.contextGestureContainerNode.shouldBegin = { [weak self] point in
|
|
guard let strongSelf = self else {
|
|
return false
|
|
}
|
|
guard let item = strongSelf.itemGrid.item(at: point) else {
|
|
return false
|
|
}
|
|
guard let layer = item.layer as? ItemLayer else {
|
|
return false
|
|
}
|
|
guard let storyItem = layer.item else {
|
|
return false
|
|
}
|
|
|
|
if let result = strongSelf.view.hitTest(point, with: nil) {
|
|
if result.asyncdisplaykit_node is SparseItemGridScrollingArea {
|
|
return false
|
|
}
|
|
}
|
|
|
|
if !strongSelf.canManageStories {
|
|
if !storyItem.story.isForwardingDisabled, case .everyone = storyItem.story.privacy?.base {
|
|
} else {
|
|
return false
|
|
}
|
|
}
|
|
|
|
strongSelf.currentGestureItem = item
|
|
|
|
return true
|
|
}
|
|
|
|
self.contextGestureContainerNode.customActivationProgress = { [weak self] progress, update in
|
|
guard let strongSelf = self, let currentGestureItem = strongSelf.currentGestureItem else {
|
|
return
|
|
}
|
|
guard let itemLayer = currentGestureItem.layer else {
|
|
return
|
|
}
|
|
|
|
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.transform = sublayerTransform
|
|
case .begin:
|
|
let sublayerTransform = CATransform3DTranslate(CATransform3DScale(CATransform3DIdentity, currentScale, currentScale, 1.0), scaleMidX, scaleMidY, 0.0)
|
|
itemLayer.transform = sublayerTransform
|
|
case .ended:
|
|
let sublayerTransform = CATransform3DTranslate(CATransform3DScale(CATransform3DIdentity, currentScale, currentScale, 1.0), scaleMidX, scaleMidY, 0.0)
|
|
let previousTransform = itemLayer.transform
|
|
itemLayer.transform = sublayerTransform
|
|
|
|
itemLayer.animate(from: NSValue(caTransform3D: previousTransform), to: NSValue(caTransform3D: sublayerTransform), keyPath: "transform", timingFunction: CAMediaTimingFunctionName.easeOut.rawValue, duration: 0.2)
|
|
}
|
|
}
|
|
|
|
self.contextGestureContainerNode.activated = { [weak self] gesture, _ in
|
|
guard let strongSelf = self, let currentGestureItem = strongSelf.currentGestureItem else {
|
|
return
|
|
}
|
|
strongSelf.currentGestureItem = nil
|
|
|
|
guard let itemLayer = currentGestureItem.layer as? ItemLayer else {
|
|
return
|
|
}
|
|
guard let story = itemLayer.item?.story else {
|
|
return
|
|
}
|
|
let rect = strongSelf.itemGrid.frameForItem(layer: itemLayer)
|
|
|
|
strongSelf.openContextMenu(item: story, itemLayer: itemLayer, rect: rect, gesture: gesture)
|
|
}
|
|
|
|
let paneKey: PeerInfoPaneKey
|
|
switch self.scope {
|
|
case let .peer(_, _, isArchived):
|
|
paneKey = isArchived ? .storyArchive : .stories
|
|
case .botPreview:
|
|
paneKey = .botPreview
|
|
default:
|
|
paneKey = .stories
|
|
}
|
|
self.statusPromise.set(.single(PeerInfoStatusData(text: "", isActivity: false, key: paneKey)))
|
|
|
|
self.presentationDataDisposable = (self.context.sharedContext.presentationData
|
|
|> deliverOnMainQueue).start(next: { [weak self] presentationData in
|
|
guard let strongSelf = self else {
|
|
return
|
|
}
|
|
|
|
strongSelf.itemGridBinding.updatePresentationData(presentationData: presentationData)
|
|
strongSelf.itemGrid.updatePresentationData(theme: presentationData.theme)
|
|
strongSelf.mapOptionsNode?.updatePresentationData(presentationData)
|
|
})
|
|
|
|
self.requestHistoryAroundVisiblePosition(synchronous: false, reloadAtTop: false)
|
|
|
|
if case let .peer(id, _, isArchived) = self.scope, id == context.account.peerId, !isArchived {
|
|
self.preloadArchiveListContext = PeerStoryListContext(account: context.account, peerId: context.account.peerId, isArchived: true)
|
|
}
|
|
|
|
if case let .location(_, venue) = scope, let mapNode = self.mapNode {
|
|
let locationCoordinate = CLLocationCoordinate2D(latitude: venue.latitude, longitude: venue.longitude)
|
|
|
|
var initialMapState = LocationViewState()
|
|
initialMapState.selectedLocation = .coordinate(locationCoordinate, true)
|
|
self.locationViewStatePromise.set(.single(initialMapState))
|
|
|
|
let userLocation: Signal<CLLocation?, NoError> = .single(nil)
|
|
|> then(
|
|
throttledUserLocation(mapNode.mapNode.userLocation)
|
|
)
|
|
|
|
var eta: Signal<(ExpectedTravelTime, ExpectedTravelTime, ExpectedTravelTime), NoError> = .single((.calculating, .calculating, .calculating))
|
|
var address: Signal<String?, NoError> = .single(nil)
|
|
|
|
let locale = localeWithStrings(self.presentationData.strings)
|
|
eta = .single((.calculating, .calculating, .calculating))
|
|
|> then(combineLatest(queue: Queue.mainQueue(), getExpectedTravelTime(coordinate: locationCoordinate, transportType: .automobile), getExpectedTravelTime(coordinate: locationCoordinate, transportType: .transit), getExpectedTravelTime(coordinate: locationCoordinate, transportType: .walking))
|
|
|> mapToSignal { drivingTime, transitTime, walkingTime -> Signal<(ExpectedTravelTime, ExpectedTravelTime, ExpectedTravelTime), NoError> in
|
|
if case .calculating = drivingTime {
|
|
return .complete()
|
|
}
|
|
if case .calculating = transitTime {
|
|
return .complete()
|
|
}
|
|
if case .calculating = walkingTime {
|
|
return .complete()
|
|
}
|
|
|
|
return .single((drivingTime, transitTime, walkingTime))
|
|
})
|
|
|
|
/*if let venue = location.venue, let venueAddress = venue.address, !venueAddress.isEmpty {
|
|
address = .single(venueAddress)
|
|
} else*/ do {
|
|
address = .single(nil)
|
|
|> then(
|
|
reverseGeocodeLocation(latitude: locationCoordinate.latitude, longitude: locationCoordinate.longitude, locale: locale)
|
|
|> map { placemark -> String? in
|
|
return placemark?.compactDisplayAddress ?? ""
|
|
}
|
|
)
|
|
}
|
|
|
|
let previousState = Atomic<LocationViewState?>(value: nil)
|
|
let previousAnnotations = Atomic<[LocationPinAnnotation]>(value: [])
|
|
|
|
self.mapDisposable = (combineLatest(
|
|
context.sharedContext.presentationData,
|
|
self.locationViewStatePromise.get(),
|
|
mapNode.mapNode.userLocation,
|
|
userLocation,
|
|
address,
|
|
eta
|
|
)
|
|
|> deliverOnMainQueue).start(next: { [weak self] presentationData, state, userLocation, distance, address, eta in
|
|
guard let self, let mapNode = self.mapNode else {
|
|
return
|
|
}
|
|
|
|
let previousState = previousState.swap(state)
|
|
|
|
var annotations: [LocationPinAnnotation] = []
|
|
|
|
let subjectLocation = CLLocation(latitude: locationCoordinate.latitude, longitude: locationCoordinate.longitude)
|
|
let distance = userLocation.flatMap { subjectLocation.distance(from: $0) }
|
|
|
|
let locationMap = TelegramMediaMap(latitude: locationCoordinate.latitude, longitude: locationCoordinate.longitude, heading: nil, accuracyRadius: nil, venue: nil, address: venue.address, liveBroadcastingTimeout: nil, liveProximityNotificationRadius: nil)
|
|
|
|
let mapInfoData = MapInfoData(
|
|
location: locationMap,
|
|
address: address,
|
|
distance: distance,
|
|
drivingTime: eta.0,
|
|
transitTime: eta.1,
|
|
walkingTime: eta.2,
|
|
hasEta: false
|
|
)
|
|
|
|
annotations.append(LocationPinAnnotation(context: context, theme: presentationData.theme, location: locationMap, queryId: nil, resultId: nil, forcedSelection: true))
|
|
|
|
mapNode.updateState(
|
|
mapMode: state.mapMode,
|
|
trackingMode: state.trackingMode,
|
|
displayingMapModeOptions: state.displayingMapModeOptions,
|
|
displayingPlacesButton: false,
|
|
proximityNotification: nil,
|
|
animated: false
|
|
)
|
|
|
|
mapNode.mapNode.trackingMode = state.trackingMode
|
|
|
|
let previousAnnotations = previousAnnotations.swap(annotations)
|
|
if annotations != previousAnnotations {
|
|
mapNode.mapNode.annotations = annotations
|
|
}
|
|
|
|
switch state.selectedLocation {
|
|
case .initial:
|
|
if previousState?.selectedLocation != .initial {
|
|
mapNode.mapNode.setMapCenter(coordinate: locationCoordinate, span: LocationMapNode.viewMapSpan, animated: previousState != nil)
|
|
}
|
|
case let .coordinate(coordinate, defaultSpan):
|
|
if let previousState = previousState, case let .coordinate(previousCoordinate, _) = previousState.selectedLocation, locationCoordinatesAreEqual(previousCoordinate, coordinate) {
|
|
} else {
|
|
mapNode.mapNode.setMapCenter(
|
|
coordinate: coordinate,
|
|
span: defaultSpan ? LocationMapNode.defaultMapSpan : LocationMapNode.viewMapSpan,
|
|
animated: true
|
|
)
|
|
}
|
|
case .user:
|
|
if previousState?.selectedLocation != .user, let userLocation = userLocation {
|
|
mapNode.mapNode.setMapCenter(
|
|
coordinate: userLocation.coordinate,
|
|
isUserLocation: true,
|
|
animated: true
|
|
)
|
|
}
|
|
case .custom:
|
|
break
|
|
}
|
|
|
|
if let previousState, previousState.displayingMapModeOptions != state.displayingMapModeOptions {
|
|
self.parentController?.requestLayout(transition: .animated(duration: 0.4, curve: .spring))
|
|
} else {
|
|
if self.mapInfoData != mapInfoData {
|
|
self.mapInfoData = mapInfoData
|
|
self.update(transition: .immediate)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
deinit {
|
|
self.listDisposable?.dispose()
|
|
self.hiddenMediaDisposable?.dispose()
|
|
self.animationTimer?.invalidate()
|
|
self.presentationDataDisposable?.dispose()
|
|
self.updateDisposable.dispose()
|
|
self.mapDisposable?.dispose()
|
|
}
|
|
|
|
public func loadHole(anchor: SparseItemGrid.HoleAnchor, at location: SparseItemGrid.HoleLocation) -> Signal<Never, NoError> {
|
|
let listSource = self.listSource
|
|
return Signal { subscriber in
|
|
listSource.loadMore(completion: {
|
|
Queue.mainQueue().async {
|
|
subscriber.putCompletion()
|
|
}
|
|
})
|
|
|
|
|
|
return EmptyDisposable
|
|
}
|
|
|> runOn(.mainQueue())
|
|
}
|
|
|
|
private func openContextMenu(item: EngineStoryItem, itemLayer: ItemLayer, rect: CGRect, gesture: ContextGesture?) {
|
|
guard let parentController = self.parentController else {
|
|
return
|
|
}
|
|
|
|
let canManage = self.canManageStories
|
|
|
|
var items: [ContextMenuItem] = []
|
|
|
|
if canManage, case let .peer(peerId, _, isArchived) = self.scope {
|
|
items.append(.action(ContextMenuActionItem(text: !isArchived ? self.presentationData.strings.StoryList_ItemAction_Archive : self.presentationData.strings.StoryList_ItemAction_Unarchive, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: isArchived ? "Chat/Context Menu/Archive" : "Chat/Context Menu/Unarchive"), color: theme.contextMenu.primaryColor) }, action: { [weak self] _, f in
|
|
guard let self else {
|
|
f(.default)
|
|
return
|
|
}
|
|
|
|
if isArchived {
|
|
f(.default)
|
|
} else {
|
|
f(.dismissWithoutContent)
|
|
}
|
|
|
|
let _ = self.context.engine.messages.updateStoriesArePinned(peerId: peerId, ids: [item.id: item], isPinned: isArchived ? true : false).startStandalone()
|
|
self.parentController?.present(UndoOverlayController(presentationData: self.presentationData, content: .actionSucceeded(title: nil, text: isArchived ? self.presentationData.strings.StoryList_ToastUnarchived_Text(1) : self.presentationData.strings.StoryList_ToastArchived_Text(1), cancel: nil, destructive: false), elevatedLayout: false, animateInAsReplacement: false, action: { _ in return false }), in: .current)
|
|
})))
|
|
|
|
if !isArchived {
|
|
let isPinned = self.pinnedIds.contains(item.id)
|
|
items.append(.action(ContextMenuActionItem(text: isPinned ? self.presentationData.strings.StoryList_ItemAction_Unpin : self.presentationData.strings.StoryList_ItemAction_Pin, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: isPinned ? "Chat/Context Menu/Unpin" : "Chat/Context Menu/Pin"), color: theme.contextMenu.primaryColor) }, action: { [weak self, weak itemLayer] _, f in
|
|
itemLayer?.isHidden = false
|
|
guard let self else {
|
|
f(.default)
|
|
return
|
|
}
|
|
|
|
if !isPinned && self.pinnedIds.count >= 3 {
|
|
f(.default)
|
|
|
|
let presentationData = self.presentationData
|
|
self.parentController?.present(UndoOverlayController(presentationData: presentationData, content: .info(title: nil, text: presentationData.strings.StoryList_ToastPinLimit_Text(Int32(3)), timeout: nil, customUndoText: nil), elevatedLayout: false, animateInAsReplacement: false, action: { _ in return false }), in: .current)
|
|
|
|
return
|
|
}
|
|
|
|
f(.dismissWithoutContent)
|
|
|
|
var updatedPinnedIds = self.pinnedIds
|
|
if isPinned {
|
|
updatedPinnedIds.remove(item.id)
|
|
} else {
|
|
updatedPinnedIds.insert(item.id)
|
|
}
|
|
let _ = self.context.engine.messages.updatePinnedToTopStories(peerId: peerId, ids: Array(updatedPinnedIds)).startStandalone()
|
|
|
|
let presentationData = self.presentationData
|
|
|
|
let toastTitle: String?
|
|
let toastText: String
|
|
if isPinned {
|
|
toastTitle = nil
|
|
toastText = presentationData.strings.StoryList_ToastUnpinned_Text(1)
|
|
} else {
|
|
toastTitle = presentationData.strings.StoryList_ToastPinned_Title(1)
|
|
toastText = presentationData.strings.StoryList_ToastPinned_Text(1)
|
|
}
|
|
self.parentController?.present(UndoOverlayController(presentationData: presentationData, content: .universal(animation: isPinned ? "anim_toastunpin" : "anim_toastpin", scale: 0.06, colors: [:], title: toastTitle, text: toastText, customUndoText: nil, timeout: 5), elevatedLayout: false, animateInAsReplacement: false, action: { _ in return false }), in: .current)
|
|
})))
|
|
if isPinned && self.canReorder() {
|
|
items.append(.action(ContextMenuActionItem(text: self.presentationData.strings.BotPreviews_MenuReorder, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/ReorderItems"), color: theme.contextMenu.primaryColor) }, action: { [weak self] c, _ in
|
|
c?.dismiss(completion: {
|
|
guard let self else {
|
|
return
|
|
}
|
|
|
|
self.beginReordering()
|
|
})
|
|
})))
|
|
}
|
|
}
|
|
|
|
items.append(.action(ContextMenuActionItem(text: self.presentationData.strings.StoryList_ItemAction_Edit, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Edit"), color: theme.contextMenu.primaryColor) }, action: { [weak self] c, _ in
|
|
c?.dismiss(completion: {
|
|
guard let self else {
|
|
return
|
|
}
|
|
let _ = (self.context.engine.data.get(
|
|
TelegramEngine.EngineData.Item.Peer.Peer(id: peerId)
|
|
)
|
|
|> deliverOnMainQueue).startStandalone(next: { [weak self] peer in
|
|
guard let self, let peer else {
|
|
return
|
|
}
|
|
|
|
var foundItemLayer: SparseItemGridLayer?
|
|
var sourceImage: UIImage?
|
|
self.itemGrid.forEachVisibleItem { gridItem in
|
|
guard let itemLayer = gridItem.layer as? ItemLayer else {
|
|
return
|
|
}
|
|
if let listItem = itemLayer.item, listItem.story.id == item.id {
|
|
foundItemLayer = itemLayer
|
|
if let contents = itemLayer.contents, CFGetTypeID(contents as CFTypeRef) == CGImage.typeID {
|
|
sourceImage = UIImage(cgImage: contents as! CGImage)
|
|
}
|
|
}
|
|
}
|
|
|
|
guard let controller = MediaEditorScreen.makeEditStoryController(
|
|
context: self.context,
|
|
peer: peer,
|
|
storyItem: item,
|
|
videoPlaybackPosition: nil,
|
|
cover: false,
|
|
repost: false,
|
|
transitionIn: .gallery(MediaEditorScreen.TransitionIn.GalleryTransitionIn(sourceView: self.itemGrid.view, sourceRect: foundItemLayer?.frame ?? .zero, sourceImage: sourceImage)),
|
|
transitionOut: MediaEditorScreen.TransitionOut(destinationView: self.itemGrid.view, destinationRect: foundItemLayer?.frame ?? .zero, destinationCornerRadius: 0.0),
|
|
update: { [weak self] disposable in
|
|
guard let self else {
|
|
return
|
|
}
|
|
self.updateDisposable.set(disposable)
|
|
}
|
|
) else {
|
|
return
|
|
}
|
|
self.parentController?.push(controller)
|
|
})
|
|
})
|
|
})))
|
|
}
|
|
|
|
if canManage, case .botPreview = self.scope, self.canReorder() {
|
|
items.append(.action(ContextMenuActionItem(text: self.presentationData.strings.BotPreviews_MenuReorder, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/ReorderItems"), color: theme.contextMenu.primaryColor) }, action: { [weak self] c, _ in
|
|
c?.dismiss(completion: {
|
|
guard let self else {
|
|
return
|
|
}
|
|
|
|
self.beginReordering()
|
|
})
|
|
})))
|
|
}
|
|
|
|
if !item.isForwardingDisabled, case .everyone = item.privacy?.base {
|
|
items.append(.action(ContextMenuActionItem(text: self.presentationData.strings.StoryList_ItemAction_Forward, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Forward"), color: theme.contextMenu.primaryColor) }, action: { [weak self] c, _ in
|
|
c?.dismiss(completion: {
|
|
guard let self, case let .peer(peerId, _, _) = self.scope else {
|
|
return
|
|
}
|
|
|
|
let _ = (self.context.engine.data.get(
|
|
TelegramEngine.EngineData.Item.Peer.Peer(id: peerId)
|
|
)
|
|
|> deliverOnMainQueue).startStandalone(next: { [weak self] peer in
|
|
guard let self else {
|
|
return
|
|
}
|
|
guard let peer, let peerReference = PeerReference(peer._asPeer()) else {
|
|
return
|
|
}
|
|
|
|
let shareController = ShareController(
|
|
context: self.context,
|
|
subject: .media(.story(peer: peerReference, id: item.id, media: TelegramMediaStory(storyId: StoryId(peerId: peer.id, id: item.id), isMention: false))),
|
|
presetText: nil,
|
|
preferredAction: .default,
|
|
showInChat: nil,
|
|
fromForeignApp: false,
|
|
segmentedValues: nil,
|
|
externalShare: false,
|
|
immediateExternalShare: false,
|
|
switchableAccounts: [],
|
|
immediatePeerId: nil,
|
|
updatedPresentationData: nil,
|
|
forceTheme: nil,
|
|
forcedActionTitle: nil,
|
|
shareAsLink: false,
|
|
collectibleItemInfo: nil
|
|
)
|
|
self.parentController?.present(shareController, in: .window(.root))
|
|
})
|
|
})
|
|
})))
|
|
}
|
|
|
|
if canManage {
|
|
items.append(.action(ContextMenuActionItem(text: self.presentationData.strings.StoryList_ItemAction_Delete, textColor: .destructive, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Delete"), color: theme.contextMenu.destructiveColor) }, action: { [weak self] c, _ in
|
|
c?.dismiss(completion: {
|
|
guard let self else {
|
|
return
|
|
}
|
|
|
|
self.presentDeleteConfirmation(ids: Set([item.id]))
|
|
})
|
|
})))
|
|
}
|
|
|
|
if self.canManageStories {
|
|
if !items.isEmpty {
|
|
items.append(.separator)
|
|
}
|
|
items.append(.action(ContextMenuActionItem(text: self.presentationData.strings.Conversation_ContextMenuSelect, icon: { theme in
|
|
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Select"), color: theme.actionSheet.primaryTextColor)
|
|
}, action: { [weak self] c, f in
|
|
guard let self, let parentController = self.parentController as? PeerInfoScreen else {
|
|
f(.default)
|
|
return
|
|
}
|
|
|
|
self.contextControllerToDismissOnSelection = c
|
|
parentController.toggleStorySelection(ids: [item.id], isSelected: true)
|
|
|
|
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.5, execute: { [weak self] in
|
|
guard let self, let contextControllerToDismissOnSelection = self.contextControllerToDismissOnSelection else {
|
|
return
|
|
}
|
|
if let contextControllerToDismissOnSelection = contextControllerToDismissOnSelection as? ContextController {
|
|
contextControllerToDismissOnSelection.dismissWithCustomTransition(transition: .animated(duration: 0.4, curve: .spring), completion: nil)
|
|
}
|
|
})
|
|
})))
|
|
}
|
|
|
|
if items.isEmpty {
|
|
return
|
|
}
|
|
|
|
let tempSourceNode = TempExtractedItemNode(
|
|
item: item,
|
|
itemLayer: itemLayer
|
|
)
|
|
tempSourceNode.frame = rect
|
|
tempSourceNode.update(size: rect.size)
|
|
|
|
let scaleSide = itemLayer.bounds.width
|
|
let minScale: CGFloat = max(0.7, (scaleSide - 15.0) / scaleSide)
|
|
let currentScale = minScale
|
|
|
|
ContainedViewLayoutTransition.immediate.updateSublayerTransformScale(node: tempSourceNode.contextSourceNode.contentNode, scale: currentScale)
|
|
ContainedViewLayoutTransition.immediate.updateTransformScale(layer: itemLayer, scale: 1.0)
|
|
|
|
self.tempContextContentItemNode = tempSourceNode
|
|
self.addSubnode(tempSourceNode)
|
|
|
|
let contextController = ContextController(presentationData: self.presentationData, source: .extracted(ExtractedContentSourceImpl(controller: parentController, sourceNode: tempSourceNode.contextSourceNode, keepInPlace: false, blurBackground: true)), items: .single(ContextController.Items(content: .list(items))), gesture: gesture)
|
|
parentController.presentInGlobalOverlay(contextController)
|
|
}
|
|
|
|
public func updateZoomLevel(level: ZoomLevel) {
|
|
self.itemGrid.setZoomLevel(level: level.value)
|
|
|
|
//let _ = updateVisualMediaStoredState(engine: self.context.engine, peerId: self.peerId, messageTag: self.stateTag, state: VisualMediaStoredState(zoomLevel: level.rawValue)).start()
|
|
}
|
|
|
|
public func setIsSelectionModeActive(_ value: Bool) {
|
|
if self.isSelectionModeActive != value {
|
|
self.isSelectionModeActive = value
|
|
|
|
if value {
|
|
if self._itemInteraction?.selectedIds == nil {
|
|
self._itemInteraction?.selectedIds = Set()
|
|
}
|
|
} else {
|
|
self._itemInteraction?.selectedIds = nil
|
|
}
|
|
|
|
self.selectedIdsPromise.set(self._itemInteraction?.selectedIds ?? Set())
|
|
self.updateSelectedItems(animated: true)
|
|
}
|
|
}
|
|
|
|
public func ensureMessageIsVisible(id: MessageId) {
|
|
}
|
|
|
|
private func requestHistoryAroundVisiblePosition(synchronous: Bool, reloadAtTop: Bool) {
|
|
if self.isRequestingView {
|
|
return
|
|
}
|
|
self.isRequestingView = true
|
|
var firstTime = true
|
|
let queue = Queue()
|
|
|
|
let state = self.listSource.state
|
|
|
|
self.listDisposable?.dispose()
|
|
self.listDisposable = nil
|
|
|
|
if reloadAtTop {
|
|
self.didUpdateItemsOnce = false
|
|
}
|
|
|
|
self.listDisposable = (state
|
|
|> deliverOn(queue)).startStrict(next: { [weak self] state in
|
|
guard let self else {
|
|
return
|
|
}
|
|
|
|
let title: String
|
|
if state.totalCount == 0 {
|
|
if case .botPreview = self.scope {
|
|
if state.isLoading {
|
|
title = self.presentationData.strings.BotPreviews_SubtitleLoading
|
|
} else {
|
|
title = self.presentationData.strings.BotPreviews_SubtitleEmpty
|
|
}
|
|
} else {
|
|
title = ""
|
|
}
|
|
} else if case let .peer(_, isSaved, isArchived) = self.scope {
|
|
if isSaved {
|
|
title = self.presentationData.strings.StoryList_SubtitleSaved(Int32(state.totalCount))
|
|
} else if isArchived {
|
|
title = self.presentationData.strings.StoryList_SubtitleArchived(Int32(state.totalCount))
|
|
} else {
|
|
title = self.presentationData.strings.StoryList_SubtitleCount(Int32(state.totalCount))
|
|
}
|
|
} else if case .botPreview = self.scope {
|
|
title = self.presentationData.strings.BotPreviews_SubtitleCount(Int32(state.totalCount))
|
|
} else if case .search = self.scope {
|
|
title = self.presentationData.strings.StoryList_SubtitleCount(Int32(state.totalCount))
|
|
} else {
|
|
title = ""
|
|
}
|
|
let paneKey: PeerInfoPaneKey
|
|
switch self.scope {
|
|
case let .peer(_, _, isArchived):
|
|
paneKey = isArchived ? .storyArchive : .stories
|
|
case .botPreview:
|
|
paneKey = .botPreview
|
|
default:
|
|
paneKey = .stories
|
|
}
|
|
self.statusPromise.set(.single(PeerInfoStatusData(text: title, isActivity: false, key: paneKey)))
|
|
|
|
Queue.mainQueue().async { [weak self] in
|
|
guard let self else {
|
|
return
|
|
}
|
|
|
|
var botPreviewLanguages = self.currentBotPreviewLanguages
|
|
for language in state.availableLanguages {
|
|
if !botPreviewLanguages.contains(where: { $0.id == language.id }) && !self.removedBotPreviewLanguages.contains(language.id) {
|
|
botPreviewLanguages.append(language)
|
|
}
|
|
}
|
|
botPreviewLanguages.sort(by: { $0.name < $1.name })
|
|
|
|
var hadLocalItems = false
|
|
if let currentListState = self.currentListState {
|
|
for item in currentListState.items {
|
|
if item.storyItem.isPending {
|
|
hadLocalItems = true
|
|
}
|
|
}
|
|
}
|
|
|
|
self.currentListState = state
|
|
|
|
var hasLocalItems = false
|
|
if let currentListState = self.currentListState {
|
|
for item in currentListState.items {
|
|
if item.storyItem.isPending {
|
|
hasLocalItems = true
|
|
}
|
|
}
|
|
}
|
|
|
|
var synchronous = synchronous
|
|
if hasLocalItems != hadLocalItems {
|
|
synchronous = true
|
|
}
|
|
|
|
self.updateItemsFromState(state: state, firstTime: firstTime, reloadAtTop: reloadAtTop, synchronous: synchronous, animated: false)
|
|
|
|
if self.currentBotPreviewLanguages != botPreviewLanguages || reloadAtTop {
|
|
self.currentBotPreviewLanguages = botPreviewLanguages
|
|
if let (size, topInset, sideInset, bottomInset, deviceMetrics, visibleHeight, isScrollingLockedAtTop, expandProgress, navigationHeight, presentationData) = self.currentParams {
|
|
self.update(size: size, topInset: topInset, sideInset: sideInset, bottomInset: bottomInset, deviceMetrics: deviceMetrics, visibleHeight: visibleHeight, isScrollingLockedAtTop: isScrollingLockedAtTop, expandProgress: expandProgress, navigationHeight: navigationHeight, presentationData: presentationData, synchronous: synchronous, transition: .immediate)
|
|
}
|
|
}
|
|
|
|
firstTime = false
|
|
self.isRequestingView = false
|
|
}
|
|
})
|
|
}
|
|
|
|
private func updateItemsFromState(state: StoryListContext.State, firstTime: Bool, reloadAtTop: Bool, synchronous: Bool, animated: Bool) {
|
|
let timezoneOffset = Int32(TimeZone.current.secondsFromGMT())
|
|
|
|
var mappedItems: [SparseItemGrid.Item] = []
|
|
var mappedHoles: [SparseItemGrid.HoleAnchor] = []
|
|
var totalCount: Int = 0
|
|
|
|
var stateItems = state.items
|
|
if let reorderedIds = self.reorderedIds {
|
|
var fixedStateItems: [StoryListContext.State.Item] = []
|
|
|
|
var seenIds = Set<StoryId>()
|
|
for id in reorderedIds {
|
|
if let index = stateItems.firstIndex(where: { $0.id == id }) {
|
|
seenIds.insert(id)
|
|
fixedStateItems.append(stateItems[index])
|
|
}
|
|
}
|
|
|
|
for item in stateItems {
|
|
if !seenIds.contains(item.id) {
|
|
fixedStateItems.append(item)
|
|
}
|
|
}
|
|
stateItems = fixedStateItems
|
|
self.reorderedIds = fixedStateItems.map(\.id)
|
|
}
|
|
|
|
for item in stateItems {
|
|
var peerReference: PeerReference?
|
|
if let value = state.peerReference {
|
|
peerReference = value
|
|
} else if let peer = item.peer {
|
|
peerReference = PeerReference(peer._asPeer())
|
|
}
|
|
guard let peerReference else {
|
|
continue
|
|
}
|
|
|
|
var isReorderable = false
|
|
switch self.scope {
|
|
case .botPreview:
|
|
isReorderable = !item.storyItem.isPending
|
|
case let .peer(id, _, _):
|
|
if id == self.context.account.peerId {
|
|
isReorderable = state.pinnedIds.contains(item.storyItem.id)
|
|
}
|
|
default:
|
|
break
|
|
}
|
|
|
|
mappedItems.append(VisualMediaItem(
|
|
index: mappedItems.count,
|
|
peer: peerReference,
|
|
storyId: item.id,
|
|
story: item.storyItem,
|
|
authorPeer: item.peer,
|
|
isPinned: state.pinnedIds.contains(item.storyItem.id),
|
|
localMonthTimestamp: Month(localTimestamp: item.storyItem.timestamp + timezoneOffset).packedValue,
|
|
isReorderable: isReorderable
|
|
))
|
|
}
|
|
if mappedItems.count < state.totalCount, let lastItem = state.items.last, let _ = state.loadMoreToken {
|
|
mappedHoles.append(VisualMediaHoleAnchor(index: mappedItems.count, storyId: StoryId(peerId: context.account.peerId, id: Int32.max), localMonthTimestamp: Month(localTimestamp: lastItem.storyItem.timestamp + timezoneOffset).packedValue))
|
|
}
|
|
totalCount = state.totalCount
|
|
totalCount = max(mappedItems.count, totalCount)
|
|
|
|
if totalCount == 0 && state.loadMoreToken != nil && !state.isCached {
|
|
totalCount = 100
|
|
}
|
|
|
|
var headerText: String?
|
|
if case let .peer(peerId, _, isArchived) = self.scope {
|
|
if isArchived && !mappedItems.isEmpty && peerId == self.context.account.peerId {
|
|
headerText = self.presentationData.strings.StoryList_ArchiveDescription
|
|
}
|
|
}
|
|
|
|
let items = SparseItemGrid.Items(
|
|
items: mappedItems,
|
|
holeAnchors: mappedHoles,
|
|
count: totalCount,
|
|
itemBinding: self.itemGridBinding,
|
|
headerText: headerText,
|
|
snapTopInset: false
|
|
)
|
|
|
|
self.itemCount = state.totalCount
|
|
|
|
let currentSynchronous = synchronous && firstTime
|
|
let currentReloadAtTop = reloadAtTop && firstTime
|
|
self.updateHistory(items: items, pinnedIds: Set(state.pinnedIds), synchronous: currentSynchronous, reloadAtTop: currentReloadAtTop, animated: animated)
|
|
}
|
|
|
|
private func updateHistory(items: SparseItemGrid.Items, pinnedIds: Set<Int32>, synchronous: Bool, reloadAtTop: Bool, animated: Bool) {
|
|
var transition: ContainedViewLayoutTransition = .immediate
|
|
if case .location = self.scope, let previousItems = self.items, previousItems.items.count == 0, previousItems.count != 0, items.items.count == 0, items.count == 0 {
|
|
transition = .animated(duration: 0.3, curve: .spring)
|
|
}
|
|
|
|
self.items = items
|
|
self.pinnedIds = pinnedIds
|
|
|
|
if let (size, topInset, sideInset, bottomInset, deviceMetrics, visibleHeight, isScrollingLockedAtTop, expandProgress, navigationHeight, presentationData) = self.currentParams {
|
|
var gridSnapshot: UIView?
|
|
if case .botPreview = scope {
|
|
} else if reloadAtTop {
|
|
gridSnapshot = self.itemGrid.view.snapshotView(afterScreenUpdates: false)
|
|
}
|
|
self.update(size: size, topInset: topInset, sideInset: sideInset, bottomInset: bottomInset, deviceMetrics: deviceMetrics, visibleHeight: visibleHeight, isScrollingLockedAtTop: isScrollingLockedAtTop, expandProgress: expandProgress, navigationHeight: navigationHeight, presentationData: presentationData, synchronous: false, transition: transition, animateGridItems: animated)
|
|
self.updateSelectedItems(animated: false)
|
|
if let gridSnapshot = gridSnapshot {
|
|
self.view.addSubview(gridSnapshot)
|
|
gridSnapshot.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak gridSnapshot] _ in
|
|
gridSnapshot?.removeFromSuperview()
|
|
})
|
|
}
|
|
}
|
|
|
|
self.isEmptyUpdated(self.isEmpty)
|
|
|
|
if !self.didSetReady {
|
|
self.didSetReady = true
|
|
self.ready.set(.single(true))
|
|
}
|
|
}
|
|
|
|
private func reorderIfPossible(item: SparseItemGrid.Item, toIndex: Int) {
|
|
if let items = self.items, let item = item as? VisualMediaItem {
|
|
var toIndex = toIndex
|
|
if case .botPreview = self.scope {
|
|
} else if case let .peer(id, _, _) = self.scope {
|
|
if id == self.context.account.peerId {
|
|
let maxPinnedIndex = items.items.lastIndex(where: { ($0 as? VisualMediaItem)?.isPinned == true })
|
|
if let maxPinnedIndex {
|
|
toIndex = min(toIndex, maxPinnedIndex)
|
|
} else {
|
|
return
|
|
}
|
|
}
|
|
} else {
|
|
return
|
|
}
|
|
|
|
guard let toItem = items.items.first(where: { $0.index == toIndex }) as? VisualMediaItem else {
|
|
return
|
|
}
|
|
if item.story.isPending || toItem.story.isPending {
|
|
return
|
|
}
|
|
if !item.isReorderable {
|
|
return
|
|
}
|
|
|
|
var ids = items.items.compactMap { item -> StoryId? in
|
|
return (item as? VisualMediaItem)?.storyId
|
|
}
|
|
|
|
if let fromIndex = ids.firstIndex(of: item.storyId) {
|
|
if fromIndex < toIndex {
|
|
ids.insert(item.storyId, at: toIndex + 1)
|
|
ids.remove(at: fromIndex)
|
|
} else if fromIndex > toIndex {
|
|
ids.remove(at: fromIndex)
|
|
ids.insert(item.storyId, at: toIndex)
|
|
}
|
|
}
|
|
if self.reorderedIds != ids {
|
|
self.reorderedIds = ids
|
|
|
|
HapticFeedback().tap()
|
|
|
|
if let currentListState = self.currentListState {
|
|
self.updateItemsFromState(state: currentListState, firstTime: false, reloadAtTop: false, synchronous: false, animated: true)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
public func scrollToTop() -> Bool {
|
|
return self.itemGrid.scrollToTop()
|
|
}
|
|
|
|
public func hitTestResultForScrolling() -> UIView? {
|
|
return self.itemGrid.hitTestResultForScrolling()
|
|
}
|
|
|
|
public func brieflyDisableTouchActions() {
|
|
self.itemGrid.brieflyDisableTouchActions()
|
|
}
|
|
|
|
public func findLoadedMessage(id: MessageId) -> Message? {
|
|
return nil
|
|
}
|
|
|
|
public func updateHiddenMedia() {
|
|
}
|
|
|
|
public func transferVelocity(_ velocity: CGFloat) {
|
|
self.itemGrid.transferVelocity(velocity)
|
|
}
|
|
|
|
public func cancelPreviewGestures() {
|
|
}
|
|
|
|
public func transitionNodeForGallery(messageId: MessageId, media: Media) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? {
|
|
return nil
|
|
}
|
|
|
|
public func extractPendingStoryTransitionView() -> UIView? {
|
|
guard let items = self.items else {
|
|
return nil
|
|
}
|
|
guard let visualItem = items.items.last(where: { item in
|
|
guard let item = item as? VisualMediaItem else {
|
|
return false
|
|
}
|
|
if item.story.isPending {
|
|
return true
|
|
}
|
|
return false
|
|
}) else {
|
|
return nil
|
|
}
|
|
guard let item = self.itemGrid.item(at: visualItem.index) else {
|
|
return nil
|
|
}
|
|
|
|
guard let itemLayer = item.layer as? ItemLayer else {
|
|
return nil
|
|
}
|
|
guard let story = itemLayer.item?.story else {
|
|
return nil
|
|
}
|
|
let rect = self.itemGrid.frameForItem(layer: itemLayer)
|
|
|
|
let tempSourceNode = TempExtractedItemNode(
|
|
item: story,
|
|
itemLayer: itemLayer
|
|
)
|
|
tempSourceNode.frame = rect
|
|
tempSourceNode.update(size: rect.size)
|
|
|
|
self.tempContextContentItemNode = tempSourceNode
|
|
self.view.addSubview(tempSourceNode.view)
|
|
|
|
return tempSourceNode.view
|
|
}
|
|
|
|
public func addToTransitionSurface(view: UIView) {
|
|
self.itemGrid.addToTransitionSurface(view: view)
|
|
}
|
|
|
|
private var gridSelectionGesture: MediaPickerGridSelectionGesture<Int32>?
|
|
|
|
override public func didLoad() {
|
|
super.didLoad()
|
|
|
|
/*let selectionRecognizer = MediaListSelectionRecognizer(target: self, action: #selector(self.selectionPanGesture(_:)))
|
|
selectionRecognizer.shouldBegin = {
|
|
return true
|
|
}
|
|
self.view.addGestureRecognizer(selectionRecognizer)*/
|
|
}
|
|
|
|
private var selectionPanState: (selecting: Bool, initialMessageId: EngineMessage.Id, toggledMessageIds: [[EngineMessage.Id]])?
|
|
private var selectionScrollActivationTimer: SwiftSignalKit.Timer?
|
|
private var selectionScrollDisplayLink: ConstantDisplayLinkAnimator?
|
|
private var selectionScrollDelta: CGFloat?
|
|
private var selectionLastLocation: CGPoint?
|
|
|
|
private func storyAtPoint(_ location: CGPoint) -> StoryViewList.Item? {
|
|
return nil
|
|
}
|
|
|
|
@objc private func selectionPanGesture(_ recognizer: UIGestureRecognizer) -> Void {
|
|
//TODO:selection
|
|
/*let location = recognizer.location(in: self.view)
|
|
switch recognizer.state {
|
|
case .began:
|
|
if let message = self.messageAtPoint(location) {
|
|
let selecting = !(self.chatControllerInteraction.selectionState?.selectedIds.contains(message.id) ?? false)
|
|
self.selectionPanState = (selecting, message.id, [])
|
|
self.chatControllerInteraction.toggleMessagesSelection([message.id], selecting)
|
|
}
|
|
case .changed:
|
|
self.handlePanSelection(location: location)
|
|
self.selectionLastLocation = location
|
|
case .ended, .failed, .cancelled:
|
|
self.selectionPanState = nil
|
|
self.selectionScrollDisplayLink = nil
|
|
self.selectionScrollActivationTimer?.invalidate()
|
|
self.selectionScrollActivationTimer = nil
|
|
self.selectionScrollDelta = nil
|
|
self.selectionLastLocation = nil
|
|
self.selectionScrollSkipUpdate = false
|
|
case .possible:
|
|
break
|
|
@unknown default:
|
|
fatalError()
|
|
}*/
|
|
}
|
|
|
|
private func handlePanSelection(location: CGPoint) {
|
|
}
|
|
|
|
private var selectionScrollSkipUpdate = false
|
|
private func setupSelectionScrolling() {
|
|
self.selectionScrollDisplayLink = ConstantDisplayLinkAnimator(update: { [weak self] in
|
|
self?.selectionScrollActivationTimer = nil
|
|
if let strongSelf = self, let delta = strongSelf.selectionScrollDelta {
|
|
let distance: CGFloat = 15.0 * min(1.0, 0.15 + abs(delta * delta))
|
|
let direction: ListViewScrollDirection = delta > 0.0 ? .up : .down
|
|
let _ = strongSelf.itemGrid.scrollWithDelta(direction == .up ? -distance : distance)
|
|
|
|
if let location = strongSelf.selectionLastLocation {
|
|
if !strongSelf.selectionScrollSkipUpdate {
|
|
strongSelf.handlePanSelection(location: location)
|
|
}
|
|
strongSelf.selectionScrollSkipUpdate = !strongSelf.selectionScrollSkipUpdate
|
|
}
|
|
}
|
|
})
|
|
self.selectionScrollDisplayLink?.isPaused = false
|
|
}
|
|
|
|
override public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
|
/*let location = gestureRecognizer.location(in: gestureRecognizer.view)
|
|
if location.x < 44.0 {
|
|
return false
|
|
}*/
|
|
return true
|
|
}
|
|
|
|
public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
|
|
if gestureRecognizer.state != .failed, let otherGestureRecognizer = otherGestureRecognizer as? UIPanGestureRecognizer {
|
|
let _ = otherGestureRecognizer
|
|
//otherGestureRecognizer.isEnabled = false
|
|
//otherGestureRecognizer.isEnabled = true
|
|
return true
|
|
} else {
|
|
return false
|
|
}
|
|
}
|
|
|
|
public func clearSelection() {
|
|
self.itemInteraction.selectedIds = Set()
|
|
self.selectedIdsPromise.set(Set())
|
|
self.updateSelectedItems(animated: true)
|
|
}
|
|
|
|
public func updateSelectedMessages(animated: Bool) {
|
|
}
|
|
|
|
public func updateSelectedStories(selectedStoryIds: Set<Int32>?, animated: Bool) {
|
|
self.itemInteraction.selectedIds = selectedStoryIds
|
|
self.selectedIdsPromise.set(selectedStoryIds ?? Set())
|
|
|
|
self.updateSelectedItems(animated: animated)
|
|
|
|
if let tempContextContentItemNode = self.tempContextContentItemNode, let itemLayer = tempContextContentItemNode.itemLayer {
|
|
let rect = self.itemGrid.frameForItem(layer: itemLayer)
|
|
tempContextContentItemNode.frame = rect
|
|
|
|
var isSelected: Bool?
|
|
if let selectedIds = self.itemInteraction.selectedIds {
|
|
isSelected = selectedIds.contains(tempContextContentItemNode.item.id)
|
|
}
|
|
tempContextContentItemNode.itemView.updateSelection(theme: self.itemGridBinding.checkNodeTheme, isSelected: isSelected, animated: true)
|
|
}
|
|
|
|
if let contextControllerToDismissOnSelection = self.contextControllerToDismissOnSelection as? ContextController {
|
|
self.contextControllerToDismissOnSelection = nil
|
|
contextControllerToDismissOnSelection.dismissWithCustomTransition(transition: .animated(duration: 0.4, curve: .spring), completion: nil)
|
|
}
|
|
|
|
if let (size, topInset, sideInset, bottomInset, deviceMetrics, visibleHeight, isScrollingLockedAtTop, expandProgress, navigationHeight, presentationData) = self.currentParams {
|
|
self.update(size: size, topInset: topInset, sideInset: sideInset, bottomInset: bottomInset, deviceMetrics: deviceMetrics, visibleHeight: visibleHeight, isScrollingLockedAtTop: isScrollingLockedAtTop, expandProgress: expandProgress, navigationHeight: navigationHeight, presentationData: presentationData, synchronous: false, transition: .animated(duration: 0.4, curve: .spring))
|
|
}
|
|
}
|
|
|
|
private func updateSelectedItems(animated: Bool) {
|
|
self.contextGestureContainerNode.isGestureEnabled = self.isProfileEmbedded && self.itemInteraction.selectedIds == nil && !self.isReordering
|
|
|
|
self.itemGrid.forEachVisibleItem { item in
|
|
guard let itemLayer = item.layer as? ItemLayer, let item = itemLayer.item else {
|
|
return
|
|
}
|
|
itemLayer.updateSelection(theme: self.itemGridBinding.checkNodeTheme, isSelected: self.itemInteraction.selectedIds?.contains(item.story.id), animated: animated)
|
|
}
|
|
|
|
var isSelecting = false
|
|
if let selectedIds = self._itemInteraction?.selectedIds, !selectedIds.isEmpty {
|
|
isSelecting = true
|
|
}
|
|
self.itemGrid.pinchEnabled = !isSelecting
|
|
|
|
var enableDismissGesture = true
|
|
if self.isProfileEmbedded {
|
|
enableDismissGesture = true
|
|
} else if let items = self.items, items.items.isEmpty {
|
|
}
|
|
|
|
if isSelecting {
|
|
enableDismissGesture = false
|
|
}
|
|
self.view.disablesInteractiveTransitionGestureRecognizer = !enableDismissGesture
|
|
|
|
if isSelecting {
|
|
if self.gridSelectionGesture == nil {
|
|
let selectionGesture = MediaPickerGridSelectionGesture<Int32>()
|
|
selectionGesture.delegate = self.wrappedGestureRecognizerDelegate
|
|
selectionGesture.sideInset = 44.0
|
|
selectionGesture.updateIsScrollEnabled = { [weak self] isEnabled in
|
|
self?.itemGrid.isScrollEnabled = isEnabled
|
|
}
|
|
selectionGesture.itemAt = { [weak self] point in
|
|
if let strongSelf = self, let itemLayer = strongSelf.itemGrid.item(at: point)?.layer as? ItemLayer, let storyId = itemLayer.item?.story.id {
|
|
return (storyId, strongSelf._itemInteraction?.selectedIds?.contains(storyId) ?? false)
|
|
} else {
|
|
return nil
|
|
}
|
|
}
|
|
selectionGesture.updateSelection = { [weak self] storyId, selected in
|
|
if let strongSelf = self {
|
|
strongSelf._itemInteraction?.toggleSelection(storyId, selected)
|
|
}
|
|
}
|
|
self.itemGrid.view.addGestureRecognizer(selectionGesture)
|
|
self.gridSelectionGesture = selectionGesture
|
|
}
|
|
} else if let gridSelectionGesture = self.gridSelectionGesture {
|
|
self.itemGrid.view.removeGestureRecognizer(gridSelectionGesture)
|
|
self.gridSelectionGesture = nil
|
|
}
|
|
}
|
|
|
|
private func updateHiddenItems() {
|
|
self.itemGrid.forEachVisibleItem { itemValue in
|
|
guard let itemLayer = itemValue.layer as? ItemLayer, let item = itemLayer.item else {
|
|
return
|
|
}
|
|
let itemHidden = self.itemInteraction.hiddenStories.contains(item.storyId)
|
|
itemLayer.isHidden = itemHidden
|
|
|
|
if let blurLayer = itemValue.blurLayer {
|
|
let transition = ComponentTransition.immediate
|
|
if itemHidden {
|
|
transition.setAlpha(layer: blurLayer, alpha: 0.0)
|
|
} else {
|
|
transition.setAlpha(layer: blurLayer, alpha: 1.0)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func presentDeleteConfirmation(ids: Set<Int32>) {
|
|
if case let .peer(peerId, _, _) = self.scope {
|
|
let presentationData = self.presentationData
|
|
let controller = ActionSheetController(presentationData: presentationData)
|
|
let dismissAction: () -> Void = { [weak controller] in
|
|
controller?.dismissAnimated()
|
|
}
|
|
|
|
let title: String = presentationData.strings.StoryList_DeleteConfirmation_Title(Int32(ids.count))
|
|
|
|
controller.setItemGroups([
|
|
ActionSheetItemGroup(items: [
|
|
ActionSheetTextItem(title: title),
|
|
ActionSheetButtonItem(title: presentationData.strings.StoryList_DeleteConfirmation_Action, color: .destructive, action: { [weak self] in
|
|
dismissAction()
|
|
|
|
guard let self else {
|
|
return
|
|
}
|
|
|
|
if let parentController = self.parentController as? PeerInfoScreen {
|
|
parentController.cancelItemSelection()
|
|
}
|
|
|
|
let _ = self.context.engine.messages.deleteStories(peerId: peerId, ids: Array(ids)).startStandalone()
|
|
})
|
|
]),
|
|
ActionSheetItemGroup(items: [ActionSheetButtonItem(title: presentationData.strings.Common_Cancel, action: { dismissAction() })])
|
|
])
|
|
self.parentController?.present(controller, in: .window(.root), with: ViewControllerPresentationArguments(presentationAnimation: .modalSheet))
|
|
} else if case let .botPreview(peerId) = self.scope {
|
|
let presentationData = self.presentationData
|
|
let controller = ActionSheetController(presentationData: presentationData)
|
|
let dismissAction: () -> Void = { [weak controller] in
|
|
controller?.dismissAnimated()
|
|
}
|
|
|
|
var mappedMedia: [Media] = []
|
|
if let items = self.items {
|
|
mappedMedia = items.items.compactMap { item -> Media? in
|
|
guard let item = item as? VisualMediaItem else {
|
|
return nil
|
|
}
|
|
if ids.contains(item.story.id) {
|
|
return item.story.media._asMedia()
|
|
} else {
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
if mappedMedia.isEmpty {
|
|
return
|
|
}
|
|
|
|
let title: String = presentationData.strings.BotPreviews_SheetDeleteTitle(Int32(mappedMedia.count))
|
|
|
|
controller.setItemGroups([
|
|
ActionSheetItemGroup(items: [
|
|
ActionSheetTextItem(title: title),
|
|
ActionSheetButtonItem(title: presentationData.strings.Common_Delete, color: .destructive, action: { [weak self] in
|
|
dismissAction()
|
|
|
|
guard let self else {
|
|
return
|
|
}
|
|
guard let listSource = self.listSource as? BotPreviewStoryListContext else {
|
|
return
|
|
}
|
|
|
|
if let parentController = self.parentController as? PeerInfoScreen {
|
|
parentController.cancelItemSelection()
|
|
}
|
|
|
|
let _ = self.context.engine.messages.deleteBotPreviews(peerId: peerId, language: listSource.language, media: mappedMedia).startStandalone()
|
|
})
|
|
]),
|
|
ActionSheetItemGroup(items: [ActionSheetButtonItem(title: presentationData.strings.Common_Cancel, action: { dismissAction() })])
|
|
])
|
|
self.parentController?.present(controller, in: .window(.root), with: ViewControllerPresentationArguments(presentationAnimation: .modalSheet))
|
|
}
|
|
}
|
|
|
|
private func update(transition: ContainedViewLayoutTransition) {
|
|
if let (size, topInset, sideInset, bottomInset, deviceMetrics, visibleHeight, isScrollingLockedAtTop, expandProgress, navigationHeight, presentationData) = self.currentParams {
|
|
self.update(size: size, topInset: topInset, sideInset: sideInset, bottomInset: bottomInset, deviceMetrics: deviceMetrics, visibleHeight: visibleHeight, isScrollingLockedAtTop: isScrollingLockedAtTop, expandProgress: expandProgress, navigationHeight: navigationHeight, presentationData: presentationData, synchronous: false, transition: transition)
|
|
}
|
|
}
|
|
|
|
private func gridScrollingOffsetUpdated(transition: ContainedViewLayoutTransition) {
|
|
if let currentParams = self.currentParams {
|
|
if let _ = self.mapNode {
|
|
self.updateMapLayout(size: currentParams.size, topInset: currentParams.topInset, bottomInset: currentParams.bottomInset, deviceMetrics: currentParams.deviceMetrics, transition: transition)
|
|
}
|
|
if case .botPreview = self.scope, self.canManageStories {
|
|
self.updateBotPreviewLanguageTab(size: currentParams.size, topInset: currentParams.topInset, transition: transition)
|
|
self.updateBotPreviewFooter(size: currentParams.size, bottomInset: 0.0, transition: transition)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var effectiveMapHeight: CGFloat = 0.0
|
|
private func updateMapLayout(size: CGSize, topInset: CGFloat, bottomInset: CGFloat, deviceMetrics: DeviceMetrics, transition: ContainedViewLayoutTransition) {
|
|
guard let mapNode = self.mapNode else {
|
|
return
|
|
}
|
|
|
|
var mapHeight = min(size.width, size.height)
|
|
mapHeight = min(mapHeight, floor(size.height * 0.389))
|
|
|
|
let mapOverscrollInset: CGFloat = size.height - mapHeight
|
|
|
|
self.effectiveMapHeight = mapHeight - self.additionalNavigationHeight
|
|
let mapSize = CGSize(width: size.width, height: mapHeight + mapOverscrollInset)
|
|
|
|
var controlsTopPadding = mapOverscrollInset + self.additionalNavigationHeight
|
|
|
|
let effectiveScrollingOffset: CGFloat
|
|
if let items = self.items, items.items.isEmpty, items.count == 0 {
|
|
effectiveScrollingOffset = -size.height * 0.5 + 60.0 + bottomInset
|
|
} else {
|
|
effectiveScrollingOffset = self.itemGrid.scrollingOffset
|
|
}
|
|
controlsTopPadding += min(0.0, effectiveScrollingOffset)
|
|
|
|
let mapFrame = CGRect(origin: CGPoint(x: 0.0, y: topInset - mapOverscrollInset - effectiveScrollingOffset - self.additionalNavigationHeight), size: mapSize)
|
|
transition.updateFrame(node: mapNode, frame: mapFrame)
|
|
|
|
let mapOffset = min(floorToScreenPixels(effectiveScrollingOffset * 0.5), mapSize.height)
|
|
|
|
mapNode.updateLayout(
|
|
layout: ContainerViewLayout(
|
|
size: mapSize,
|
|
metrics: LayoutMetrics(widthClass: .compact, heightClass: .compact, orientation: nil),
|
|
deviceMetrics: deviceMetrics,
|
|
intrinsicInsets: UIEdgeInsets(top: mapOverscrollInset, left: 0.0, bottom: 0.0, right: 0.0),
|
|
safeInsets: UIEdgeInsets(),
|
|
additionalInsets: UIEdgeInsets(),
|
|
statusBarHeight: nil,
|
|
inputHeight: nil,
|
|
inputHeightIsInteractivellyChanging: false,
|
|
inVoiceOver: false
|
|
),
|
|
navigationBarHeight: 0.0,
|
|
topPadding: mapOverscrollInset + self.additionalNavigationHeight,
|
|
controlsTopPadding: controlsTopPadding,
|
|
offset: mapOffset,
|
|
size: mapSize,
|
|
transition: transition
|
|
)
|
|
|
|
if let mapInfoData = self.mapInfoData {
|
|
let mapInfoNode: LocationInfoListItemNode
|
|
if let current = self.mapInfoNode {
|
|
mapInfoNode = current
|
|
} else {
|
|
mapInfoNode = LocationInfoListItemNode()
|
|
mapInfoNode.isUserInteractionEnabled = false
|
|
self.mapInfoNode = mapInfoNode
|
|
mapNode.supernode?.insertSubnode(mapInfoNode, aboveSubnode: mapNode)
|
|
mapInfoNode.clipsToBounds = true
|
|
mapInfoNode.cornerRadius = 10.0
|
|
mapInfoNode.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
|
|
}
|
|
mapInfoNode.backgroundColor = self.presentationData.theme.list.plainBackgroundColor
|
|
|
|
let addressString: String?
|
|
if let address = mapInfoData.address {
|
|
addressString = address
|
|
} else {
|
|
addressString = self.presentationData.strings.Map_Locating
|
|
}
|
|
let distanceString: String?
|
|
if let distance = mapInfoData.distance {
|
|
distanceString = distance < 10 ? self.presentationData.strings.Map_YouAreHere : self.presentationData.strings.Map_DistanceAway(stringForDistance(strings: self.presentationData.strings, distance: distance)).string
|
|
} else {
|
|
distanceString = nil
|
|
}
|
|
|
|
let item = LocationInfoListItem(
|
|
presentationData: ItemListPresentationData(self.presentationData),
|
|
engine: self.context.engine,
|
|
location: mapInfoData.location,
|
|
address: addressString,
|
|
distance: distanceString,
|
|
drivingTime: mapInfoData.drivingTime,
|
|
transitTime: mapInfoData.transitTime,
|
|
walkingTime: mapInfoData.walkingTime,
|
|
hasEta: mapInfoData.hasEta,
|
|
action: {},
|
|
drivingAction: {},
|
|
transitAction: {},
|
|
walkingAction: {}
|
|
)
|
|
let (mapInfoLayout, mapInfoReadyAndApply) = mapInfoNode.asyncLayout()(
|
|
item,
|
|
ListViewItemLayoutParams(width: size.width, leftInset: 0.0, rightInset: 0.0, availableHeight: 1000.0, isStandalone: true)
|
|
)
|
|
|
|
let mapInfoTopInset: CGFloat = -6.0
|
|
|
|
let mapInfoFrame = CGRect(origin: CGPoint(x: 0.0, y: mapFrame.maxY + mapInfoTopInset), size: mapInfoLayout.contentSize)
|
|
transition.updateFrame(node: mapInfoNode, frame: mapInfoFrame)
|
|
mapInfoReadyAndApply().1(ListViewItemApply(isOnScreen: true))
|
|
|
|
self.effectiveMapHeight += mapInfoLayout.contentSize.height + mapInfoTopInset
|
|
|
|
if let itemCount = self.itemCount, itemCount != 0 {
|
|
let searchHeader: ComponentView<Empty>
|
|
if let current = self.searchHeader {
|
|
searchHeader = current
|
|
} else {
|
|
searchHeader = ComponentView()
|
|
self.searchHeader = searchHeader
|
|
}
|
|
let searchHeaderSize = searchHeader.update(
|
|
transition: ComponentTransition(transition),
|
|
component: AnyComponent(StorySearchHeaderComponent(
|
|
theme: self.presentationData.theme,
|
|
strings: self.presentationData.strings,
|
|
count: itemCount
|
|
)),
|
|
environment: {},
|
|
containerSize: CGSize(width: size.width, height: 1000.0)
|
|
)
|
|
let searchHeaderFrame = CGRect(origin: CGPoint(x: 0.0, y: max(topInset, mapInfoFrame.maxY)), size: searchHeaderSize)
|
|
if let searchHeaderView = searchHeader.view {
|
|
if searchHeaderView.superview == nil {
|
|
self.view.addSubview(searchHeaderView)
|
|
}
|
|
transition.updateFrame(view: searchHeaderView, frame: searchHeaderFrame)
|
|
}
|
|
self.effectiveMapHeight += searchHeaderSize.height
|
|
} else {
|
|
if let searchHeader = self.searchHeader {
|
|
self.searchHeader = nil
|
|
searchHeader.view?.removeFromSuperview()
|
|
}
|
|
}
|
|
} else {
|
|
if let mapInfoNode = self.mapInfoNode {
|
|
self.mapInfoNode = nil
|
|
mapInfoNode.removeFromSupernode()
|
|
}
|
|
if let searchHeader = self.searchHeader {
|
|
self.searchHeader = nil
|
|
searchHeader.view?.removeFromSuperview()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func updateBotPreviewLanguageTab(size: CGSize, topInset: CGFloat, transition: ContainedViewLayoutTransition) {
|
|
guard case .botPreview = self.scope, self.canManageStories else {
|
|
return
|
|
}
|
|
|
|
let botPreviewLanguageTab: ComponentView<Empty>
|
|
if let current = self.botPreviewLanguageTab {
|
|
botPreviewLanguageTab = current
|
|
} else {
|
|
botPreviewLanguageTab = ComponentView()
|
|
self.botPreviewLanguageTab = botPreviewLanguageTab
|
|
}
|
|
|
|
var languageItems: [TabSelectorComponent.Item] = []
|
|
languageItems.append(TabSelectorComponent.Item(
|
|
id: AnyHashable("_main"),
|
|
title: self.presentationData.strings.BotPreviews_LanguageTab_Main
|
|
))
|
|
for language in self.currentBotPreviewLanguages {
|
|
languageItems.append(TabSelectorComponent.Item(
|
|
id: AnyHashable(language.id),
|
|
title: language.name
|
|
))
|
|
}
|
|
languageItems.append(TabSelectorComponent.Item(
|
|
id: AnyHashable("_add"),
|
|
title: self.presentationData.strings.BotPreviews_LanguageTab_Add
|
|
))
|
|
var selectedLanguageId = "_main"
|
|
if let listSource = self.listSource as? BotPreviewStoryListContext, let language = listSource.language {
|
|
selectedLanguageId = language
|
|
}
|
|
|
|
let botPreviewLanguageTabSize = botPreviewLanguageTab.update(
|
|
transition: ComponentTransition(transition),
|
|
component: AnyComponent(TabSelectorComponent(
|
|
colors: TabSelectorComponent.Colors(
|
|
foreground: self.presentationData.theme.list.itemPrimaryTextColor.withMultipliedAlpha(0.8),
|
|
selection: self.presentationData.theme.list.itemPrimaryTextColor.withMultipliedAlpha(0.05)
|
|
),
|
|
customLayout: TabSelectorComponent.CustomLayout(
|
|
font: Font.medium(14.0),
|
|
spacing: 9.0,
|
|
verticalInset: 11.0
|
|
),
|
|
items: languageItems,
|
|
selectedId: AnyHashable(selectedLanguageId),
|
|
setSelectedId: { [weak self] id in
|
|
guard let self, let id = id.base as? String else {
|
|
return
|
|
}
|
|
if id == "_add" {
|
|
self.presentAddBotPreviewLanguage()
|
|
} else if id == "_main" {
|
|
self.setBotPreviewLanguage(id: nil, assumeEmpty: false)
|
|
} else if let language = self.currentBotPreviewLanguages.first(where: { $0.id == id }) {
|
|
self.setBotPreviewLanguage(id: language.id, assumeEmpty: false)
|
|
}
|
|
}
|
|
)),
|
|
environment: {},
|
|
containerSize: CGSize(width: size.width, height: 44.0)
|
|
)
|
|
var botPreviewLanguageTabFrame = CGRect(origin: CGPoint(x: floor((size.width - botPreviewLanguageTabSize.width) * 0.5), y: topInset - 11.0), size: botPreviewLanguageTabSize)
|
|
|
|
let effectiveScrollingOffset: CGFloat
|
|
effectiveScrollingOffset = self.itemGrid.scrollingOffset
|
|
botPreviewLanguageTabFrame.origin.y -= effectiveScrollingOffset
|
|
|
|
let isSelectingOrReordering = self.isReordering || self.itemInteraction.selectedIds != nil
|
|
|
|
if let botPreviewLanguageTabView = botPreviewLanguageTab.view {
|
|
if botPreviewLanguageTabView.superview == nil {
|
|
self.view.addSubview(botPreviewLanguageTabView)
|
|
}
|
|
transition.updateFrame(view: botPreviewLanguageTabView, frame: botPreviewLanguageTabFrame)
|
|
transition.updateAlpha(layer: botPreviewLanguageTabView.layer, alpha: isSelectingOrReordering ? 0.5 : 1.0)
|
|
botPreviewLanguageTabView.isUserInteractionEnabled = !isSelectingOrReordering
|
|
}
|
|
}
|
|
|
|
private func updateBotPreviewFooter(size: CGSize, bottomInset: CGFloat, transition: ContainedViewLayoutTransition) {
|
|
if let items = self.items, !items.items.isEmpty {
|
|
var botPreviewFooterTransition = ComponentTransition(transition)
|
|
let botPreviewFooter: ComponentView<Empty>
|
|
if let current = self.botPreviewFooter {
|
|
botPreviewFooter = current
|
|
} else {
|
|
botPreviewFooterTransition = .immediate
|
|
botPreviewFooter = ComponentView()
|
|
self.botPreviewFooter = botPreviewFooter
|
|
}
|
|
|
|
var isMainLanguage = true
|
|
let text: String
|
|
if let listSource = self.listSource as? BotPreviewStoryListContext, let id = listSource.language, let language = self.currentBotPreviewLanguages.first(where: { $0.id == id }) {
|
|
isMainLanguage = false
|
|
|
|
text = self.presentationData.strings.BotPreviews_TranslationFooter_Text(language.name).string
|
|
} else {
|
|
text = self.presentationData.strings.BotPreviews_DefaultFooter_Text
|
|
}
|
|
|
|
let botPreviewFooterSize = botPreviewFooter.update(
|
|
transition: botPreviewFooterTransition,
|
|
component: AnyComponent(EmptyStateIndicatorComponent(
|
|
context: self.context,
|
|
theme: self.presentationData.theme,
|
|
fitToHeight: true,
|
|
animationName: nil,
|
|
title: nil,
|
|
text: text,
|
|
actionTitle: self.presentationData.strings.BotPreviews_Empty_Add,
|
|
action: { [weak self] in
|
|
guard let self else {
|
|
return
|
|
}
|
|
if self.canAddMoreBotPreviews() {
|
|
self.emptyAction?()
|
|
} else {
|
|
self.presentUnableToAddMorePreviewsAlert()
|
|
}
|
|
},
|
|
additionalActionTitle: isMainLanguage ? self.presentationData.strings.BotPreviews_Empty_AddTranslation : nil,
|
|
additionalAction: { [weak self] in
|
|
guard let self else {
|
|
return
|
|
}
|
|
if isMainLanguage {
|
|
self.presentAddBotPreviewLanguage()
|
|
}
|
|
},
|
|
additionalActionSeparator: isMainLanguage ? self.presentationData.strings.BotPreviews_Empty_Separator : nil
|
|
)),
|
|
environment: {},
|
|
containerSize: CGSize(width: size.width, height: 1000.0)
|
|
)
|
|
let botPreviewFooterFrame = CGRect(origin: CGPoint(x: floor((size.width - botPreviewFooterSize.width) * 0.5), y: self.itemGrid.contentBottomOffset + 16.0), size: botPreviewFooterSize)
|
|
if let botPreviewFooterView = botPreviewFooter.view {
|
|
if botPreviewFooterView.superview == nil {
|
|
self.view.addSubview(botPreviewFooterView)
|
|
}
|
|
botPreviewFooterTransition.setFrame(view: botPreviewFooterView, frame: botPreviewFooterFrame)
|
|
}
|
|
} else {
|
|
if let botPreviewFooter = self.botPreviewFooter {
|
|
self.botPreviewFooter = nil
|
|
botPreviewFooter.view?.removeFromSuperview()
|
|
}
|
|
}
|
|
}
|
|
|
|
public func update(size: CGSize, topInset: CGFloat, sideInset: CGFloat, bottomInset: CGFloat, deviceMetrics: DeviceMetrics, visibleHeight: CGFloat, isScrollingLockedAtTop: Bool, expandProgress: CGFloat, navigationHeight: CGFloat, presentationData: PresentationData, synchronous: Bool, transition: ContainedViewLayoutTransition) {
|
|
self.update(size: size, topInset: topInset, sideInset: sideInset, bottomInset: bottomInset, deviceMetrics: deviceMetrics, visibleHeight: visibleHeight, isScrollingLockedAtTop: isScrollingLockedAtTop, expandProgress: expandProgress, navigationHeight: navigationHeight, presentationData: presentationData, synchronous: synchronous, transition: transition, animateGridItems: false)
|
|
}
|
|
|
|
private func update(size: CGSize, topInset: CGFloat, sideInset: CGFloat, bottomInset: CGFloat, deviceMetrics: DeviceMetrics, visibleHeight: CGFloat, isScrollingLockedAtTop: Bool, expandProgress: CGFloat, navigationHeight: CGFloat, presentationData: PresentationData, synchronous: Bool, transition: ContainedViewLayoutTransition, animateGridItems: Bool) {
|
|
self.currentParams = (size, topInset, sideInset, bottomInset, deviceMetrics, visibleHeight, isScrollingLockedAtTop, expandProgress, navigationHeight, presentationData)
|
|
|
|
var gridTopInset = topInset
|
|
|
|
if self.mapNode != nil {
|
|
self.updateMapLayout(size: size, topInset: topInset, bottomInset: bottomInset, deviceMetrics: deviceMetrics, transition: transition)
|
|
gridTopInset += self.effectiveMapHeight
|
|
|
|
let mapOptionsNode: LocationOptionsNode
|
|
if let current = self.mapOptionsNode {
|
|
mapOptionsNode = current
|
|
} else {
|
|
mapOptionsNode = LocationOptionsNode(presentationData: self.presentationData, hasBackground: false, updateMapMode: { [weak self] mode in
|
|
guard let self else {
|
|
return
|
|
}
|
|
|
|
var state = self.locationViewState
|
|
state.mapMode = mode
|
|
state.displayingMapModeOptions = false
|
|
self.locationViewState = state
|
|
})
|
|
mapOptionsNode.clipsToBounds = true
|
|
self.mapOptionsNode = mapOptionsNode
|
|
self.parentController?.navigationBar?.additionalContentNode.addSubnode(mapOptionsNode)
|
|
}
|
|
|
|
let mapOptionsFrame = CGRect(origin: CGPoint(x: 0.0, y: topInset - self.additionalNavigationHeight), size: CGSize(width: size.width, height: self.additionalNavigationHeight))
|
|
transition.updatePosition(node: mapOptionsNode, position: mapOptionsFrame.center)
|
|
transition.updateBounds(node: mapOptionsNode, bounds: CGRect(origin: CGPoint(x: 0.0, y: 38.0 - self.additionalNavigationHeight), size: mapOptionsFrame.size))
|
|
mapOptionsNode.updateLayout(size: mapOptionsFrame.size, leftInset: sideInset, rightInset: sideInset, transition: transition)
|
|
}
|
|
|
|
if self.isProfileEmbedded, case .botPreview = self.scope {
|
|
let barBackgroundLayer: SimpleLayer
|
|
if let current = self.barBackgroundLayer {
|
|
barBackgroundLayer = current
|
|
} else {
|
|
barBackgroundLayer = SimpleLayer()
|
|
self.barBackgroundLayer = barBackgroundLayer
|
|
self.layer.insertSublayer(barBackgroundLayer, at: 0)
|
|
}
|
|
barBackgroundLayer.backgroundColor = presentationData.theme.list.plainBackgroundColor.cgColor
|
|
transition.updateFrame(layer: barBackgroundLayer, frame: CGRect(origin: CGPoint(), size: CGSize(width: size.width, height: gridTopInset)))
|
|
}
|
|
|
|
var listBottomInset = bottomInset
|
|
var bottomInset = bottomInset
|
|
|
|
if case .botPreview = self.scope, self.canManageStories {
|
|
updateBotPreviewLanguageTab(size: size, topInset: topInset, transition: transition)
|
|
gridTopInset += 50.0
|
|
|
|
updateBotPreviewFooter(size: size, bottomInset: 0.0, transition: transition)
|
|
if let botPreviewFooterView = self.botPreviewFooter?.view {
|
|
listBottomInset += 18.0 + botPreviewFooterView.bounds.height
|
|
}
|
|
}
|
|
|
|
if self.isProfileEmbedded, let selectedIds = self.itemInteraction.selectedIds, self.canManageStories, case let .peer(peerId, _, isArchived) = self.scope {
|
|
let selectionPanel: ComponentView<Empty>
|
|
var selectionPanelTransition = ComponentTransition(transition)
|
|
if let current = self.selectionPanel {
|
|
selectionPanel = current
|
|
} else {
|
|
selectionPanelTransition = selectionPanelTransition.withAnimation(.none)
|
|
selectionPanel = ComponentView()
|
|
self.selectionPanel = selectionPanel
|
|
}
|
|
|
|
var selectionItems: [BottomActionsPanelComponent.Item] = []
|
|
|
|
let actionIsPin = !selectedIds.contains(where: { self.pinnedIds.contains($0) })
|
|
selectionItems.append(BottomActionsPanelComponent.Item(
|
|
id: "pin-unpin",
|
|
color: .accent,
|
|
title: actionIsPin ? presentationData.strings.StoryList_ActionPanel_Pin : presentationData.strings.StoryList_ActionPanel_Unpin,
|
|
isEnabled: !selectedIds.isEmpty,
|
|
action: { [weak self] in
|
|
guard let self, let selectedIds = self.itemInteraction.selectedIds else {
|
|
return
|
|
}
|
|
if !selectedIds.contains(where: { self.pinnedIds.contains($0) }) {
|
|
var updatedPinnedIds = self.pinnedIds
|
|
for id in selectedIds {
|
|
updatedPinnedIds.insert(id)
|
|
}
|
|
if updatedPinnedIds.count > 3 {
|
|
let presentationData = self.presentationData
|
|
let animationBackgroundColor = presentationData.theme.rootController.tabBar.backgroundColor
|
|
let toastText = presentationData.strings.StoryList_ToastPinLimit_Text(3)
|
|
self.parentController?.present(UndoOverlayController(presentationData: presentationData, content: .universal(animation: "anim_infotip", scale: 1.0, colors: ["info1.info1.stroke": animationBackgroundColor, "info2.info2.Fill": animationBackgroundColor], title: nil, text: toastText, customUndoText: nil, timeout: 5), elevatedLayout: false, animateInAsReplacement: false, action: { _ in return false }), in: .current)
|
|
} else {
|
|
let _ = self.context.engine.messages.updatePinnedToTopStories(peerId: peerId, ids: Array(updatedPinnedIds)).startStandalone()
|
|
|
|
let presentationData = self.presentationData
|
|
|
|
let toastTitle = presentationData.strings.StoryList_ToastPinned_Title(Int32(selectedIds.count))
|
|
let toastText = presentationData.strings.StoryList_ToastPinned_Text(Int32(selectedIds.count))
|
|
|
|
self.parentController?.present(UndoOverlayController(presentationData: presentationData, content: .universal(animation: "anim_toastpin", scale: 0.06, colors: [:], title: toastTitle, text: toastText, customUndoText: nil, timeout: 5), elevatedLayout: false, animateInAsReplacement: false, action: { _ in return false }), in: .current)
|
|
|
|
if let parentController = self.parentController as? PeerInfoScreen {
|
|
parentController.cancelItemSelection()
|
|
}
|
|
}
|
|
} else {
|
|
var updatedPinnedIds = self.pinnedIds
|
|
for id in selectedIds {
|
|
updatedPinnedIds.remove(id)
|
|
}
|
|
let _ = self.context.engine.messages.updatePinnedToTopStories(peerId: peerId, ids: Array(updatedPinnedIds)).startStandalone()
|
|
|
|
let presentationData = self.presentationData
|
|
|
|
let toastTitle: String? = nil
|
|
let toastText: String = presentationData.strings.StoryList_ToastUnpinned_Text(Int32(selectedIds.count))
|
|
|
|
self.parentController?.present(UndoOverlayController(presentationData: presentationData, content: .universal(animation: "anim_toastunpin", scale: 0.06, colors: [:], title: toastTitle, text: toastText, customUndoText: nil, timeout: 5), elevatedLayout: false, animateInAsReplacement: false, action: { _ in return false }), in: .current)
|
|
|
|
if let parentController = self.parentController as? PeerInfoScreen {
|
|
parentController.cancelItemSelection()
|
|
}
|
|
}
|
|
}
|
|
))
|
|
selectionItems.append(BottomActionsPanelComponent.Item(
|
|
id: "archive",
|
|
color: .accent,
|
|
title: isArchived ? presentationData.strings.StoryList_ActionPanel_Unarchive : presentationData.strings.StoryList_ActionPanel_Archive,
|
|
isEnabled: !selectedIds.isEmpty,
|
|
action: { [weak self] in
|
|
guard let self, let _ = self.itemInteraction.selectedIds else {
|
|
return
|
|
}
|
|
|
|
let items: [Int32: EngineStoryItem] = self.selectedItems
|
|
|
|
if let parentController = self.parentController as? PeerInfoScreen {
|
|
parentController.cancelItemSelection()
|
|
}
|
|
|
|
let _ = self.context.engine.messages.updateStoriesArePinned(peerId: peerId, ids: items, isPinned: isArchived ? true : false).startStandalone()
|
|
|
|
let text: String
|
|
if isArchived {
|
|
text = presentationData.strings.StoryList_ToastUnarchived_Text(Int32(items.count))
|
|
} else {
|
|
text = presentationData.strings.StoryList_ToastArchived_Text(Int32(items.count))
|
|
}
|
|
self.parentController?.present(UndoOverlayController(presentationData: presentationData, content: .actionSucceeded(title: nil, text: text, cancel: nil, destructive: false), elevatedLayout: false, animateInAsReplacement: false, action: { _ in return false }), in: .current)
|
|
}
|
|
))
|
|
selectionItems.append(BottomActionsPanelComponent.Item(
|
|
id: "delete",
|
|
color: .destructive,
|
|
title: presentationData.strings.StoryList_ActionPanel_Delete,
|
|
isEnabled: !selectedIds.isEmpty,
|
|
action: { [weak self] in
|
|
guard let self, let selectedIds = self.itemInteraction.selectedIds else {
|
|
return
|
|
}
|
|
|
|
self.presentDeleteConfirmation(ids: selectedIds)
|
|
}
|
|
))
|
|
|
|
let selectionPanelSize = selectionPanel.update(
|
|
transition: selectionPanelTransition,
|
|
component: AnyComponent(BottomActionsPanelComponent(
|
|
theme: presentationData.theme,
|
|
insets: UIEdgeInsets(top: 0.0, left: sideInset, bottom: bottomInset, right: sideInset),
|
|
items: selectionItems
|
|
)),
|
|
environment: {},
|
|
containerSize: size
|
|
)
|
|
let selectionPanelFrame = CGRect(origin: CGPoint(x: 0.0, y: size.height - selectionPanelSize.height), size: selectionPanelSize)
|
|
if let selectionPanelView = selectionPanel.view {
|
|
if selectionPanelView.superview == nil {
|
|
self.view.addSubview(selectionPanelView)
|
|
transition.animatePositionAdditive(layer: selectionPanelView.layer, offset: CGPoint(x: 0.0, y: selectionPanelFrame.height))
|
|
}
|
|
selectionPanelTransition.setFrame(view: selectionPanelView, frame: selectionPanelFrame)
|
|
}
|
|
bottomInset = selectionPanelSize.height
|
|
listBottomInset += selectionPanelSize.height
|
|
} else if self.isProfileEmbedded, let selectedIds = self.itemInteraction.selectedIds, self.canManageStories, case .botPreview = self.scope {
|
|
let selectionPanel: ComponentView<Empty>
|
|
var selectionPanelTransition = ComponentTransition(transition)
|
|
if let current = self.selectionPanel {
|
|
selectionPanel = current
|
|
} else {
|
|
selectionPanelTransition = selectionPanelTransition.withAnimation(.none)
|
|
selectionPanel = ComponentView()
|
|
self.selectionPanel = selectionPanel
|
|
}
|
|
|
|
var selectionItems: [BottomActionsPanelComponent.Item] = []
|
|
|
|
selectionItems.append(BottomActionsPanelComponent.Item(
|
|
id: "delete",
|
|
color: .destructive,
|
|
title: presentationData.strings.StoryList_ActionPanel_Delete,
|
|
isEnabled: !selectedIds.isEmpty,
|
|
action: { [weak self] in
|
|
guard let self, let selectedIds = self.itemInteraction.selectedIds else {
|
|
return
|
|
}
|
|
|
|
self.presentDeleteConfirmation(ids: selectedIds)
|
|
}
|
|
))
|
|
|
|
let selectionPanelSize = selectionPanel.update(
|
|
transition: selectionPanelTransition,
|
|
component: AnyComponent(BottomActionsPanelComponent(
|
|
theme: presentationData.theme,
|
|
insets: UIEdgeInsets(top: 0.0, left: sideInset, bottom: bottomInset, right: sideInset),
|
|
items: selectionItems
|
|
)),
|
|
environment: {},
|
|
containerSize: size
|
|
)
|
|
let selectionPanelFrame = CGRect(origin: CGPoint(x: 0.0, y: size.height - selectionPanelSize.height), size: selectionPanelSize)
|
|
if let selectionPanelView = selectionPanel.view {
|
|
if selectionPanelView.superview == nil {
|
|
self.view.addSubview(selectionPanelView)
|
|
transition.animatePositionAdditive(layer: selectionPanelView.layer, offset: CGPoint(x: 0.0, y: selectionPanelFrame.height))
|
|
}
|
|
selectionPanelTransition.setFrame(view: selectionPanelView, frame: selectionPanelFrame)
|
|
}
|
|
bottomInset = selectionPanelSize.height
|
|
listBottomInset += selectionPanelSize.height
|
|
} else if let selectionPanel = self.selectionPanel {
|
|
self.selectionPanel = nil
|
|
if let selectionPanelView = selectionPanel.view {
|
|
transition.updateFrame(view: selectionPanelView, frame: CGRect(origin: CGPoint(x: 0.0, y: size.height), size: selectionPanelView.bounds.size))
|
|
}
|
|
}
|
|
|
|
transition.updateFrame(node: self.contextGestureContainerNode, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: size.width, height: size.height)))
|
|
|
|
if case let .peer(_, _, isArchived) = self.scope, let items = self.items, items.items.isEmpty, items.count == 0 {
|
|
let emptyStateView: ComponentView<Empty>
|
|
var emptyStateTransition = ComponentTransition(transition)
|
|
if let current = self.emptyStateView {
|
|
emptyStateView = current
|
|
} else {
|
|
emptyStateTransition = .immediate
|
|
emptyStateView = ComponentView()
|
|
self.emptyStateView = emptyStateView
|
|
}
|
|
let emptyStateSize = emptyStateView.update(
|
|
transition: emptyStateTransition,
|
|
component: AnyComponent(EmptyStateIndicatorComponent(
|
|
context: self.context,
|
|
theme: presentationData.theme,
|
|
fitToHeight: self.isProfileEmbedded,
|
|
animationName: "StoryListEmpty",
|
|
title: isArchived ? presentationData.strings.StoryList_ArchivedEmptyState_Title : presentationData.strings.StoryList_SavedEmptyPosts_Title,
|
|
text: isArchived ? presentationData.strings.StoryList_ArchivedEmptyState_Text : presentationData.strings.StoryList_SavedEmptyPosts_Text,
|
|
actionTitle: isArchived ? nil : presentationData.strings.StoryList_SavedAddAction,
|
|
action: { [weak self] in
|
|
guard let self else {
|
|
return
|
|
}
|
|
self.emptyAction?()
|
|
},
|
|
additionalActionTitle: (isArchived || self.isProfileEmbedded) ? nil : presentationData.strings.StoryList_SavedEmptyAction,
|
|
additionalAction: { [weak self] in
|
|
guard let self else {
|
|
return
|
|
}
|
|
self.additionalEmptyAction?()
|
|
}
|
|
)),
|
|
environment: {},
|
|
containerSize: CGSize(width: size.width, height: size.height - gridTopInset - bottomInset)
|
|
)
|
|
|
|
let emptyStateFrame: CGRect
|
|
if self.isProfileEmbedded {
|
|
emptyStateFrame = CGRect(origin: CGPoint(x: floor((size.width - emptyStateSize.width) * 0.5), y: max(gridTopInset, floor((visibleHeight - gridTopInset - bottomInset - emptyStateSize.height) * 0.5))), size: emptyStateSize)
|
|
} else {
|
|
emptyStateFrame = CGRect(origin: CGPoint(x: floor((size.width - emptyStateSize.width) * 0.5), y: gridTopInset), size: emptyStateSize)
|
|
}
|
|
|
|
if let emptyStateComponentView = emptyStateView.view {
|
|
if emptyStateComponentView.superview == nil {
|
|
self.view.addSubview(emptyStateComponentView)
|
|
if self.didUpdateItemsOnce {
|
|
emptyStateComponentView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
|
|
}
|
|
}
|
|
emptyStateTransition.setFrame(view: emptyStateComponentView, frame: emptyStateFrame)
|
|
}
|
|
|
|
let backgroundColor: UIColor
|
|
if self.isProfileEmbedded {
|
|
backgroundColor = presentationData.theme.list.plainBackgroundColor
|
|
} else {
|
|
backgroundColor = presentationData.theme.list.blocksBackgroundColor
|
|
}
|
|
|
|
if self.didUpdateItemsOnce {
|
|
ComponentTransition(animation: .curve(duration: 0.2, curve: .easeInOut)).setBackgroundColor(view: self.view, color: backgroundColor)
|
|
} else {
|
|
self.view.backgroundColor = backgroundColor
|
|
}
|
|
} else if case .botPreview = self.scope, let items = self.items, items.items.isEmpty, items.count == 0 {
|
|
let emptyStateView: ComponentView<Empty>
|
|
var emptyStateTransition = ComponentTransition(transition)
|
|
if let current = self.emptyStateView {
|
|
emptyStateView = current
|
|
} else {
|
|
emptyStateTransition = .immediate
|
|
emptyStateView = ComponentView()
|
|
self.emptyStateView = emptyStateView
|
|
}
|
|
|
|
var isMainLanguage = true
|
|
if let listSource = self.listSource as? BotPreviewStoryListContext, let _ = listSource.language {
|
|
isMainLanguage = false
|
|
}
|
|
|
|
let emptyStateSize = emptyStateView.update(
|
|
transition: emptyStateTransition,
|
|
component: AnyComponent(EmptyStateIndicatorComponent(
|
|
context: self.context,
|
|
theme: presentationData.theme,
|
|
fitToHeight: self.isProfileEmbedded,
|
|
animationName: nil,
|
|
title: presentationData.strings.BotPreviews_Empty_Title,
|
|
text: presentationData.strings.BotPreviews_Empty_Text(Int32(self.maxBotPreviewCount)),
|
|
actionTitle: self.canManageStories ? presentationData.strings.BotPreviews_Empty_Add : nil,
|
|
action: { [weak self] in
|
|
guard let self else {
|
|
return
|
|
}
|
|
if self.canAddMoreBotPreviews() {
|
|
self.emptyAction?()
|
|
} else {
|
|
self.presentUnableToAddMorePreviewsAlert()
|
|
}
|
|
},
|
|
additionalActionTitle: self.canManageStories ? (isMainLanguage ? presentationData.strings.BotPreviews_Empty_AddTranslation : presentationData.strings.BotPreviews_Empty_DeleteTranslation) : nil,
|
|
additionalAction: {
|
|
if isMainLanguage {
|
|
self.presentAddBotPreviewLanguage()
|
|
} else {
|
|
self.presentDeleteBotPreviewLanguage()
|
|
}
|
|
},
|
|
additionalActionSeparator: self.canManageStories ? presentationData.strings.BotPreviews_Empty_Separator : nil
|
|
)),
|
|
environment: {},
|
|
containerSize: CGSize(width: size.width, height: size.height - gridTopInset - bottomInset)
|
|
)
|
|
|
|
let emptyStateFrame: CGRect
|
|
if self.isProfileEmbedded {
|
|
emptyStateFrame = CGRect(origin: CGPoint(x: floor((size.width - emptyStateSize.width) * 0.5), y: max(gridTopInset + 22.0, floor((visibleHeight - gridTopInset - bottomInset - emptyStateSize.height) * 0.5))), size: emptyStateSize)
|
|
} else {
|
|
emptyStateFrame = CGRect(origin: CGPoint(x: floor((size.width - emptyStateSize.width) * 0.5), y: gridTopInset), size: emptyStateSize)
|
|
}
|
|
|
|
if let emptyStateComponentView = emptyStateView.view {
|
|
if emptyStateComponentView.superview == nil {
|
|
self.view.addSubview(emptyStateComponentView)
|
|
if self.didUpdateItemsOnce {
|
|
emptyStateComponentView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
|
|
}
|
|
}
|
|
emptyStateTransition.setFrame(view: emptyStateComponentView, frame: emptyStateFrame)
|
|
}
|
|
|
|
let backgroundColor: UIColor
|
|
if self.isProfileEmbedded, case .botPreview = self.scope {
|
|
backgroundColor = presentationData.theme.list.blocksBackgroundColor
|
|
} else if self.isProfileEmbedded {
|
|
backgroundColor = presentationData.theme.list.blocksBackgroundColor
|
|
} else {
|
|
backgroundColor = presentationData.theme.list.blocksBackgroundColor
|
|
}
|
|
|
|
if self.didUpdateItemsOnce {
|
|
ComponentTransition(animation: .curve(duration: 0.2, curve: .easeInOut)).setBackgroundColor(view: self.view, color: backgroundColor)
|
|
} else {
|
|
self.view.backgroundColor = backgroundColor
|
|
}
|
|
} else {
|
|
if let emptyStateView = self.emptyStateView {
|
|
let subTransition = ComponentTransition(animation: .curve(duration: 0.2, curve: .easeInOut))
|
|
self.emptyStateView = nil
|
|
|
|
if let emptyStateComponentView = emptyStateView.view {
|
|
if self.didUpdateItemsOnce {
|
|
subTransition.setAlpha(view: emptyStateComponentView, alpha: 0.0, completion: { [weak emptyStateComponentView] _ in
|
|
emptyStateComponentView?.removeFromSuperview()
|
|
})
|
|
} else {
|
|
emptyStateComponentView.removeFromSuperview()
|
|
}
|
|
}
|
|
|
|
if self.isProfileEmbedded, case .botPreview = self.scope {
|
|
subTransition.setBackgroundColor(view: self.view, color: presentationData.theme.list.blocksBackgroundColor)
|
|
} else if self.isProfileEmbedded {
|
|
subTransition.setBackgroundColor(view: self.view, color: presentationData.theme.list.plainBackgroundColor)
|
|
} else {
|
|
subTransition.setBackgroundColor(view: self.view, color: presentationData.theme.list.blocksBackgroundColor)
|
|
}
|
|
} else {
|
|
if self.isProfileEmbedded, case .botPreview = self.scope {
|
|
self.view.backgroundColor = presentationData.theme.list.blocksBackgroundColor
|
|
} else {
|
|
self.view.backgroundColor = .clear
|
|
}
|
|
}
|
|
}
|
|
|
|
transition.updateFrame(node: self.itemGrid, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: size.width, height: size.height)))
|
|
if let items = self.items {
|
|
let wasFirstTime = !self.didUpdateItemsOnce
|
|
self.didUpdateItemsOnce = true
|
|
let fixedItemHeight: CGFloat?
|
|
let isList = false
|
|
fixedItemHeight = nil
|
|
|
|
let fixedItemAspect: CGFloat? = 0.81
|
|
|
|
var adjustForSmallCount = true
|
|
if case .botPreview = self.scope {
|
|
adjustForSmallCount = false
|
|
}
|
|
|
|
self.itemGrid.pinchEnabled = items.count > 2 && !self.isReordering
|
|
self.itemGrid.update(size: size, insets: UIEdgeInsets(top: gridTopInset, left: sideInset, bottom: listBottomInset, right: sideInset), useSideInsets: !isList, scrollIndicatorInsets: UIEdgeInsets(top: 0.0, left: sideInset, bottom: bottomInset, right: sideInset), lockScrollingAtTop: isScrollingLockedAtTop, fixedItemHeight: fixedItemHeight, fixedItemAspect: fixedItemAspect, adjustForSmallCount: adjustForSmallCount, items: items, theme: self.itemGridBinding.chatPresentationData.theme.theme, synchronous: wasFirstTime ? .full : .none, transition: animateGridItems ? .spring(duration: 0.35) : .immediate)
|
|
}
|
|
|
|
self.listBottomInset = listBottomInset
|
|
if case .botPreview = self.scope, self.canManageStories {
|
|
updateBotPreviewFooter(size: size, bottomInset: 0.0, transition: transition)
|
|
}
|
|
}
|
|
|
|
public func currentTopTimestamp() -> Int32? {
|
|
var timestamp: Int32?
|
|
self.itemGrid.forEachVisibleItem { item in
|
|
guard let itemLayer = item.layer as? ItemLayer else {
|
|
return
|
|
}
|
|
if let item = itemLayer.item {
|
|
if let timestampValue = timestamp {
|
|
timestamp = max(timestampValue, item.story.timestamp)
|
|
} else {
|
|
timestamp = item.story.timestamp
|
|
}
|
|
}
|
|
}
|
|
return timestamp
|
|
}
|
|
|
|
public func scrollToTimestamp(timestamp: Int32) {
|
|
if let items = self.items, !items.items.isEmpty {
|
|
var previousIndex: Int?
|
|
for item in items.items {
|
|
guard let item = item as? VisualMediaItem else {
|
|
continue
|
|
}
|
|
if item.story.timestamp <= timestamp {
|
|
break
|
|
}
|
|
previousIndex = item.index
|
|
}
|
|
if previousIndex == nil {
|
|
previousIndex = (items.items[0] as? VisualMediaItem)?.index
|
|
}
|
|
if let index = previousIndex {
|
|
self.itemGrid.scrollToItem(at: index)
|
|
|
|
if let item = self.itemGrid.item(at: index) {
|
|
if let layer = item.layer as? ItemLayer {
|
|
Queue.mainQueue().after(0.1, { [weak layer] in
|
|
guard let layer = layer else {
|
|
return
|
|
}
|
|
|
|
let overlayLayer = SimpleLayer()
|
|
overlayLayer.backgroundColor = UIColor(white: 1.0, alpha: 0.6).cgColor
|
|
overlayLayer.frame = layer.bounds
|
|
layer.addSublayer(overlayLayer)
|
|
overlayLayer.animateAlpha(from: 1.0, to: 0.0, duration: 0.8, delay: 0.3, removeOnCompletion: false, completion: { [weak overlayLayer] _ in
|
|
overlayLayer?.removeFromSuperlayer()
|
|
})
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
public func scrollToItem(index: Int) {
|
|
guard let _ = self.items else {
|
|
return
|
|
}
|
|
self.itemGrid.scrollToItem(at: index)
|
|
}
|
|
|
|
override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
|
|
guard let result = super.hitTest(point, with: event) else {
|
|
return nil
|
|
}
|
|
/*if self.decelerationAnimator != nil {
|
|
self.decelerationAnimator?.isPaused = true
|
|
self.decelerationAnimator = nil
|
|
|
|
return self.scrollNode.view
|
|
}*/
|
|
return result
|
|
}
|
|
|
|
public func availableZoomLevels() -> (decrement: ZoomLevel?, increment: ZoomLevel?) {
|
|
let levels = self.itemGrid.availableZoomLevels()
|
|
return (levels.decrement.flatMap(ZoomLevel.init), levels.increment.flatMap(ZoomLevel.init))
|
|
}
|
|
|
|
public func canAddMoreBotPreviews() -> Bool {
|
|
guard let items = self.items else {
|
|
return false
|
|
}
|
|
|
|
return items.count < self.maxBotPreviewCount
|
|
}
|
|
|
|
public func canReorder() -> Bool {
|
|
guard let items = self.items else {
|
|
return false
|
|
}
|
|
return items.count > 1
|
|
}
|
|
|
|
private func presentAddBotPreviewLanguage() {
|
|
let excludeIds: [String] = self.currentBotPreviewLanguages.map(\.id)
|
|
self.parentController?.push(LanguageSelectionScreen(context: self.context, excludeIds: excludeIds, selectLocalization: { [weak self] info in
|
|
guard let self else {
|
|
return
|
|
}
|
|
self.addBotPreviewLanguage(language: StoryListContext.State.Language(id: info.languageCode, name: info.title))
|
|
}))
|
|
}
|
|
|
|
public func presentUnableToAddMorePreviewsAlert() {
|
|
self.parentController?.present(standardTextAlertController(theme: AlertControllerTheme(presentationData: self.presentationData), title: nil, text: self.presentationData.strings.BotPreviews_AlertTooManyPreviews(Int32(self.maxBotPreviewCount)), actions: [
|
|
TextAlertAction(type: .defaultAction, title: self.presentationData.strings.Common_OK, action: {
|
|
})
|
|
], parseMarkdown: true), in: .window(.root))
|
|
}
|
|
|
|
public func presentDeleteBotPreviewLanguage() {
|
|
self.parentController?.present(standardTextAlertController(theme: AlertControllerTheme(presentationData: self.presentationData), title: self.presentationData.strings.BotPreviews_DeleteTranslationAlert_Title, text: self.presentationData.strings.BotPreviews_DeleteTranslationAlert_Text, actions: [
|
|
TextAlertAction(type: .defaultAction, title: self.presentationData.strings.Common_Cancel, action: {
|
|
}),
|
|
TextAlertAction(type: .destructiveAction, title: self.presentationData.strings.Common_OK, action: { [weak self] in
|
|
guard let self else {
|
|
return
|
|
}
|
|
if let listSource = self.listSource as? BotPreviewStoryListContext, let language = listSource.language {
|
|
self.deleteBotPreviewLanguage(id: language)
|
|
}
|
|
})
|
|
], parseMarkdown: true), in: .window(.root))
|
|
}
|
|
|
|
private func addBotPreviewLanguage(language: StoryListContext.State.Language) {
|
|
var botPreviewLanguages = self.currentBotPreviewLanguages
|
|
|
|
var assumeEmpty = false
|
|
if !botPreviewLanguages.contains(where: { $0.id == language.id}) {
|
|
botPreviewLanguages.append(language)
|
|
assumeEmpty = true
|
|
}
|
|
botPreviewLanguages.sort(by: { $0.name < $1.name })
|
|
self.removedBotPreviewLanguages.remove(language.id)
|
|
|
|
if self.currentBotPreviewLanguages != botPreviewLanguages {
|
|
self.currentBotPreviewLanguages = botPreviewLanguages
|
|
if let (size, topInset, sideInset, bottomInset, deviceMetrics, visibleHeight, isScrollingLockedAtTop, expandProgress, navigationHeight, presentationData) = self.currentParams {
|
|
self.update(size: size, topInset: topInset, sideInset: sideInset, bottomInset: bottomInset, deviceMetrics: deviceMetrics, visibleHeight: visibleHeight, isScrollingLockedAtTop: isScrollingLockedAtTop, expandProgress: expandProgress, navigationHeight: navigationHeight, presentationData: presentationData, synchronous: false, transition: .immediate)
|
|
}
|
|
}
|
|
|
|
self.setBotPreviewLanguage(id: language.id, assumeEmpty: assumeEmpty)
|
|
}
|
|
|
|
private func deleteBotPreviewLanguage(id: String) {
|
|
var botPreviewLanguages = self.currentBotPreviewLanguages
|
|
|
|
if let index = botPreviewLanguages.firstIndex(where: { $0.id == id}) {
|
|
botPreviewLanguages.remove(at: index)
|
|
}
|
|
self.removedBotPreviewLanguages.insert(id)
|
|
|
|
guard case let .botPreview(peerId) = self.scope else {
|
|
return
|
|
}
|
|
|
|
var mappedMedia: [Media] = []
|
|
if let items = self.items {
|
|
mappedMedia = items.items.compactMap { item -> Media? in
|
|
guard let item = item as? VisualMediaItem else {
|
|
return nil
|
|
}
|
|
return item.story.media._asMedia()
|
|
}
|
|
}
|
|
let _ = self.context.engine.messages.deleteBotPreviewsLanguage(peerId: peerId, language: id, media: mappedMedia).startStandalone()
|
|
|
|
if self.currentBotPreviewLanguages != botPreviewLanguages {
|
|
self.currentBotPreviewLanguages = botPreviewLanguages
|
|
if let (size, topInset, sideInset, bottomInset, deviceMetrics, visibleHeight, isScrollingLockedAtTop, expandProgress, navigationHeight, presentationData) = self.currentParams {
|
|
self.update(size: size, topInset: topInset, sideInset: sideInset, bottomInset: bottomInset, deviceMetrics: deviceMetrics, visibleHeight: visibleHeight, isScrollingLockedAtTop: isScrollingLockedAtTop, expandProgress: expandProgress, navigationHeight: navigationHeight, presentationData: presentationData, synchronous: false, transition: .immediate)
|
|
}
|
|
}
|
|
|
|
self.setBotPreviewLanguage(id: nil, assumeEmpty: false)
|
|
}
|
|
|
|
private func setBotPreviewLanguage(id: String?, assumeEmpty: Bool) {
|
|
guard case let .botPreview(peerId) = self.scope else {
|
|
return
|
|
}
|
|
if let listSource = self.listSource as? BotPreviewStoryListContext, listSource.language == id {
|
|
return
|
|
}
|
|
|
|
if let id {
|
|
if let cachedListSource = self.cachedListSources[id] {
|
|
self.listSource = cachedListSource
|
|
} else {
|
|
let listSource = BotPreviewStoryListContext(account: self.context.account, engine: self.context.engine, peerId: peerId, language: id, assumeEmpty: assumeEmpty)
|
|
self.listSource = listSource
|
|
self.cachedListSources[id] = listSource
|
|
}
|
|
} else {
|
|
self.listSource = self.defaultListSource
|
|
}
|
|
|
|
self.requestHistoryAroundVisiblePosition(synchronous: false, reloadAtTop: true)
|
|
}
|
|
|
|
public func beginReordering() {
|
|
if let parentController = self.parentController as? PeerInfoScreen {
|
|
parentController.togglePaneIsReordering(isReordering: true)
|
|
} else {
|
|
self.updateIsReordering(isReordering: true, animated: true)
|
|
}
|
|
}
|
|
|
|
public func endReordering() {
|
|
if let parentController = self.parentController as? PeerInfoScreen {
|
|
parentController.togglePaneIsReordering(isReordering: false)
|
|
} else {
|
|
self.updateIsReordering(isReordering: false, animated: true)
|
|
}
|
|
}
|
|
|
|
public func updateIsReordering(isReordering: Bool, animated: Bool) {
|
|
if self.isReordering != isReordering {
|
|
self.isReordering = isReordering
|
|
|
|
self.contextGestureContainerNode.isGestureEnabled = self.isProfileEmbedded && self.itemInteraction.selectedIds == nil && !self.isReordering
|
|
|
|
self.itemGrid.setReordering(isReordering: isReordering)
|
|
|
|
if !isReordering, let reorderedIds = self.reorderedIds {
|
|
self.reorderedIds = nil
|
|
if case .botPreview = self.scope, let listSource = self.listSource as? BotPreviewStoryListContext {
|
|
if let items = self.items {
|
|
var reorderedMedia: [Media] = []
|
|
|
|
for id in reorderedIds {
|
|
if let item = items.items.first(where: { ($0 as? VisualMediaItem)?.storyId == id }) as? VisualMediaItem {
|
|
reorderedMedia.append(item.story.media._asMedia())
|
|
}
|
|
}
|
|
|
|
listSource.reorderItems(media: reorderedMedia)
|
|
}
|
|
} else if case let .peer(id, _, _) = self.scope, id == self.context.account.peerId, let items = self.items {
|
|
var updatedPinnedIds: [Int32] = []
|
|
for id in reorderedIds {
|
|
inner: for item in items.items {
|
|
if let item = item as? VisualMediaItem {
|
|
if item.storyId == id {
|
|
if item.isPinned {
|
|
updatedPinnedIds.append(id.id)
|
|
break inner
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
let _ = self.context.engine.messages.updatePinnedToTopStories(peerId: id, ids: updatedPinnedIds).startStandalone()
|
|
}
|
|
}
|
|
|
|
self.update(transition: animated ? .animated(duration: 0.4, curve: .spring) : .immediate)
|
|
}
|
|
}
|
|
}
|
|
|
|
private class MediaListSelectionRecognizer: UIPanGestureRecognizer {
|
|
private let selectionGestureActivationThreshold: CGFloat = 5.0
|
|
|
|
var recognized: Bool? = nil
|
|
var initialLocation: CGPoint = CGPoint()
|
|
|
|
public var shouldBegin: (() -> Bool)?
|
|
|
|
public override init(target: Any?, action: Selector?) {
|
|
super.init(target: target, action: action)
|
|
|
|
self.minimumNumberOfTouches = 2
|
|
self.maximumNumberOfTouches = 2
|
|
}
|
|
|
|
public override func reset() {
|
|
super.reset()
|
|
|
|
self.recognized = nil
|
|
}
|
|
|
|
public override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
|
|
super.touchesBegan(touches, with: event)
|
|
|
|
if let shouldBegin = self.shouldBegin, !shouldBegin() {
|
|
self.state = .failed
|
|
} else {
|
|
let touch = touches.first!
|
|
self.initialLocation = touch.location(in: self.view)
|
|
}
|
|
}
|
|
|
|
public override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent) {
|
|
let location = touches.first!.location(in: self.view)
|
|
let translation = location.offsetBy(dx: -self.initialLocation.x, dy: -self.initialLocation.y)
|
|
|
|
let touchesArray = Array(touches)
|
|
if self.recognized == nil, touchesArray.count == 2 {
|
|
if let firstTouch = touchesArray.first, let secondTouch = touchesArray.last {
|
|
let firstLocation = firstTouch.location(in: self.view)
|
|
let secondLocation = secondTouch.location(in: self.view)
|
|
|
|
func distance(_ v1: CGPoint, _ v2: CGPoint) -> CGFloat {
|
|
let dx = v1.x - v2.x
|
|
let dy = v1.y - v2.y
|
|
return sqrt(dx * dx + dy * dy)
|
|
}
|
|
if distance(firstLocation, secondLocation) > 200.0 {
|
|
self.state = .failed
|
|
}
|
|
}
|
|
if self.state != .failed && (abs(translation.y) >= selectionGestureActivationThreshold) {
|
|
self.recognized = true
|
|
}
|
|
}
|
|
|
|
if let recognized = self.recognized, recognized {
|
|
super.touchesMoved(touches, with: event)
|
|
}
|
|
}
|
|
}
|
|
|
|
private final class TempExtractedItemNode: ASDisplayNode {
|
|
let contextSourceNode: ContextExtractedContentContainingNode
|
|
let item: EngineStoryItem
|
|
weak var itemLayer: ItemLayer?
|
|
let itemView: ItemTransitionView
|
|
|
|
init(item: EngineStoryItem, itemLayer: ItemLayer) {
|
|
self.item = item
|
|
self.contextSourceNode = ContextExtractedContentContainingNode()
|
|
self.itemView = ItemTransitionView(itemLayer: itemLayer)
|
|
self.itemLayer = itemLayer
|
|
|
|
super.init()
|
|
|
|
self.addSubnode(self.contextSourceNode)
|
|
|
|
self.contextSourceNode.contentNode.view.addSubview(self.itemView)
|
|
self.itemView.clipsToBounds = true
|
|
|
|
self.contextSourceNode.willUpdateIsExtractedToContextPreview = { [weak self] isExtracted, transition in
|
|
guard let self else {
|
|
return
|
|
}
|
|
|
|
transition.updateCornerRadius(layer: self.itemView.layer, cornerRadius: isExtracted ? 10.0 : 0.0)
|
|
|
|
if isExtracted {
|
|
transition.updateSublayerTransformScale(node: self.contextSourceNode.contentNode, scale: 1.0)
|
|
}
|
|
}
|
|
|
|
self.contextSourceNode.isExtractedToContextPreviewUpdated = { [weak self] isExtracted in
|
|
guard let self else {
|
|
return
|
|
}
|
|
|
|
self.itemLayer?.isHidden = isExtracted
|
|
|
|
if !isExtracted {
|
|
self.removeFromSupernode()
|
|
}
|
|
}
|
|
}
|
|
|
|
func update(size: CGSize) {
|
|
self.contextSourceNode.frame = CGRect(origin: CGPoint(), size: size)
|
|
self.contextSourceNode.contentNode.frame = CGRect(origin: CGPoint(), size: size)
|
|
self.contextSourceNode.contentRect = CGRect(origin: CGPoint(x: 2.0, y: 0.0), size: CGSize(width: size.width - 4.0, height: size.height))
|
|
|
|
self.itemView.frame = CGRect(origin: CGPoint(), size: size)
|
|
self.itemView.update(state: StoryContainerScreen.TransitionState(sourceSize: size, destinationSize: size, progress: 0.0), transition: .immediate)
|
|
}
|
|
}
|
|
|
|
private final class ExtractedContentSourceImpl: ContextExtractedContentSource {
|
|
var keepInPlace: Bool
|
|
let ignoreContentTouches: Bool = true
|
|
let blurBackground: Bool
|
|
let adjustContentForSideInset: Bool = true
|
|
|
|
private let controller: ViewController
|
|
private let sourceNode: ContextExtractedContentContainingNode
|
|
|
|
var actionsHorizontalAlignment: ContextActionsHorizontalAlignment {
|
|
return .center
|
|
}
|
|
|
|
init(controller: ViewController, sourceNode: ContextExtractedContentContainingNode, keepInPlace: Bool, blurBackground: Bool) {
|
|
self.controller = controller
|
|
self.sourceNode = sourceNode
|
|
self.keepInPlace = keepInPlace
|
|
self.blurBackground = blurBackground
|
|
}
|
|
|
|
func takeView() -> ContextControllerTakeViewInfo? {
|
|
return ContextControllerTakeViewInfo(containingItem: .node(self.sourceNode), contentAreaInScreenSpace: UIScreen.main.bounds)
|
|
}
|
|
|
|
func putBack() -> ContextControllerPutBackViewInfo? {
|
|
return ContextControllerPutBackViewInfo(contentAreaInScreenSpace: UIScreen.main.bounds)
|
|
}
|
|
}
|
|
|
|
private final class BottomActionsPanelComponent: Component {
|
|
public final class Item: Equatable {
|
|
public enum Color {
|
|
case accent
|
|
case destructive
|
|
}
|
|
|
|
public var id: AnyHashable
|
|
public var color: Color
|
|
public var title: String
|
|
public var isEnabled: Bool
|
|
public var action: () -> Void
|
|
|
|
public init(id: AnyHashable, color: Color, title: String, isEnabled: Bool, action: @escaping () -> Void) {
|
|
self.id = id
|
|
self.color = color
|
|
self.title = title
|
|
self.isEnabled = isEnabled
|
|
self.action = action
|
|
}
|
|
|
|
public static func ==(lhs: Item, rhs: Item) -> Bool {
|
|
if lhs === rhs {
|
|
return true
|
|
}
|
|
if lhs.id != rhs.id {
|
|
return false
|
|
}
|
|
if lhs.color != rhs.color {
|
|
return false
|
|
}
|
|
if lhs.title != rhs.title {
|
|
return false
|
|
}
|
|
if lhs.isEnabled != rhs.isEnabled {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
}
|
|
|
|
public let theme: PresentationTheme
|
|
public let insets: UIEdgeInsets
|
|
public let items: [Item]
|
|
|
|
public init(
|
|
theme: PresentationTheme,
|
|
insets: UIEdgeInsets,
|
|
items: [Item]
|
|
) {
|
|
self.theme = theme
|
|
self.insets = insets
|
|
self.items = items
|
|
}
|
|
|
|
public static func ==(lhs: BottomActionsPanelComponent, rhs: BottomActionsPanelComponent) -> Bool {
|
|
if lhs.theme !== rhs.theme {
|
|
return false
|
|
}
|
|
if lhs.insets != rhs.insets {
|
|
return false
|
|
}
|
|
if lhs.items != rhs.items {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
public final class View: UIView {
|
|
private let backgroundView: BlurredBackgroundView
|
|
private let separatorLayer: SimpleLayer
|
|
|
|
private var itemViews: [AnyHashable: ComponentView<Empty>] = [:]
|
|
|
|
private var component: BottomActionsPanelComponent?
|
|
|
|
public override init(frame: CGRect) {
|
|
self.backgroundView = BlurredBackgroundView(color: nil, enableBlur: true)
|
|
self.backgroundView.isUserInteractionEnabled = false
|
|
|
|
self.separatorLayer = SimpleLayer()
|
|
|
|
super.init(frame: frame)
|
|
|
|
self.addSubview(self.backgroundView)
|
|
self.layer.addSublayer(self.separatorLayer)
|
|
}
|
|
|
|
required public init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
func update(component: BottomActionsPanelComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
|
|
let themeUpdated = self.component?.theme !== component.theme
|
|
|
|
self.component = component
|
|
|
|
if themeUpdated {
|
|
self.backgroundView.updateColor(color: component.theme.rootController.navigationBar.blurredBackgroundColor, transition: .immediate)
|
|
self.separatorLayer.backgroundColor = component.theme.rootController.navigationBar.separatorColor.cgColor
|
|
}
|
|
|
|
let itemHeight: CGFloat = 54.0
|
|
let size = CGSize(width: availableSize.width, height: itemHeight + component.insets.bottom)
|
|
|
|
transition.setFrame(view: self.backgroundView, frame: CGRect(origin: CGPoint(), size: size))
|
|
self.backgroundView.update(size: size, transition: transition.containedViewLayoutTransition)
|
|
transition.setFrame(layer: self.separatorLayer, frame: CGRect(origin: CGPoint(x: 0.0, y: -UIScreenPixel), size: CGSize(width: size.width, height: UIScreenPixel)))
|
|
|
|
let sideInset = component.insets.left + 12.0
|
|
|
|
var validIds: [AnyHashable] = []
|
|
var itemsAndSizes: [(CGSize, ComponentView<Empty>)] = []
|
|
for item in component.items {
|
|
validIds.append(item.id)
|
|
|
|
let itemColor: UIColor
|
|
if item.isEnabled {
|
|
switch item.color {
|
|
case .accent:
|
|
itemColor = component.theme.list.itemAccentColor
|
|
case .destructive:
|
|
itemColor = component.theme.list.itemDestructiveColor
|
|
}
|
|
} else {
|
|
itemColor = component.theme.list.itemDisabledTextColor
|
|
}
|
|
|
|
let itemView: ComponentView<Empty>
|
|
if let current = self.itemViews[item.id] {
|
|
itemView = current
|
|
} else {
|
|
itemView = ComponentView()
|
|
self.itemViews[item.id] = itemView
|
|
}
|
|
let itemSize = itemView.update(
|
|
transition: .immediate,
|
|
component: AnyComponent(PlainButtonComponent(
|
|
content: AnyComponent(Text(text: item.title, font: Font.regular(17.0), color: itemColor)),
|
|
effectAlignment: .center,
|
|
minSize: CGSize(width: 16.0, height: itemHeight),
|
|
contentInsets: UIEdgeInsets(top: 0.0, left: 8.0, bottom: 0.0, right: 8.0),
|
|
action: {
|
|
item.action()
|
|
},
|
|
isEnabled: item.isEnabled,
|
|
animateAlpha: true,
|
|
animateScale: false,
|
|
animateContents: false
|
|
)),
|
|
environment: {},
|
|
containerSize: CGSize(width: availableSize.width, height: itemHeight)
|
|
)
|
|
itemsAndSizes.append((itemSize, itemView))
|
|
}
|
|
var removedIds: [AnyHashable] = []
|
|
for (id, itemView) in self.itemViews {
|
|
if !validIds.contains(id) {
|
|
removedIds.append(id)
|
|
itemView.view?.removeFromSuperview()
|
|
}
|
|
}
|
|
for id in removedIds {
|
|
self.itemViews.removeValue(forKey: id)
|
|
}
|
|
|
|
for i in 0 ..< itemsAndSizes.count {
|
|
let (itemSize, itemView) = itemsAndSizes[i]
|
|
|
|
let itemCenterX: CGFloat = CGFloat(i) * (floor((availableSize.width - sideInset * 2.0) / CGFloat(itemsAndSizes.count - 1)))
|
|
let itemX: CGFloat
|
|
if itemsAndSizes.count == 1 {
|
|
itemX = floor((availableSize.width - itemSize.width) * 0.5)
|
|
} else if i == 0 {
|
|
itemX = sideInset
|
|
} else if i == itemsAndSizes.count - 1 {
|
|
itemX = availableSize.width - sideInset - itemSize.width
|
|
} else {
|
|
itemX = sideInset + floor(itemCenterX - itemSize.width * 0.5)
|
|
}
|
|
|
|
let itemFrame = CGRect(origin: CGPoint(x: itemX, y: 0.0), size: itemSize)
|
|
|
|
if let itemComponenView = itemView.view {
|
|
if itemComponenView.superview == nil {
|
|
self.addSubview(itemComponenView)
|
|
}
|
|
itemComponenView.frame = itemFrame
|
|
}
|
|
}
|
|
|
|
return size
|
|
}
|
|
}
|
|
|
|
public func makeView() -> View {
|
|
return View(frame: CGRect())
|
|
}
|
|
|
|
public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
|
|
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
|
|
}
|
|
}
|
|
|