mirror of
https://github.com/Swiftgram/Telegram-iOS.git
synced 2025-06-16 05:55:20 +00:00
1835 lines
100 KiB
Swift
1835 lines
100 KiB
Swift
import Foundation
|
|
import UIKit
|
|
import AsyncDisplayKit
|
|
import Display
|
|
import Postbox
|
|
import TelegramCore
|
|
import SwiftSignalKit
|
|
import TelegramPresentationData
|
|
import ItemListUI
|
|
import PresentationDataUtils
|
|
import AccountContext
|
|
import TelegramStringFormatting
|
|
import AccountContext
|
|
import RadialStatusNode
|
|
import SemanticStatusNode
|
|
import PhotoResources
|
|
import MusicAlbumArtResources
|
|
import UniversalMediaPlayer
|
|
import ContextUI
|
|
import FileMediaResourceStatus
|
|
import ManagedAnimationNode
|
|
import ShimmerEffect
|
|
import ComponentFlow
|
|
import EmojiStatusComponent
|
|
|
|
private let extensionImageCache = Atomic<[UInt32: UIImage]>(value: [:])
|
|
|
|
private let redColors: (UInt32, UInt32) = (0xed6b7b, 0xe63f45)
|
|
private let greenColors: (UInt32, UInt32) = (0x99de6f, 0x5fb84f)
|
|
private let blueColors: (UInt32, UInt32) = (0x72d5fd, 0x2a9ef1)
|
|
private let yellowColors: (UInt32, UInt32) = (0xffa24b, 0xed705c)
|
|
|
|
private let extensionColorsMap: [String: (UInt32, UInt32)] = [
|
|
"ppt": redColors,
|
|
"pptx": redColors,
|
|
"pdf": redColors,
|
|
"key": redColors,
|
|
|
|
"xls": greenColors,
|
|
"xlsx": greenColors,
|
|
"csv": greenColors,
|
|
|
|
"zip": yellowColors,
|
|
"rar": yellowColors,
|
|
"gzip": yellowColors,
|
|
"ai": yellowColors
|
|
]
|
|
|
|
private func generateExtensionImage(colors: (UInt32, UInt32)) -> UIImage? {
|
|
return generateImage(CGSize(width: 40.0, height: 40.0), rotatedContext: { size, context in
|
|
context.clear(CGRect(origin: CGPoint(), size: size))
|
|
|
|
context.saveGState()
|
|
context.beginPath()
|
|
let _ = try? drawSvgPath(context, path: "M6,0 L26.7573593,0 C27.5530088,-8.52837125e-16 28.3160705,0.316070521 28.8786797,0.878679656 L39.1213203,11.1213203 C39.6839295,11.6839295 40,12.4469912 40,13.2426407 L40,34 C40,37.3137085 37.3137085,40 34,40 L6,40 C2.6862915,40 4.05812251e-16,37.3137085 0,34 L0,6 C-4.05812251e-16,2.6862915 2.6862915,6.08718376e-16 6,0 ")
|
|
context.clip()
|
|
|
|
let gradientColors = [UIColor(rgb: colors.0).cgColor, UIColor(rgb: colors.1).cgColor] as CFArray
|
|
var locations: [CGFloat] = [0.0, 1.0]
|
|
let colorSpace = CGColorSpaceCreateDeviceRGB()
|
|
let gradient = CGGradient(colorsSpace: colorSpace, colors: gradientColors, locations: &locations)!
|
|
context.drawLinearGradient(gradient, start: CGPoint(x: 0.0, y: 0.0), end: CGPoint(x: 0.0, y: size.height), options: CGGradientDrawingOptions())
|
|
|
|
context.restoreGState()
|
|
|
|
context.beginPath()
|
|
let _ = try? drawSvgPath(context, path: "M6,0 L26.7573593,0 C27.5530088,-8.52837125e-16 28.3160705,0.316070521 28.8786797,0.878679656 L39.1213203,11.1213203 C39.6839295,11.6839295 40,12.4469912 40,13.2426407 L40,34 C40,37.3137085 37.3137085,40 34,40 L6,40 C2.6862915,40 4.05812251e-16,37.3137085 0,34 L0,6 C-4.05812251e-16,2.6862915 2.6862915,6.08718376e-16 6,0 ")
|
|
context.clip()
|
|
|
|
context.setFillColor(UIColor(rgb: 0xffffff, alpha: 0.2).cgColor)
|
|
context.translateBy(x: 40.0 - 14.0, y: 0.0)
|
|
let _ = try? drawSvgPath(context, path: "M-1,0 L14,0 L14,15 L14,14 C14,12.8954305 13.1045695,12 12,12 L4,12 C2.8954305,12 2,11.1045695 2,10 L2,2 C2,0.8954305 1.1045695,-2.02906125e-16 0,0 L-1,0 L-1,0 Z ")
|
|
})
|
|
}
|
|
|
|
private func extensionImage(fileExtension: String?) -> UIImage? {
|
|
let colors: (UInt32, UInt32)
|
|
if let fileExtension = fileExtension {
|
|
if let extensionColors = extensionColorsMap[fileExtension] {
|
|
colors = extensionColors
|
|
} else {
|
|
colors = blueColors
|
|
}
|
|
} else {
|
|
colors = blueColors
|
|
}
|
|
|
|
if let cachedImage = (extensionImageCache.with { dict in
|
|
return dict[colors.0]
|
|
}) {
|
|
return cachedImage
|
|
} else if let image = generateExtensionImage(colors: colors) {
|
|
let _ = extensionImageCache.modify { dict in
|
|
var dict = dict
|
|
dict[colors.0] = image
|
|
return dict
|
|
}
|
|
return image
|
|
} else {
|
|
return nil
|
|
}
|
|
}
|
|
private let extensionFont = Font.with(size: 15.0, design: .round, weight: .bold)
|
|
private let mediumExtensionFont = Font.with(size: 14.0, design: .round, weight: .bold)
|
|
private let smallExtensionFont = Font.with(size: 12.0, design: .round, weight: .bold)
|
|
|
|
private struct FetchControls {
|
|
let fetch: () -> Void
|
|
let cancel: () -> Void
|
|
}
|
|
|
|
private enum FileIconImage: Equatable {
|
|
case imageRepresentation(Media, TelegramMediaImageRepresentation)
|
|
case albumArt(TelegramMediaFile, SharedMediaPlaybackAlbumArt)
|
|
case roundVideo(TelegramMediaFile)
|
|
|
|
static func ==(lhs: FileIconImage, rhs: FileIconImage) -> Bool {
|
|
switch lhs {
|
|
case let .imageRepresentation(lhsMedia, lhsValue):
|
|
if case let .imageRepresentation(rhsMedia, rhsValue) = rhs, lhsMedia.isEqual(to: rhsMedia), lhsValue == rhsValue {
|
|
return true
|
|
} else {
|
|
return false
|
|
}
|
|
case let .albumArt(file, value):
|
|
if case .albumArt(file, value) = rhs {
|
|
return true
|
|
} else {
|
|
return false
|
|
}
|
|
case let .roundVideo(file):
|
|
if case .roundVideo(file) = rhs {
|
|
return true
|
|
} else {
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
final class CachedChatListSearchResult {
|
|
let text: String
|
|
let searchQuery: String
|
|
let resultRanges: [Range<String.Index>]
|
|
|
|
init(text: String, searchQuery: String, resultRanges: [Range<String.Index>]) {
|
|
self.text = text
|
|
self.searchQuery = searchQuery
|
|
self.resultRanges = resultRanges
|
|
}
|
|
|
|
func matches(text: String, searchQuery: String) -> Bool {
|
|
if self.text != text {
|
|
return false
|
|
}
|
|
if self.searchQuery != searchQuery {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
}
|
|
|
|
public final class ListMessageFileItemNode: ListMessageNode {
|
|
public final class DescriptionNode: ASDisplayNode {
|
|
let descriptionNode: TextNode
|
|
var titleTopicArrowNode: ASImageNode?
|
|
var topicTitleNode: TextNode?
|
|
var titleTopicIconView: ComponentHostView<Empty>?
|
|
var titleTopicIconComponent: EmojiStatusComponent?
|
|
|
|
var visibilityStatus: Bool = false {
|
|
didSet {
|
|
if self.visibilityStatus != oldValue {
|
|
if let titleTopicIconView = self.titleTopicIconView, let titleTopicIconComponent = self.titleTopicIconComponent {
|
|
let _ = titleTopicIconView.update(
|
|
transition: .immediate,
|
|
component: AnyComponent(titleTopicIconComponent.withVisibleForAnimations(self.visibilityStatus)),
|
|
environment: {},
|
|
containerSize: titleTopicIconView.bounds.size
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
override init() {
|
|
self.descriptionNode = TextNode()
|
|
self.descriptionNode.displaysAsynchronously = true
|
|
|
|
super.init()
|
|
|
|
self.addSubnode(self.descriptionNode)
|
|
}
|
|
|
|
func asyncLayout() -> (_ context: AccountContext, _ constrainedWidth: CGFloat, _ theme: PresentationTheme, _ authorTitle: NSAttributedString?, _ topic: (title: NSAttributedString, showIcon: Bool, iconId: Int64?, iconColor: Int32)?) -> (CGSize, () -> Void) {
|
|
let makeDescriptionLayout = TextNode.asyncLayout(self.descriptionNode)
|
|
let makeTopicTitleLayout = TextNode.asyncLayout(self.topicTitleNode)
|
|
|
|
return { [weak self] context, constrainedWidth, theme, authorTitle, topic in
|
|
var maxTitleWidth = constrainedWidth
|
|
if let _ = topic {
|
|
maxTitleWidth = floor(constrainedWidth * 0.7)
|
|
}
|
|
|
|
let descriptionLayout = makeDescriptionLayout(TextNodeLayoutArguments(attributedString: authorTitle, backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .middle, constrainedSize: CGSize(width: maxTitleWidth, height: CGFloat.greatestFiniteMagnitude), alignment: .natural, cutout: nil, insets: UIEdgeInsets(top: 2.0, left: 1.0, bottom: 2.0, right: 1.0)))
|
|
|
|
var remainingWidth = constrainedWidth - descriptionLayout.0.size.width
|
|
|
|
var topicTitleArguments: TextNodeLayoutArguments?
|
|
var arrowIconImage: UIImage?
|
|
if let topic = topic {
|
|
remainingWidth -= 22.0 + 2.0
|
|
|
|
if authorTitle != nil {
|
|
arrowIconImage = PresentationResourcesItemList.topicArrowDescriptionIcon(theme)
|
|
if let arrowIconImage = arrowIconImage {
|
|
remainingWidth -= arrowIconImage.size.width + 6.0 * 2.0
|
|
}
|
|
}
|
|
|
|
topicTitleArguments = TextNodeLayoutArguments(attributedString: topic.title, backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: remainingWidth, height: CGFloat.greatestFiniteMagnitude), alignment: .natural, cutout: nil, insets: UIEdgeInsets(top: 2.0, left: 1.0, bottom: 2.0, right: 1.0))
|
|
}
|
|
|
|
let topicTitleLayout = topicTitleArguments.flatMap(makeTopicTitleLayout)
|
|
|
|
var size = descriptionLayout.0.size
|
|
if let topicTitleLayout = topicTitleLayout {
|
|
size.height = max(size.height, topicTitleLayout.0.size.height)
|
|
size.width += 10.0 + topicTitleLayout.0.size.width
|
|
}
|
|
|
|
return (size, {
|
|
guard let self else {
|
|
return
|
|
}
|
|
|
|
let _ = descriptionLayout.1()
|
|
let authorFrame = CGRect(origin: CGPoint(), size: descriptionLayout.0.size)
|
|
self.descriptionNode.frame = authorFrame
|
|
|
|
var nextX = authorFrame.maxX - 1.0
|
|
if authorTitle == nil {
|
|
nextX = 0.0
|
|
}
|
|
|
|
if let arrowIconImage = arrowIconImage {
|
|
let titleTopicArrowNode: ASImageNode
|
|
if let current = self.titleTopicArrowNode {
|
|
titleTopicArrowNode = current
|
|
} else {
|
|
titleTopicArrowNode = ASImageNode()
|
|
self.titleTopicArrowNode = titleTopicArrowNode
|
|
self.addSubnode(titleTopicArrowNode)
|
|
}
|
|
titleTopicArrowNode.image = arrowIconImage
|
|
nextX += 6.0
|
|
titleTopicArrowNode.frame = CGRect(origin: CGPoint(x: nextX, y: 5.0), size: arrowIconImage.size)
|
|
nextX += arrowIconImage.size.width + 6.0
|
|
} else {
|
|
if let titleTopicArrowNode = self.titleTopicArrowNode {
|
|
self.titleTopicArrowNode = nil
|
|
titleTopicArrowNode.removeFromSupernode()
|
|
}
|
|
}
|
|
|
|
if let topic, topic.showIcon {
|
|
let titleTopicIconView: ComponentHostView<Empty>
|
|
if let current = self.titleTopicIconView {
|
|
titleTopicIconView = current
|
|
} else {
|
|
titleTopicIconView = ComponentHostView<Empty>()
|
|
self.titleTopicIconView = titleTopicIconView
|
|
self.view.addSubview(titleTopicIconView)
|
|
}
|
|
|
|
let titleTopicIconContent: EmojiStatusComponent.Content
|
|
if let fileId = topic.iconId, fileId != 0 {
|
|
titleTopicIconContent = .animation(content: .customEmoji(fileId: fileId), size: CGSize(width: 36.0, height: 36.0), placeholderColor: theme.list.mediaPlaceholderColor, themeColor: theme.list.itemAccentColor, loopMode: .count(2))
|
|
} else {
|
|
titleTopicIconContent = .topic(title: String(topic.title.string.prefix(1)), color: topic.iconColor, size: CGSize(width: 22.0, height: 22.0))
|
|
}
|
|
|
|
let titleTopicIconComponent = EmojiStatusComponent(
|
|
context: context,
|
|
animationCache: context.animationCache,
|
|
animationRenderer: context.animationRenderer,
|
|
content: titleTopicIconContent,
|
|
isVisibleForAnimations: self.visibilityStatus,
|
|
action: nil
|
|
)
|
|
self.titleTopicIconComponent = titleTopicIconComponent
|
|
|
|
let iconSize = titleTopicIconView.update(
|
|
transition: .immediate,
|
|
component: AnyComponent(titleTopicIconComponent),
|
|
environment: {},
|
|
containerSize: CGSize(width: 22.0, height: 22.0)
|
|
)
|
|
titleTopicIconView.frame = CGRect(origin: CGPoint(x: nextX, y: UIScreenPixel), size: iconSize)
|
|
nextX += iconSize.width + 2.0
|
|
} else {
|
|
if let titleTopicIconView = self.titleTopicIconView {
|
|
self.titleTopicIconView = nil
|
|
titleTopicIconView.removeFromSuperview()
|
|
}
|
|
}
|
|
|
|
if let topicTitleLayout = topicTitleLayout {
|
|
let topicTitleNode = topicTitleLayout.1()
|
|
if topicTitleNode.supernode == nil {
|
|
self.addSubnode(topicTitleNode)
|
|
self.topicTitleNode = topicTitleNode
|
|
}
|
|
|
|
topicTitleNode.frame = CGRect(origin: CGPoint(x: nextX - 1.0, y: 0.0), size: topicTitleLayout.0.size)
|
|
} else if let topicTitleNode = self.topicTitleNode {
|
|
self.topicTitleNode = nil
|
|
topicTitleNode.removeFromSupernode()
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
private let contextSourceNode: ContextExtractedContentContainingNode
|
|
private let containerNode: ContextControllerSourceNode
|
|
private let extractedBackgroundImageNode: ASImageNode
|
|
|
|
private var extractedRect: CGRect?
|
|
private var nonExtractedRect: CGRect?
|
|
|
|
private let offsetContainerNode: ASDisplayNode
|
|
|
|
private var backgroundNode: ASDisplayNode?
|
|
private let highlightedBackgroundNode: ASDisplayNode
|
|
public let separatorNode: ASDisplayNode
|
|
private let maskNode: ASImageNode
|
|
|
|
private var selectionNode: ItemListSelectableControlNode?
|
|
|
|
public let titleNode: DescriptionNode
|
|
public let textNode: TextNode
|
|
public let descriptionNode: DescriptionNode
|
|
private let descriptionProgressNode: ImmediateTextNode
|
|
public let dateNode: TextNode
|
|
|
|
public let extensionIconNode: ASImageNode
|
|
private let extensionIconText: TextNode
|
|
public let iconImageNode: TransformImageNode
|
|
private let iconStatusNode: SemanticStatusNode
|
|
|
|
private let restrictionNode: ASDisplayNode
|
|
|
|
private var currentIconImage: FileIconImage?
|
|
public var currentMedia: Media?
|
|
|
|
private let statusDisposable = MetaDisposable()
|
|
private let fetchControls = Atomic<FetchControls?>(value: nil)
|
|
private var fetchStatus: MediaResourceStatus?
|
|
private var resourceStatus: FileMediaResourceMediaStatus?
|
|
private let fetchDisposable = MetaDisposable()
|
|
private let playbackStatusDisposable = MetaDisposable()
|
|
private let playbackStatus = Promise<MediaPlayerStatus>()
|
|
|
|
private var downloadStatusIconNode: DownloadIconNode?
|
|
private var linearProgressNode: LinearProgressNode?
|
|
|
|
private var placeholderNode: ShimmerEffectNode?
|
|
private var absoluteLocation: (CGRect, CGSize)?
|
|
|
|
private var context: AccountContext?
|
|
private (set) var message: Message?
|
|
|
|
private var appliedItem: ListMessageItem?
|
|
private var layoutParams: ListViewItemLayoutParams?
|
|
private var contentSizeValue: CGSize?
|
|
private var currentLeftOffset: CGFloat = 0.0
|
|
|
|
private var currentIsRestricted = false
|
|
private var cachedSearchResult: CachedChatListSearchResult?
|
|
|
|
public override var visibility: ListViewItemNodeVisibility {
|
|
didSet {
|
|
let wasVisible = self.visibilityStatus
|
|
let isVisible: Bool
|
|
switch self.visibility {
|
|
case let .visible(fraction, _):
|
|
isVisible = fraction > 0.2
|
|
case .none:
|
|
isVisible = false
|
|
}
|
|
if wasVisible != isVisible {
|
|
self.visibilityStatus = isVisible
|
|
}
|
|
}
|
|
}
|
|
|
|
private var visibilityStatus: Bool = false {
|
|
didSet {
|
|
if self.visibilityStatus != oldValue {
|
|
self.descriptionNode.visibilityStatus = self.visibilityStatus
|
|
}
|
|
}
|
|
}
|
|
|
|
public required init() {
|
|
self.contextSourceNode = ContextExtractedContentContainingNode()
|
|
self.containerNode = ContextControllerSourceNode()
|
|
|
|
self.separatorNode = ASDisplayNode()
|
|
self.separatorNode.displaysAsynchronously = false
|
|
self.separatorNode.isLayerBacked = true
|
|
|
|
self.maskNode = ASImageNode()
|
|
self.maskNode.isUserInteractionEnabled = false
|
|
|
|
self.extractedBackgroundImageNode = ASImageNode()
|
|
self.extractedBackgroundImageNode.displaysAsynchronously = false
|
|
self.extractedBackgroundImageNode.alpha = 0.0
|
|
|
|
self.offsetContainerNode = ASDisplayNode()
|
|
|
|
self.highlightedBackgroundNode = ASDisplayNode()
|
|
self.highlightedBackgroundNode.isLayerBacked = true
|
|
|
|
self.titleNode = DescriptionNode()
|
|
self.titleNode.displaysAsynchronously = false
|
|
self.titleNode.isUserInteractionEnabled = false
|
|
|
|
self.textNode = TextNode()
|
|
self.textNode.displaysAsynchronously = false
|
|
self.textNode.isUserInteractionEnabled = false
|
|
|
|
self.descriptionNode = DescriptionNode()
|
|
self.descriptionNode.displaysAsynchronously = false
|
|
self.descriptionNode.isUserInteractionEnabled = false
|
|
|
|
self.descriptionProgressNode = ImmediateTextNode()
|
|
self.descriptionProgressNode.displaysAsynchronously = false
|
|
self.descriptionProgressNode.isUserInteractionEnabled = false
|
|
self.descriptionProgressNode.maximumNumberOfLines = 1
|
|
|
|
self.dateNode = TextNode()
|
|
self.dateNode.isUserInteractionEnabled = false
|
|
|
|
self.extensionIconNode = ASImageNode()
|
|
self.extensionIconNode.isLayerBacked = true
|
|
self.extensionIconNode.displaysAsynchronously = false
|
|
self.extensionIconNode.displayWithoutProcessing = true
|
|
|
|
self.extensionIconText = TextNode()
|
|
self.extensionIconText.displaysAsynchronously = false
|
|
self.extensionIconText.isUserInteractionEnabled = false
|
|
|
|
self.iconImageNode = TransformImageNode()
|
|
self.iconImageNode.displaysAsynchronously = false
|
|
self.iconImageNode.contentAnimations = .subsequentUpdates
|
|
|
|
self.iconStatusNode = SemanticStatusNode(backgroundNodeColor: .clear, foregroundNodeColor: .white)
|
|
self.iconStatusNode.isUserInteractionEnabled = false
|
|
|
|
self.restrictionNode = ASDisplayNode()
|
|
self.restrictionNode.isHidden = true
|
|
|
|
super.init()
|
|
|
|
self.containerNode.addSubnode(self.contextSourceNode)
|
|
self.containerNode.targetNodeForActivationProgress = self.contextSourceNode.contentNode
|
|
self.addSubnode(self.containerNode)
|
|
|
|
self.contextSourceNode.contentNode.addSubnode(self.extractedBackgroundImageNode)
|
|
self.contextSourceNode.contentNode.addSubnode(self.offsetContainerNode)
|
|
self.offsetContainerNode.addSubnode(self.titleNode)
|
|
self.offsetContainerNode.addSubnode(self.textNode)
|
|
self.offsetContainerNode.addSubnode(self.descriptionNode)
|
|
self.offsetContainerNode.addSubnode(self.descriptionProgressNode)
|
|
self.offsetContainerNode.addSubnode(self.dateNode)
|
|
self.offsetContainerNode.addSubnode(self.extensionIconNode)
|
|
self.offsetContainerNode.addSubnode(self.extensionIconText)
|
|
self.offsetContainerNode.addSubnode(self.iconStatusNode)
|
|
|
|
self.addSubnode(self.restrictionNode)
|
|
self.addSubnode(self.separatorNode)
|
|
|
|
self.containerNode.activated = { [weak self] gesture, _ in
|
|
guard let strongSelf = self, let item = strongSelf.item, let message = item.message else {
|
|
return
|
|
}
|
|
|
|
cancelParentGestures(view: strongSelf.view)
|
|
|
|
item.interaction.openMessageContextMenu(message, false, strongSelf.contextSourceNode, strongSelf.contextSourceNode.bounds, gesture)
|
|
}
|
|
|
|
self.contextSourceNode.willUpdateIsExtractedToContextPreview = { [weak self] isExtracted, transition in
|
|
guard let strongSelf = self, let item = strongSelf.item else {
|
|
return
|
|
}
|
|
|
|
if isExtracted {
|
|
strongSelf.extractedBackgroundImageNode.image = generateStretchableFilledCircleImage(diameter: 28.0, color: item.presentationData.theme.theme.list.plainBackgroundColor)
|
|
}
|
|
|
|
if let extractedRect = strongSelf.extractedRect, let nonExtractedRect = strongSelf.nonExtractedRect {
|
|
let rect = isExtracted ? extractedRect : nonExtractedRect
|
|
transition.updateFrame(node: strongSelf.extractedBackgroundImageNode, frame: rect)
|
|
}
|
|
|
|
transition.updateSublayerTransformOffset(layer: strongSelf.offsetContainerNode.layer, offset: CGPoint(x: isExtracted ? 12.0 : 0.0, y: 0.0))
|
|
transition.updateAlpha(node: strongSelf.extractedBackgroundImageNode, alpha: isExtracted ? 1.0 : 0.0, completion: { _ in
|
|
if !isExtracted {
|
|
self?.extractedBackgroundImageNode.image = nil
|
|
}
|
|
})
|
|
transition.updateAlpha(node: strongSelf.dateNode, alpha: isExtracted ? 0.0 : 1.0)
|
|
}
|
|
}
|
|
|
|
deinit {
|
|
self.statusDisposable.dispose()
|
|
self.fetchDisposable.dispose()
|
|
}
|
|
|
|
required public init?(coder aDecoder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
override func setupItem(_ item: ListMessageItem) {
|
|
self.item = item
|
|
}
|
|
|
|
override public func layoutForParams(_ params: ListViewItemLayoutParams, item: ListViewItem, previousItem: ListViewItem?, nextItem: ListViewItem?) {
|
|
if let item = item as? ListMessageItem {
|
|
let doLayout = self.asyncLayout()
|
|
let merged = (top: false, bottom: false, dateAtBottom: item.getDateAtBottom(top: previousItem, bottom: nextItem))
|
|
let (layout, apply) = doLayout(item, params, merged.top, merged.bottom, merged.dateAtBottom)
|
|
self.contentSize = layout.contentSize
|
|
self.insets = layout.insets
|
|
apply(.None)
|
|
}
|
|
}
|
|
|
|
override public func animateInsertion(_ currentTimestamp: Double, duration: Double, short: Bool) {
|
|
super.animateInsertion(currentTimestamp, duration: duration, short: short)
|
|
|
|
self.transitionOffset = self.bounds.size.height * 1.6
|
|
self.addTransitionOffsetAnimation(0.0, duration: duration, beginAt: currentTimestamp)
|
|
}
|
|
|
|
override public func animateRemoved(_ currentTimestamp: Double, duration: Double) {
|
|
self.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false)
|
|
}
|
|
|
|
override public func updateAbsoluteRect(_ rect: CGRect, within containerSize: CGSize) {
|
|
var rect = rect
|
|
rect.origin.y += self.insets.top
|
|
self.absoluteLocation = (rect, containerSize)
|
|
if let shimmerNode = self.placeholderNode {
|
|
shimmerNode.updateAbsoluteRect(rect, within: containerSize)
|
|
}
|
|
}
|
|
|
|
override public func asyncLayout() -> (_ item: ListMessageItem, _ params: ListViewItemLayoutParams, _ mergedTop: Bool, _ mergedBottom: Bool, _ dateHeaderAtBottom: Bool) -> (ListViewItemNodeLayout, (ListViewItemUpdateAnimation) -> Void) {
|
|
let titleNodeMakeLayout = self.titleNode.asyncLayout()
|
|
let textNodeMakeLayout = TextNode.asyncLayout(self.textNode)
|
|
let descriptionNodeMakeLayout = self.descriptionNode.asyncLayout()
|
|
let extensionIconTextMakeLayout = TextNode.asyncLayout(self.extensionIconText)
|
|
let dateNodeMakeLayout = TextNode.asyncLayout(self.dateNode)
|
|
let iconImageLayout = self.iconImageNode.asyncLayout()
|
|
|
|
let currentMedia = self.currentMedia
|
|
let currentMessage = self.message
|
|
let currentIconImage = self.currentIconImage
|
|
let currentSearchResult = self.cachedSearchResult
|
|
|
|
let currentItem = self.appliedItem
|
|
|
|
let selectionNodeLayout = ItemListSelectableControlNode.asyncLayout(self.selectionNode)
|
|
|
|
return { [weak self] item, params, mergedTop, mergedBottom, dateHeaderAtBottom in
|
|
var updatedTheme: PresentationTheme?
|
|
|
|
if currentItem?.presentationData.theme.theme !== item.presentationData.theme.theme {
|
|
updatedTheme = item.presentationData.theme.theme
|
|
}
|
|
|
|
let titleFont = Font.semibold(floor(item.presentationData.fontSize.baseDisplaySize * 16.0 / 17.0))
|
|
let audioTitleFont = Font.semibold(floor(item.presentationData.fontSize.baseDisplaySize * 16.0 / 17.0))
|
|
let descriptionFont = Font.regular(floor(item.presentationData.fontSize.baseDisplaySize * 14.0 / 17.0))
|
|
let dateFont = Font.regular(floor(item.presentationData.fontSize.itemListBaseFontSize * 14.0 / 17.0))
|
|
|
|
let leftInset: CGFloat = 65.0 + params.leftInset
|
|
let rightInset: CGFloat = 8.0 + params.rightInset
|
|
|
|
var leftOffset: CGFloat = 0.0
|
|
var selectionNodeWidthAndApply: (CGFloat, (CGSize, Bool) -> ItemListSelectableControlNode)?
|
|
if case let .selectable(selected) = item.selection {
|
|
let (selectionWidth, selectionApply) = selectionNodeLayout(item.presentationData.theme.theme.list.itemCheckColors.strokeColor, item.presentationData.theme.theme.list.itemCheckColors.fillColor, item.presentationData.theme.theme.list.itemCheckColors.foregroundColor, selected, false)
|
|
selectionNodeWidthAndApply = (selectionWidth, selectionApply)
|
|
leftOffset += selectionWidth
|
|
}
|
|
|
|
var extensionIconImage: UIImage?
|
|
var titleText: NSAttributedString?
|
|
var descriptionText: NSAttributedString?
|
|
var extensionText: NSAttributedString?
|
|
|
|
var iconImage: FileIconImage?
|
|
var updateIconImageSignal: Signal<(TransformImageArguments) -> DrawingContext?, NoError>?
|
|
var updatedStatusSignal: Signal<FileMediaResourceStatus, NoError>?
|
|
var updatedPlaybackStatusSignal: Signal<MediaPlayerStatus, NoError>?
|
|
var updatedFetchControls: FetchControls?
|
|
|
|
var isAudio = false
|
|
var isVoice = false
|
|
var isInstantVideo = false
|
|
|
|
var isRestricted = false
|
|
|
|
let message = item.message
|
|
|
|
var titleExtraData: (title: NSAttributedString, showIcon: Bool, iconId: Int64?, iconColor: Int32)? = nil
|
|
var descriptionExtraData: (title: NSAttributedString, showIcon: Bool, iconId: Int64?, iconColor: Int32)? = nil
|
|
var globalAuthorTitle: String?
|
|
|
|
var selectedMedia: Media?
|
|
if let message = message {
|
|
for media in message.media {
|
|
if let file = media as? TelegramMediaFile {
|
|
selectedMedia = file
|
|
|
|
isInstantVideo = file.isInstantVideo
|
|
|
|
for attribute in file.attributes {
|
|
if case let .Audio(voice, duration, title, performer, _) = attribute {
|
|
isAudio = true
|
|
isVoice = voice
|
|
|
|
titleText = NSAttributedString(string: title ?? (file.fileName ?? "Unknown Track"), font: audioTitleFont, textColor: item.presentationData.theme.theme.list.itemPrimaryTextColor)
|
|
|
|
var descriptionString: String
|
|
if let performer = performer {
|
|
if item.isGlobalSearchResult || item.isDownloadList {
|
|
descriptionString = performer
|
|
} else {
|
|
descriptionString = "\(stringForDuration(Int32(duration))) • \(performer)"
|
|
}
|
|
} else if let size = file.size {
|
|
descriptionString = dataSizeString(size, formatting: DataSizeStringFormatting(chatPresentationData: item.presentationData))
|
|
} else {
|
|
descriptionString = ""
|
|
}
|
|
|
|
if item.isGlobalSearchResult || item.isDownloadList {
|
|
let authorString = stringForFullAuthorName(message: EngineMessage(message), strings: item.presentationData.strings, nameDisplayOrder: item.presentationData.nameDisplayOrder, accountPeerId: item.context.account.peerId)
|
|
if authorString.count > 1 {
|
|
globalAuthorTitle = authorString.last ?? ""
|
|
}
|
|
if descriptionString.isEmpty {
|
|
descriptionString = authorString.first ?? ""
|
|
} else {
|
|
descriptionString = "\(descriptionString) • \(authorString.first ?? "")"
|
|
}
|
|
}
|
|
|
|
descriptionText = NSAttributedString(string: descriptionString, font: descriptionFont, textColor: item.presentationData.theme.theme.list.itemSecondaryTextColor)
|
|
|
|
if !voice {
|
|
if file.fileName?.lowercased().hasSuffix(".ogg") == true {
|
|
iconImage = .albumArt(file, SharedMediaPlaybackAlbumArt(thumbnailResource: ExternalMusicAlbumArtResource(file: .message(message: MessageReference(message), media: file), title: "", performer: "", isThumbnail: true), fullSizeResource: ExternalMusicAlbumArtResource(file: .message(message: MessageReference(message), media: file), title: "", performer: "", isThumbnail: false)))
|
|
} else {
|
|
iconImage = .albumArt(file, SharedMediaPlaybackAlbumArt(thumbnailResource: ExternalMusicAlbumArtResource(file: .message(message: MessageReference(message), media: file), title: title ?? "", performer: performer ?? "", isThumbnail: true), fullSizeResource: ExternalMusicAlbumArtResource(file: .message(message: MessageReference(message), media: file), title: title ?? "", performer: performer ?? "", isThumbnail: false)))
|
|
}
|
|
} else {
|
|
titleText = NSAttributedString(string: " ", font: audioTitleFont, textColor: item.presentationData.theme.theme.list.itemPrimaryTextColor)
|
|
descriptionText = NSAttributedString(string: message.author.flatMap(EnginePeer.init)?.displayTitle(strings: item.presentationData.strings, displayOrder: .firstLast) ?? " ", font: descriptionFont, textColor: item.presentationData.theme.theme.list.itemSecondaryTextColor)
|
|
}
|
|
}
|
|
}
|
|
|
|
if isInstantVideo || isVoice {
|
|
var authorName: String
|
|
if let author = message.forwardInfo?.author {
|
|
if author.id == item.context.account.peerId {
|
|
authorName = item.presentationData.strings.DialogList_You
|
|
} else {
|
|
authorName = EnginePeer(author).displayTitle(strings: item.presentationData.strings, displayOrder: item.presentationData.nameDisplayOrder)
|
|
}
|
|
} else if let signature = message.forwardInfo?.authorSignature {
|
|
authorName = signature
|
|
} else if let author = message.author {
|
|
if author.id == item.context.account.peerId {
|
|
authorName = item.presentationData.strings.DialogList_You
|
|
} else {
|
|
authorName = EnginePeer(author).displayTitle(strings: item.presentationData.strings, displayOrder: item.presentationData.nameDisplayOrder)
|
|
}
|
|
} else {
|
|
authorName = " "
|
|
}
|
|
|
|
if item.isGlobalSearchResult || item.isDownloadList {
|
|
let authorString = stringForFullAuthorName(message: EngineMessage(message), strings: item.presentationData.strings, nameDisplayOrder: item.presentationData.nameDisplayOrder, accountPeerId: item.context.account.peerId)
|
|
if authorString.count > 1 {
|
|
globalAuthorTitle = authorString.last ?? ""
|
|
}
|
|
authorName = authorString.first ?? ""
|
|
}
|
|
|
|
titleText = NSAttributedString(string: authorName, font: audioTitleFont, textColor: item.presentationData.theme.theme.list.itemPrimaryTextColor)
|
|
let dateString = stringForFullDate(timestamp: message.timestamp, strings: item.presentationData.strings, dateTimeFormat: item.presentationData.dateTimeFormat)
|
|
var descriptionString: String = ""
|
|
if let duration = file.duration {
|
|
if item.isGlobalSearchResult || item.isDownloadList || !item.displayFileInfo {
|
|
descriptionString = stringForDuration(Int32(duration))
|
|
} else {
|
|
descriptionString = "\(stringForDuration(Int32(duration))) • \(dateString)"
|
|
}
|
|
} else {
|
|
if !(item.isGlobalSearchResult || item.isDownloadList) {
|
|
descriptionString = dateString
|
|
}
|
|
}
|
|
|
|
descriptionText = NSAttributedString(string: descriptionString, font: descriptionFont, textColor: item.presentationData.theme.theme.list.itemSecondaryTextColor)
|
|
iconImage = .roundVideo(file)
|
|
} else if !isAudio {
|
|
var fileName: String = file.fileName ?? "File"
|
|
if file.isVideo {
|
|
fileName = item.presentationData.strings.Message_Video
|
|
}
|
|
titleText = NSAttributedString(string: fileName, font: titleFont, textColor: item.presentationData.theme.theme.list.itemPrimaryTextColor)
|
|
|
|
var fileExtension: String?
|
|
if let range = fileName.range(of: ".", options: [.backwards]) {
|
|
fileExtension = fileName[range.upperBound...].lowercased()
|
|
}
|
|
extensionIconImage = extensionImage(fileExtension: fileExtension)
|
|
if let fileExtension = fileExtension {
|
|
extensionText = NSAttributedString(string: fileExtension, font: fileExtension.count > 3 ? mediumExtensionFont : extensionFont, textColor: UIColor.white)
|
|
}
|
|
|
|
if let representation = smallestImageRepresentation(file.previewRepresentations) {
|
|
iconImage = .imageRepresentation(file, representation)
|
|
}
|
|
|
|
let dateString = stringForFullDate(timestamp: message.timestamp, strings: item.presentationData.strings, dateTimeFormat: item.presentationData.dateTimeFormat)
|
|
|
|
var descriptionString: String = ""
|
|
if let size = file.size {
|
|
if item.isGlobalSearchResult || item.isDownloadList || !item.displayFileInfo {
|
|
descriptionString = dataSizeString(size, formatting: DataSizeStringFormatting(chatPresentationData: item.presentationData))
|
|
} else {
|
|
descriptionString = "\(dataSizeString(size, formatting: DataSizeStringFormatting(chatPresentationData: item.presentationData))) • \(dateString)"
|
|
}
|
|
} else {
|
|
if !(item.isGlobalSearchResult || item.isDownloadList) {
|
|
descriptionString = "\(dateString)"
|
|
}
|
|
}
|
|
|
|
if item.isGlobalSearchResult || item.isDownloadList {
|
|
let authorString = stringForFullAuthorName(message: EngineMessage(message), strings: item.presentationData.strings, nameDisplayOrder: item.presentationData.nameDisplayOrder, accountPeerId: item.context.account.peerId)
|
|
if authorString.count > 1 {
|
|
globalAuthorTitle = authorString.last ?? ""
|
|
}
|
|
if descriptionString.isEmpty {
|
|
descriptionString = authorString.first ?? ""
|
|
} else {
|
|
descriptionString = "\(descriptionString) • \(authorString.first ?? "")"
|
|
}
|
|
}
|
|
|
|
descriptionText = NSAttributedString(string: descriptionString, font: descriptionFont, textColor: item.presentationData.theme.theme.list.itemSecondaryTextColor)
|
|
}
|
|
|
|
break
|
|
} else if let image = media as? TelegramMediaImage {
|
|
selectedMedia = image
|
|
|
|
let fileName: String = item.presentationData.strings.Message_Photo
|
|
titleText = NSAttributedString(string: fileName, font: titleFont, textColor: item.presentationData.theme.theme.list.itemPrimaryTextColor)
|
|
|
|
if let representation = smallestImageRepresentation(image.representations) {
|
|
iconImage = .imageRepresentation(image, representation)
|
|
}
|
|
|
|
let dateString = stringForFullDate(timestamp: message.timestamp, strings: item.presentationData.strings, dateTimeFormat: item.presentationData.dateTimeFormat)
|
|
|
|
var descriptionString: String = ""
|
|
if !(item.isGlobalSearchResult || item.isDownloadList) {
|
|
descriptionString = "\(dateString)"
|
|
}
|
|
|
|
if item.isGlobalSearchResult || item.isDownloadList {
|
|
let authorString = stringForFullAuthorName(message: EngineMessage(message), strings: item.presentationData.strings, nameDisplayOrder: item.presentationData.nameDisplayOrder, accountPeerId: item.context.account.peerId)
|
|
if authorString.count > 1 {
|
|
globalAuthorTitle = authorString.last ?? ""
|
|
}
|
|
if descriptionString.isEmpty {
|
|
descriptionString = authorString.first ?? ""
|
|
} else {
|
|
descriptionString = "\(descriptionString) • \(authorString.first ?? "")"
|
|
}
|
|
}
|
|
|
|
descriptionText = NSAttributedString(string: descriptionString, font: descriptionFont, textColor: item.presentationData.theme.theme.list.itemSecondaryTextColor)
|
|
}
|
|
}
|
|
|
|
for attribute in message.attributes {
|
|
if let attribute = attribute as? RestrictedContentMessageAttribute, attribute.platformText(platform: "ios", contentSettings: item.context.currentContentSettings.with { $0 }) != nil {
|
|
isRestricted = true
|
|
break
|
|
}
|
|
}
|
|
} else {
|
|
titleText = NSAttributedString(string: " ", font: titleFont, textColor: item.presentationData.theme.theme.list.itemPrimaryTextColor)
|
|
descriptionText = NSAttributedString(string: " ", font: descriptionFont, textColor: item.presentationData.theme.theme.list.itemSecondaryTextColor)
|
|
}
|
|
|
|
if let _ = item.message?.threadId, item.message?.id.peerId.namespace == Namespaces.Peer.CloudChannel, let threadInfo = item.message?.associatedThreadInfo {
|
|
if isInstantVideo || isVoice {
|
|
titleExtraData = (NSAttributedString(string: threadInfo.title, font: titleFont, textColor: item.presentationData.theme.theme.list.itemPrimaryTextColor), true, threadInfo.icon, threadInfo.iconColor)
|
|
} else {
|
|
descriptionExtraData = (NSAttributedString(string: threadInfo.title, font: descriptionFont, textColor: item.presentationData.theme.theme.list.itemSecondaryTextColor), true, threadInfo.icon, threadInfo.iconColor)
|
|
}
|
|
} else if let globalAuthorTitle = globalAuthorTitle {
|
|
if isInstantVideo || isVoice {
|
|
titleExtraData = (NSAttributedString(string: globalAuthorTitle, font: titleFont, textColor: item.presentationData.theme.theme.list.itemPrimaryTextColor), false, nil, 0)
|
|
} else {
|
|
descriptionExtraData = (NSAttributedString(string: globalAuthorTitle, font: descriptionFont, textColor: item.presentationData.theme.theme.list.itemSecondaryTextColor), false, nil, 0)
|
|
}
|
|
}
|
|
|
|
var mediaUpdated = false
|
|
if let currentMedia = currentMedia {
|
|
if let selectedMedia = selectedMedia {
|
|
mediaUpdated = !selectedMedia.isEqual(to: currentMedia)
|
|
} else {
|
|
mediaUpdated = true
|
|
}
|
|
} else {
|
|
mediaUpdated = selectedMedia != nil
|
|
}
|
|
|
|
var statusUpdated = mediaUpdated
|
|
if currentMessage?.id != message?.id || currentMessage?.flags != message?.flags {
|
|
statusUpdated = true
|
|
}
|
|
|
|
if let message = message, let selectedMedia = selectedMedia {
|
|
if mediaUpdated {
|
|
let context = item.context
|
|
updatedFetchControls = FetchControls(fetch: { [weak self] in
|
|
if let strongSelf = self {
|
|
if let file = selectedMedia as? TelegramMediaFile {
|
|
strongSelf.fetchDisposable.set(messageMediaFileInteractiveFetched(context: context, message: message, file: file, userInitiated: true).start())
|
|
} else if let image = selectedMedia as? TelegramMediaImage, let representation = image.representations.last {
|
|
strongSelf.fetchDisposable.set(messageMediaImageInteractiveFetched(context: context, message: message, image: image, resource: representation.resource, userInitiated: true, storeToDownloadsPeerId: nil).start())
|
|
}
|
|
}
|
|
}, cancel: {
|
|
if let file = selectedMedia as? TelegramMediaFile {
|
|
if item.isDownloadList {
|
|
context.fetchManager.toggleInteractiveFetchPaused(resourceId: file.resource.id.stringRepresentation, isPaused: true)
|
|
} else {
|
|
messageMediaFileCancelInteractiveFetch(context: context, messageId: message.id, file: file)
|
|
}
|
|
} else if let image = selectedMedia as? TelegramMediaImage, let representation = image.representations.last {
|
|
if item.isDownloadList {
|
|
context.fetchManager.toggleInteractiveFetchPaused(resourceId: representation.resource.id.stringRepresentation, isPaused: true)
|
|
} else {
|
|
messageMediaImageCancelInteractiveFetch(context: context, messageId: message.id, image: image, resource: representation.resource)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
if statusUpdated && item.displayFileInfo {
|
|
if let file = selectedMedia as? TelegramMediaFile {
|
|
updatedStatusSignal = messageFileMediaResourceStatus(context: item.context, file: file, message: EngineMessage(message), isRecentActions: false, isSharedMedia: true, isGlobalSearch: item.isGlobalSearchResult, isDownloadList: item.isDownloadList)
|
|
|> mapToSignal { value -> Signal<FileMediaResourceStatus, NoError> in
|
|
if case .Fetching = value.fetchStatus, !item.isDownloadList {
|
|
return .single(value) |> delay(0.1, queue: Queue.concurrentDefaultQueue())
|
|
} else {
|
|
return .single(value)
|
|
}
|
|
}
|
|
|
|
if isAudio || isInstantVideo {
|
|
if let currentUpdatedStatusSignal = updatedStatusSignal {
|
|
updatedStatusSignal = currentUpdatedStatusSignal
|
|
|> map { status in
|
|
switch status.mediaStatus {
|
|
case .fetchStatus:
|
|
if item.isDownloadList {
|
|
return FileMediaResourceStatus(mediaStatus: .fetchStatus(status.fetchStatus), fetchStatus: status.fetchStatus)
|
|
} else {
|
|
return FileMediaResourceStatus(mediaStatus: .fetchStatus(.Local), fetchStatus: status.fetchStatus)
|
|
}
|
|
case .playbackStatus:
|
|
return status
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if isVoice {
|
|
updatedPlaybackStatusSignal = messageFileMediaPlaybackStatus(context: item.context, file: file, message: EngineMessage(message), isRecentActions: false, isGlobalSearch: item.isGlobalSearchResult, isDownloadList: item.isDownloadList)
|
|
}
|
|
} else if let image = selectedMedia as? TelegramMediaImage {
|
|
updatedStatusSignal = messageImageMediaResourceStatus(context: item.context, image: image, message: EngineMessage(message), isRecentActions: false, isSharedMedia: true, isGlobalSearch: item.isGlobalSearchResult || item.isDownloadList)
|
|
|> mapToSignal { value -> Signal<FileMediaResourceStatus, NoError> in
|
|
if case .Fetching = value.fetchStatus, !item.isDownloadList {
|
|
return .single(value) |> delay(0.1, queue: Queue.concurrentDefaultQueue())
|
|
} else {
|
|
return .single(value)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
var chatListSearchResult: CachedChatListSearchResult?
|
|
let messageText = foldLineBreaks(item.message?.text ?? "")
|
|
|
|
if let searchQuery = item.interaction.searchTextHighightState {
|
|
if let cached = currentSearchResult, cached.matches(text: messageText, searchQuery: searchQuery) {
|
|
chatListSearchResult = cached
|
|
} else {
|
|
let (ranges, text) = findSubstringRanges(in: messageText, query: searchQuery)
|
|
chatListSearchResult = CachedChatListSearchResult(text: text, searchQuery: searchQuery, resultRanges: ranges)
|
|
}
|
|
} else {
|
|
chatListSearchResult = nil
|
|
}
|
|
|
|
var captionText: NSMutableAttributedString?
|
|
if let chatListSearchResult = chatListSearchResult, let firstRange = chatListSearchResult.resultRanges.first {
|
|
var text = NSMutableAttributedString(string: messageText, font: descriptionFont, textColor: item.presentationData.theme.theme.list.itemSecondaryTextColor)
|
|
for range in chatListSearchResult.resultRanges {
|
|
let stringRange = NSRange(range, in: chatListSearchResult.text)
|
|
if stringRange.location >= 0 && stringRange.location + stringRange.length <= text.length {
|
|
text.addAttribute(.foregroundColor, value: item.presentationData.theme.theme.chatList.messageHighlightedTextColor, range: stringRange)
|
|
}
|
|
}
|
|
|
|
let firstRangeOrigin = chatListSearchResult.text.distance(from: chatListSearchResult.text.startIndex, to: firstRange.lowerBound)
|
|
if firstRangeOrigin > 24 {
|
|
var leftOrigin: Int = 0
|
|
(text.string as NSString).enumerateSubstrings(in: NSMakeRange(0, firstRangeOrigin), options: [.byWords, .reverse]) { (str, range1, _, _) in
|
|
let distanceFromEnd = firstRangeOrigin - range1.location
|
|
if (distanceFromEnd > 12 || range1.location == 0) && leftOrigin == 0 {
|
|
leftOrigin = range1.location
|
|
}
|
|
}
|
|
text = text.attributedSubstring(from: NSMakeRange(leftOrigin, text.length - leftOrigin)).mutableCopy() as! NSMutableAttributedString
|
|
text.insert(NSAttributedString(string: "\u{2026}", attributes: [NSAttributedString.Key.font: descriptionFont, NSAttributedString.Key.foregroundColor: item.presentationData.theme.theme.list.itemSecondaryTextColor]), at: 0)
|
|
}
|
|
|
|
captionText = text
|
|
}
|
|
|
|
let timestamp = Int32(CFAbsoluteTimeGetCurrent() + NSTimeIntervalSince1970)
|
|
let dateText = stringForRelativeTimestamp(strings: item.presentationData.strings, relativeTimestamp: item.message?.timestamp ?? 0, relativeTo: timestamp, dateTimeFormat: item.presentationData.dateTimeFormat)
|
|
let dateAttributedString = NSAttributedString(string: dateText, font: dateFont, textColor: item.presentationData.theme.theme.list.itemSecondaryTextColor)
|
|
|
|
let (dateNodeLayout, dateNodeApply) = dateNodeMakeLayout(TextNodeLayoutArguments(attributedString: dateAttributedString, backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: params.width - leftInset - rightInset - 12.0, height: CGFloat.greatestFiniteMagnitude), alignment: .natural, cutout: nil, insets: UIEdgeInsets()))
|
|
|
|
let (titleNodeLayout, titleNodeApply) = titleNodeMakeLayout(item.context, params.width - leftInset - leftOffset - rightInset - dateNodeLayout.size.width - 4.0, item.presentationData.theme.theme, titleText, titleExtraData)
|
|
|
|
let (textNodeLayout, textNodeApply) = textNodeMakeLayout(TextNodeLayoutArguments(attributedString: captionText, backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: params.width - leftInset - rightInset - 30.0, height: CGFloat.infinity), alignment: .natural, cutout: nil, insets: UIEdgeInsets()))
|
|
|
|
let (descriptionNodeLayout, descriptionNodeApply) = descriptionNodeMakeLayout(item.context, params.width - leftInset - rightInset - 30.0, item.presentationData.theme.theme, descriptionText, descriptionExtraData)
|
|
|
|
var (extensionTextLayout, extensionTextApply) = extensionIconTextMakeLayout(TextNodeLayoutArguments(attributedString: extensionText, backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: 38.0, height: CGFloat.infinity), alignment: .natural, cutout: nil, insets: UIEdgeInsets()))
|
|
if extensionTextLayout.truncated, let text = extensionText?.string {
|
|
extensionText = NSAttributedString(string: text, font: smallExtensionFont, textColor: .white, paragraphAlignment: .center)
|
|
(extensionTextLayout, extensionTextApply) = extensionIconTextMakeLayout(TextNodeLayoutArguments(attributedString: extensionText, backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: 38.0, height: CGFloat.infinity), alignment: .natural, cutout: nil, insets: UIEdgeInsets()))
|
|
}
|
|
|
|
var iconImageApply: (() -> Void)?
|
|
if let iconImage = iconImage {
|
|
switch iconImage {
|
|
case let .imageRepresentation(_, representation):
|
|
let iconSize = CGSize(width: 40.0, height: 40.0)
|
|
let imageCorners = ImageCorners(radius: 6.0)
|
|
let arguments = TransformImageArguments(corners: imageCorners, imageSize: representation.dimensions.cgSize.aspectFilled(iconSize), boundingSize: iconSize, intrinsicInsets: UIEdgeInsets(), emptyColor: item.presentationData.theme.theme.list.mediaPlaceholderColor)
|
|
iconImageApply = iconImageLayout(arguments)
|
|
case .albumArt:
|
|
let iconSize = CGSize(width: 40.0, height: 40.0)
|
|
let imageCorners = ImageCorners(radius: iconSize.width / 2.0)
|
|
let arguments = TransformImageArguments(corners: imageCorners, imageSize: iconSize, boundingSize: iconSize, intrinsicInsets: UIEdgeInsets(), emptyColor: item.presentationData.theme.theme.list.mediaPlaceholderColor)
|
|
iconImageApply = iconImageLayout(arguments)
|
|
case let .roundVideo(file):
|
|
let iconSize = CGSize(width: 40.0, height: 40.0)
|
|
let imageCorners = ImageCorners(radius: iconSize.width / 2.0)
|
|
let arguments = TransformImageArguments(corners: imageCorners, imageSize: (file.dimensions ?? PixelDimensions(width: 320, height: 320)).cgSize.aspectFilled(iconSize), boundingSize: iconSize, intrinsicInsets: UIEdgeInsets(), emptyColor: item.presentationData.theme.theme.list.mediaPlaceholderColor)
|
|
iconImageApply = iconImageLayout(arguments)
|
|
}
|
|
}
|
|
|
|
if let message = message {
|
|
if currentIconImage != iconImage {
|
|
if let iconImage = iconImage {
|
|
switch iconImage {
|
|
case let .imageRepresentation(media, representation):
|
|
if let file = media as? TelegramMediaFile {
|
|
updateIconImageSignal = chatWebpageSnippetFile(account: item.context.account, userLocation: .peer(message.id.peerId), mediaReference: FileMediaReference.message(message: MessageReference(message), media: file).abstract, representation: representation)
|
|
} else if let image = media as? TelegramMediaImage {
|
|
updateIconImageSignal = mediaGridMessagePhoto(account: item.context.account, userLocation: .peer(message.id.peerId), photoReference: ImageMediaReference.message(message: MessageReference(message), media: image))
|
|
} else {
|
|
updateIconImageSignal = .complete()
|
|
}
|
|
case let .albumArt(file, albumArt):
|
|
updateIconImageSignal = playerAlbumArt(postbox: item.context.account.postbox, engine: item.context.engine, fileReference: .message(message: MessageReference(message), media: file), albumArt: albumArt, thumbnail: true, overlayColor: UIColor(white: 0.0, alpha: 0.3), emptyColor: item.presentationData.theme.theme.list.itemAccentColor)
|
|
case let .roundVideo(file):
|
|
updateIconImageSignal = mediaGridMessageVideo(postbox: item.context.account.postbox, userLocation: .peer(message.id.peerId), videoReference: FileMediaReference.message(message: MessageReference(message), media: file), autoFetchFullSizeThumbnail: true, overlayColor: UIColor(white: 0.0, alpha: 0.3))
|
|
}
|
|
} else {
|
|
updateIconImageSignal = .complete()
|
|
}
|
|
}
|
|
}
|
|
|
|
var insets = UIEdgeInsets(top: 0.0, left: 0.0, bottom: 0.0, right: 0.0)
|
|
if dateHeaderAtBottom, let header = item.header {
|
|
insets.top += header.height
|
|
}
|
|
if !mergedBottom, case .blocks = item.style {
|
|
insets.bottom += 35.0
|
|
}
|
|
|
|
let nodeLayout = ListViewItemNodeLayout(contentSize: CGSize(width: params.width, height: 8.0 * 2.0 + titleNodeLayout.height - 5.0 + descriptionNodeLayout.height + (textNodeLayout.size.height > 0.0 ? textNodeLayout.size.height + 3.0 : 0.0)), insets: insets)
|
|
|
|
return (nodeLayout, { animation in
|
|
if let strongSelf = self {
|
|
if strongSelf.downloadStatusIconNode == nil {
|
|
strongSelf.downloadStatusIconNode = DownloadIconNode(theme: item.presentationData.theme.theme)
|
|
}
|
|
|
|
let transition: ContainedViewLayoutTransition
|
|
if animation.isAnimated && currentItem?.message != nil {
|
|
transition = ContainedViewLayoutTransition.animated(duration: 0.4, curve: .spring)
|
|
} else {
|
|
transition = .immediate
|
|
}
|
|
|
|
strongSelf.restrictionNode.isHidden = !isRestricted
|
|
|
|
strongSelf.containerNode.frame = CGRect(origin: CGPoint(), size: nodeLayout.contentSize)
|
|
strongSelf.contextSourceNode.frame = CGRect(origin: CGPoint(), size: nodeLayout.contentSize)
|
|
strongSelf.offsetContainerNode.frame = CGRect(origin: CGPoint(), size: nodeLayout.contentSize)
|
|
strongSelf.contextSourceNode.contentNode.frame = CGRect(origin: CGPoint(), size: nodeLayout.contentSize)
|
|
strongSelf.restrictionNode.frame = CGRect(origin: CGPoint(), size: nodeLayout.contentSize)
|
|
|
|
let nonExtractedRect = CGRect(origin: CGPoint(), size: CGSize(width: nodeLayout.contentSize.width - 16.0, height: nodeLayout.contentSize.height))
|
|
let extractedRect = CGRect(origin: CGPoint(), size: nodeLayout.contentSize).insetBy(dx: 16.0 + params.leftInset, dy: 0.0)
|
|
strongSelf.extractedRect = extractedRect
|
|
strongSelf.nonExtractedRect = nonExtractedRect
|
|
|
|
if strongSelf.contextSourceNode.isExtractedToContextPreview {
|
|
strongSelf.extractedBackgroundImageNode.frame = extractedRect
|
|
} else {
|
|
strongSelf.extractedBackgroundImageNode.frame = nonExtractedRect
|
|
}
|
|
strongSelf.contextSourceNode.contentRect = extractedRect
|
|
strongSelf.containerNode.isGestureEnabled = item.displayFileInfo
|
|
|
|
strongSelf.currentIsRestricted = isRestricted || item.message == nil
|
|
strongSelf.currentMedia = selectedMedia
|
|
strongSelf.message = message
|
|
strongSelf.context = item.context
|
|
strongSelf.appliedItem = item
|
|
strongSelf.layoutParams = params
|
|
strongSelf.contentSizeValue = nodeLayout.contentSize
|
|
strongSelf.currentLeftOffset = leftOffset
|
|
|
|
if let _ = updatedTheme {
|
|
if item.displayBackground {
|
|
let backgroundNode: ASDisplayNode
|
|
if let current = strongSelf.backgroundNode {
|
|
backgroundNode = current
|
|
} else {
|
|
backgroundNode = ASDisplayNode()
|
|
strongSelf.backgroundNode = backgroundNode
|
|
strongSelf.insertSubnode(backgroundNode, at: 0)
|
|
}
|
|
backgroundNode.backgroundColor = item.presentationData.theme.theme.list.itemBlocksBackgroundColor
|
|
}
|
|
|
|
strongSelf.separatorNode.backgroundColor = item.presentationData.theme.theme.list.itemPlainSeparatorColor
|
|
strongSelf.highlightedBackgroundNode.backgroundColor = item.presentationData.theme.theme.list.itemHighlightedBackgroundColor
|
|
strongSelf.linearProgressNode?.updateTheme(theme: item.presentationData.theme.theme)
|
|
|
|
strongSelf.restrictionNode.backgroundColor = item.presentationData.theme.theme.list.itemBlocksBackgroundColor.withAlphaComponent(0.6)
|
|
|
|
strongSelf.downloadStatusIconNode?.updateTheme(theme: item.presentationData.theme.theme)
|
|
}
|
|
|
|
if let (selectionWidth, selectionApply) = selectionNodeWidthAndApply {
|
|
let selectionFrame = CGRect(origin: CGPoint(x: params.leftInset, y: 0.0), size: CGSize(width: selectionWidth, height: nodeLayout.contentSize.height))
|
|
let selectionNode = selectionApply(selectionFrame.size, transition.isAnimated)
|
|
if selectionNode !== strongSelf.selectionNode {
|
|
strongSelf.selectionNode?.removeFromSupernode()
|
|
strongSelf.selectionNode = selectionNode
|
|
strongSelf.contextSourceNode.contentNode.addSubnode(selectionNode)
|
|
selectionNode.frame = selectionFrame
|
|
transition.animatePosition(node: selectionNode, from: CGPoint(x: -selectionFrame.size.width / 2.0, y: selectionFrame.midY))
|
|
} else {
|
|
transition.updateFrame(node: selectionNode, frame: selectionFrame)
|
|
}
|
|
} else if let selectionNode = strongSelf.selectionNode {
|
|
strongSelf.selectionNode = nil
|
|
let selectionFrame = selectionNode.frame
|
|
transition.updatePosition(node: selectionNode, position: CGPoint(x: -selectionFrame.size.width / 2.0, y: selectionFrame.midY), completion: { [weak selectionNode] _ in
|
|
selectionNode?.removeFromSupernode()
|
|
})
|
|
}
|
|
|
|
transition.updateFrame(node: strongSelf.separatorNode, frame: CGRect(origin: CGPoint(x: leftInset + leftOffset, y: nodeLayout.contentSize.height - UIScreenPixel), size: CGSize(width: params.width - leftInset - leftOffset, height: UIScreenPixel)))
|
|
strongSelf.highlightedBackgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -nodeLayout.insets.top - UIScreenPixel), size: CGSize(width: params.width, height: nodeLayout.size.height + UIScreenPixel - nodeLayout.insets.bottom))
|
|
|
|
if let backgroundNode = strongSelf.backgroundNode {
|
|
backgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -nodeLayout.insets.top), size: CGSize(width: params.width, height: nodeLayout.size.height - nodeLayout.insets.bottom))
|
|
}
|
|
|
|
switch item.style {
|
|
case .plain:
|
|
if strongSelf.maskNode.supernode != nil {
|
|
strongSelf.maskNode.removeFromSupernode()
|
|
}
|
|
case .blocks:
|
|
if strongSelf.maskNode.supernode == nil {
|
|
strongSelf.addSubnode(strongSelf.maskNode)
|
|
}
|
|
|
|
let hasCorners = itemListHasRoundedBlockLayout(params)
|
|
var hasTopCorners = false
|
|
var hasBottomCorners = false
|
|
|
|
if !mergedTop {
|
|
hasTopCorners = true
|
|
}
|
|
if !mergedBottom {
|
|
hasBottomCorners = true
|
|
strongSelf.separatorNode.isHidden = hasCorners
|
|
} else {
|
|
strongSelf.separatorNode.isHidden = false
|
|
}
|
|
|
|
strongSelf.maskNode.image = hasCorners ? PresentationResourcesItemList.cornersImage(item.presentationData.theme.theme, top: hasTopCorners, bottom: hasBottomCorners) : nil
|
|
if let backgroundNode = strongSelf.backgroundNode {
|
|
strongSelf.maskNode.frame = backgroundNode.frame.insetBy(dx: params.leftInset, dy: 0.0)
|
|
}
|
|
}
|
|
|
|
transition.updateFrame(node: strongSelf.titleNode, frame: CGRect(origin: CGPoint(x: leftOffset + leftInset - 1.0, y: 7.0), size: titleNodeLayout))
|
|
let _ = titleNodeApply()
|
|
|
|
var descriptionOffset: CGFloat = 0.0
|
|
if let resourceStatus = strongSelf.resourceStatus {
|
|
switch resourceStatus {
|
|
case .playbackStatus:
|
|
break
|
|
case let .fetchStatus(fetchStatus):
|
|
switch fetchStatus {
|
|
case .Remote, .Fetching, .Paused:
|
|
descriptionOffset = 14.0
|
|
case .Local:
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
transition.updateFrame(node: strongSelf.textNode, frame: CGRect(origin: CGPoint(x: leftOffset + leftInset + descriptionOffset, y: strongSelf.titleNode.frame.maxY + 1.0), size: textNodeLayout.size))
|
|
let _ = textNodeApply()
|
|
|
|
transition.updateFrame(node: strongSelf.descriptionNode, frame: CGRect(origin: CGPoint(x: leftOffset + leftInset + descriptionOffset - 1.0, y: strongSelf.titleNode.frame.maxY - 3.0 + (textNodeLayout.size.height > 0.0 ? textNodeLayout.size.height + 3.0 : 0.0)), size: descriptionNodeLayout))
|
|
let _ = descriptionNodeApply()
|
|
|
|
let _ = dateNodeApply()
|
|
transition.updateFrame(node: strongSelf.dateNode, frame: CGRect(origin: CGPoint(x: params.width - rightInset - dateNodeLayout.size.width, y: 11.0), size: dateNodeLayout.size))
|
|
strongSelf.dateNode.isHidden = !item.isGlobalSearchResult
|
|
|
|
let iconSize = CGSize(width: 40.0, height: 40.0)
|
|
let iconFrame = CGRect(origin: CGPoint(x: params.leftInset + leftOffset + 12.0, y: 8.0), size: iconSize)
|
|
transition.updateFrame(node: strongSelf.extensionIconNode, frame: iconFrame)
|
|
strongSelf.extensionIconNode.image = extensionIconImage
|
|
transition.updateFrame(node: strongSelf.extensionIconText, frame: CGRect(origin: CGPoint(x: iconFrame.minX + floorToScreenPixels((iconFrame.width - extensionTextLayout.size.width) / 2.0), y: iconFrame.minY + 7.0 + floorToScreenPixels((iconFrame.height - extensionTextLayout.size.height) / 2.0)), size: extensionTextLayout.size))
|
|
|
|
transition.updateFrame(node: strongSelf.iconStatusNode, frame: iconFrame)
|
|
|
|
let _ = extensionTextApply()
|
|
|
|
strongSelf.currentIconImage = iconImage
|
|
|
|
if let updateIconImageSignal, let iconImage, case .albumArt = iconImage {
|
|
strongSelf.iconStatusNode.setBackgroundImage(updateIconImageSignal, size: CGSize(width: 40.0, height: 40.0))
|
|
}
|
|
|
|
if let iconImageApply = iconImageApply {
|
|
if let updateImageSignal = updateIconImageSignal {
|
|
strongSelf.iconImageNode.setSignal(updateImageSignal)
|
|
}
|
|
|
|
transition.updateFrame(node: strongSelf.iconImageNode, frame: iconFrame)
|
|
if strongSelf.iconImageNode.supernode == nil {
|
|
strongSelf.offsetContainerNode.insertSubnode(strongSelf.iconImageNode, belowSubnode: strongSelf.iconStatusNode)
|
|
}
|
|
|
|
iconImageApply()
|
|
|
|
if strongSelf.extensionIconNode.supernode != nil {
|
|
strongSelf.extensionIconNode.removeFromSupernode()
|
|
}
|
|
if strongSelf.extensionIconText.supernode != nil {
|
|
strongSelf.extensionIconText.removeFromSupernode()
|
|
}
|
|
} else if strongSelf.iconImageNode.supernode != nil {
|
|
strongSelf.iconImageNode.removeFromSupernode()
|
|
|
|
if strongSelf.extensionIconNode.supernode == nil {
|
|
strongSelf.offsetContainerNode.insertSubnode(strongSelf.extensionIconNode, belowSubnode: strongSelf.iconStatusNode)
|
|
}
|
|
if strongSelf.extensionIconText.supernode == nil {
|
|
strongSelf.offsetContainerNode.insertSubnode(strongSelf.extensionIconText, belowSubnode: strongSelf.iconStatusNode)
|
|
}
|
|
}
|
|
|
|
if let updatedStatusSignal = updatedStatusSignal {
|
|
strongSelf.statusDisposable.set((updatedStatusSignal
|
|
|> deliverOnMainQueue).start(next: { [weak strongSelf] fileStatus in
|
|
if let strongSelf = strongSelf {
|
|
strongSelf.fetchStatus = fileStatus.fetchStatus._asStatus()
|
|
strongSelf.resourceStatus = fileStatus.mediaStatus
|
|
strongSelf.updateStatus(transition: .immediate)
|
|
}
|
|
}))
|
|
}
|
|
|
|
if let downloadStatusIconNode = strongSelf.downloadStatusIconNode {
|
|
transition.updateFrame(node: downloadStatusIconNode, frame: CGRect(origin: CGPoint(x: leftOffset + leftInset - 3.0, y: strongSelf.descriptionNode.frame.minY + floorToScreenPixels((strongSelf.descriptionNode.frame.height - 18.0) / 2.0) + UIScreenPixel), size: CGSize(width: 18.0, height: 18.0)))
|
|
}
|
|
|
|
if let updatedFetchControls = updatedFetchControls {
|
|
let _ = strongSelf.fetchControls.swap(updatedFetchControls)
|
|
}
|
|
|
|
if let updatedPlaybackStatusSignal = updatedPlaybackStatusSignal {
|
|
strongSelf.playbackStatus.set(updatedPlaybackStatusSignal)
|
|
/*strongSelf.playbackStatusDisposable.set((updatedPlaybackStatusSignal |> deliverOnMainQueue).start(next: { [weak strongSelf] status in
|
|
displayLinkDispatcher.dispatch {
|
|
if let strongSelf = strongSelf {
|
|
strongSelf.playerStatus = status
|
|
}
|
|
}
|
|
}))*/
|
|
}
|
|
|
|
strongSelf.updateStatus(transition: transition)
|
|
|
|
if item.message == nil {
|
|
let shimmerNode: ShimmerEffectNode
|
|
if let current = strongSelf.placeholderNode {
|
|
shimmerNode = current
|
|
} else {
|
|
shimmerNode = ShimmerEffectNode()
|
|
strongSelf.placeholderNode = shimmerNode
|
|
if strongSelf.separatorNode.supernode != nil {
|
|
strongSelf.insertSubnode(shimmerNode, belowSubnode: strongSelf.separatorNode)
|
|
} else {
|
|
strongSelf.addSubnode(shimmerNode)
|
|
}
|
|
}
|
|
shimmerNode.frame = CGRect(origin: CGPoint(), size: nodeLayout.contentSize)
|
|
if let (rect, size) = strongSelf.absoluteLocation {
|
|
shimmerNode.updateAbsoluteRect(rect, within: size)
|
|
}
|
|
|
|
var shapes: [ShimmerEffectNode.Shape] = []
|
|
|
|
let titleLineWidth: CGFloat = 120.0
|
|
let descriptionLineWidth: CGFloat = 60.0
|
|
let lineDiameter: CGFloat = 8.0
|
|
|
|
let titleFrame = strongSelf.titleNode.frame
|
|
shapes.append(.roundedRectLine(startPoint: CGPoint(x: titleFrame.minX, y: titleFrame.minY + floorToScreenPixels((titleFrame.height - lineDiameter) / 2.0)), width: titleLineWidth, diameter: lineDiameter))
|
|
|
|
let descriptionFrame = strongSelf.descriptionNode.frame
|
|
shapes.append(.roundedRectLine(startPoint: CGPoint(x: descriptionFrame.minX, y: descriptionFrame.minY + floorToScreenPixels((descriptionFrame.height - lineDiameter) / 2.0)), width: descriptionLineWidth, diameter: lineDiameter))
|
|
|
|
if let media = selectedMedia as? TelegramMediaFile, media.isInstantVideo {
|
|
shapes.append(.circle(iconFrame))
|
|
} else {
|
|
shapes.append(.roundedRect(rect: iconFrame, cornerRadius: 6.0))
|
|
}
|
|
|
|
shimmerNode.update(backgroundColor: item.presentationData.theme.theme.list.itemBlocksBackgroundColor, foregroundColor: item.presentationData.theme.theme.list.mediaPlaceholderColor, shimmeringColor: item.presentationData.theme.theme.list.itemBlocksBackgroundColor.withAlphaComponent(0.4), shapes: shapes, size: nodeLayout.contentSize)
|
|
} else if let shimmerNode = strongSelf.placeholderNode {
|
|
strongSelf.placeholderNode = nil
|
|
shimmerNode.removeFromSupernode()
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
private func updateStatus(transition: ContainedViewLayoutTransition) {
|
|
guard let item = self.item, let media = self.currentMedia, let _ = self.fetchStatus, let status = self.resourceStatus, let layoutParams = self.layoutParams, let contentSize = self.contentSizeValue else {
|
|
return
|
|
}
|
|
|
|
var isAudio = false
|
|
var isVoice = false
|
|
var isInstantVideo = false
|
|
if let file = media as? TelegramMediaFile {
|
|
isAudio = file.isMusic || file.isVoice
|
|
isVoice = file.isVoice
|
|
isInstantVideo = file.isInstantVideo
|
|
}
|
|
|
|
var iconStatusState: SemanticStatusNodeState = .none
|
|
var iconStatusBackgroundColor: UIColor = .clear
|
|
var iconStatusForegroundColor: UIColor = .white
|
|
|
|
if isVoice {
|
|
iconStatusBackgroundColor = item.presentationData.theme.theme.list.itemAccentColor
|
|
iconStatusForegroundColor = item.presentationData.theme.theme.list.itemCheckColors.foregroundColor
|
|
} else if isAudio {
|
|
iconStatusBackgroundColor = item.presentationData.theme.theme.list.itemAccentColor
|
|
iconStatusForegroundColor = item.presentationData.theme.theme.list.itemCheckColors.foregroundColor
|
|
}
|
|
|
|
if !isAudio && !isInstantVideo {
|
|
self.updateProgressFrame(size: contentSize, leftInset: layoutParams.leftInset, rightInset: layoutParams.rightInset, transition: .immediate)
|
|
} else {
|
|
if item.isDownloadList {
|
|
self.updateProgressFrame(size: contentSize, leftInset: layoutParams.leftInset, rightInset: layoutParams.rightInset, transition: .immediate)
|
|
}
|
|
switch status {
|
|
case let .fetchStatus(fetchStatus):
|
|
switch fetchStatus {
|
|
case let .Fetching(_, progress):
|
|
if item.isDownloadList {
|
|
iconStatusState = .progress(value: CGFloat(progress), cancelEnabled: true, appearance: nil)
|
|
}
|
|
case .Local:
|
|
if isAudio || isInstantVideo {
|
|
iconStatusState = .play
|
|
}
|
|
case .Remote, .Paused:
|
|
if isAudio || isInstantVideo {
|
|
iconStatusState = .play
|
|
}
|
|
}
|
|
case let .playbackStatus(playbackStatus):
|
|
switch playbackStatus {
|
|
case .playing:
|
|
iconStatusState = .pause
|
|
case .paused:
|
|
iconStatusState = .play
|
|
}
|
|
}
|
|
}
|
|
self.iconStatusNode.backgroundNodeColor = iconStatusBackgroundColor
|
|
self.iconStatusNode.foregroundNodeColor = iconStatusForegroundColor
|
|
self.iconStatusNode.overlayForegroundNodeColor = .white
|
|
self.iconStatusNode.transitionToState(iconStatusState)
|
|
}
|
|
|
|
override public func setHighlighted(_ highlighted: Bool, at point: CGPoint, animated: Bool) {
|
|
super.setHighlighted(highlighted, at: point, animated: animated)
|
|
|
|
if highlighted, let item = self.item, case .none = item.selection {
|
|
self.highlightedBackgroundNode.alpha = 1.0
|
|
if self.highlightedBackgroundNode.supernode == nil {
|
|
if let backgroundNode = self.backgroundNode {
|
|
self.insertSubnode(self.highlightedBackgroundNode, aboveSubnode: backgroundNode)
|
|
} else {
|
|
self.insertSubnode(self.highlightedBackgroundNode, at: 0)
|
|
}
|
|
}
|
|
} else {
|
|
if self.highlightedBackgroundNode.supernode != nil {
|
|
if animated {
|
|
self.highlightedBackgroundNode.layer.animateAlpha(from: self.highlightedBackgroundNode.alpha, to: 0.0, duration: 0.4, completion: { [weak self] completed in
|
|
if let strongSelf = self {
|
|
if completed {
|
|
strongSelf.highlightedBackgroundNode.removeFromSupernode()
|
|
}
|
|
}
|
|
})
|
|
self.highlightedBackgroundNode.alpha = 0.0
|
|
} else {
|
|
self.highlightedBackgroundNode.removeFromSupernode()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
override public func transitionNode(id: MessageId, media: Media, adjustRect: Bool) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? {
|
|
if let item = self.item, let message = item.message, message.id == id, self.iconImageNode.supernode != nil {
|
|
let iconImageNode = self.iconImageNode
|
|
return (self.iconImageNode, self.iconImageNode.bounds, { [weak iconImageNode] in
|
|
return (iconImageNode?.view.snapshotContentTree(unhide: true), nil)
|
|
})
|
|
}
|
|
return nil
|
|
}
|
|
|
|
override public func updateHiddenMedia() {
|
|
if let interaction = self.interaction, let item = self.item, let message = item.message, interaction.getHiddenMedia()[message.id] != nil {
|
|
self.iconImageNode.isHidden = true
|
|
} else {
|
|
self.iconImageNode.isHidden = false
|
|
}
|
|
}
|
|
|
|
override public func updateSelectionState(animated: Bool) {
|
|
}
|
|
|
|
public func cancelPreviewGesture() {
|
|
self.containerNode.cancelGesture()
|
|
}
|
|
|
|
private func updateProgressFrame(size: CGSize, leftInset: CGFloat, rightInset: CGFloat, transition: ContainedViewLayoutTransition) {
|
|
guard let item = self.appliedItem else {
|
|
return
|
|
}
|
|
var descriptionOffset: CGFloat = 0.0
|
|
|
|
var downloadingString: String?
|
|
if let resourceStatus = self.resourceStatus {
|
|
var maybeFetchStatus: MediaResourceStatus = .Local
|
|
switch resourceStatus {
|
|
case .playbackStatus:
|
|
break
|
|
case let .fetchStatus(fetchStatus):
|
|
maybeFetchStatus = fetchStatus._asStatus()
|
|
}
|
|
|
|
if item.isDownloadList, let fetchStatus = self.fetchStatus {
|
|
maybeFetchStatus = fetchStatus
|
|
}
|
|
|
|
switch maybeFetchStatus {
|
|
case .Fetching(_, let progress), .Paused(let progress):
|
|
if let file = self.currentMedia as? TelegramMediaFile, let size = file.size {
|
|
downloadingString = "\(dataSizeString(Int(Float(size) * progress), forceDecimal: true, formatting: DataSizeStringFormatting(chatPresentationData: item.presentationData))) / \(dataSizeString(size, forceDecimal: true, formatting: DataSizeStringFormatting(chatPresentationData: item.presentationData)))"
|
|
}
|
|
descriptionOffset = 14.0
|
|
case .Remote:
|
|
descriptionOffset = 14.0
|
|
case .Local:
|
|
break
|
|
}
|
|
|
|
switch maybeFetchStatus {
|
|
case .Fetching(_, let progress), .Paused(let progress):
|
|
let progressFrame = CGRect(x: self.currentLeftOffset + leftInset + 65.0, y: size.height - 3.0, width: floorToScreenPixels((size.width - 65.0 - leftInset - rightInset)), height: 3.0)
|
|
let linearProgressNode: LinearProgressNode
|
|
if let current = self.linearProgressNode {
|
|
linearProgressNode = current
|
|
} else {
|
|
linearProgressNode = LinearProgressNode()
|
|
linearProgressNode.updateTheme(theme: item.presentationData.theme.theme)
|
|
self.linearProgressNode = linearProgressNode
|
|
self.addSubnode(linearProgressNode)
|
|
}
|
|
transition.updateFrame(node: linearProgressNode, frame: progressFrame)
|
|
linearProgressNode.updateProgress(value: CGFloat(progress), completion: {})
|
|
|
|
var animated = true
|
|
if let downloadStatusIconNode = self.downloadStatusIconNode {
|
|
if downloadStatusIconNode.supernode == nil {
|
|
animated = false
|
|
self.offsetContainerNode.addSubnode(downloadStatusIconNode)
|
|
}
|
|
if case .Paused = maybeFetchStatus {
|
|
downloadStatusIconNode.enqueueState(.download, animated: animated)
|
|
} else {
|
|
downloadStatusIconNode.enqueueState(.pause, animated: animated)
|
|
}
|
|
}
|
|
case .Local:
|
|
if let linearProgressNode = self.linearProgressNode {
|
|
self.linearProgressNode = nil
|
|
linearProgressNode.updateProgress(value: 1.0, completion: { [weak linearProgressNode] in
|
|
linearProgressNode?.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false, completion: { _ in
|
|
linearProgressNode?.removeFromSupernode()
|
|
})
|
|
})
|
|
}
|
|
if let downloadStatusIconNode = self.downloadStatusIconNode {
|
|
if downloadStatusIconNode.supernode != nil {
|
|
downloadStatusIconNode.removeFromSupernode()
|
|
}
|
|
}
|
|
case .Remote:
|
|
if let linearProgressNode = self.linearProgressNode {
|
|
self.linearProgressNode = nil
|
|
linearProgressNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false, completion: { [weak linearProgressNode] _ in
|
|
linearProgressNode?.removeFromSupernode()
|
|
})
|
|
}
|
|
if let downloadStatusIconNode = self.downloadStatusIconNode {
|
|
var animated = true
|
|
if downloadStatusIconNode.supernode == nil {
|
|
animated = false
|
|
self.offsetContainerNode.addSubnode(downloadStatusIconNode)
|
|
}
|
|
downloadStatusIconNode.enqueueState(.download, animated: animated)
|
|
}
|
|
}
|
|
} else {
|
|
if let linearProgressNode = self.linearProgressNode {
|
|
self.linearProgressNode = nil
|
|
linearProgressNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak linearProgressNode] _ in
|
|
linearProgressNode?.removeFromSupernode()
|
|
})
|
|
}
|
|
if let downloadStatusIconNode = self.downloadStatusIconNode {
|
|
if downloadStatusIconNode.supernode != nil {
|
|
downloadStatusIconNode.removeFromSupernode()
|
|
}
|
|
}
|
|
}
|
|
|
|
var descriptionFrame = self.descriptionNode.frame
|
|
let originX = self.titleNode.frame.minX + descriptionOffset
|
|
if !descriptionFrame.origin.x.isEqual(to: originX) {
|
|
descriptionFrame.origin.x = originX
|
|
transition.updateFrame(node: self.descriptionNode, frame: descriptionFrame)
|
|
}
|
|
|
|
let alphaTransition: ContainedViewLayoutTransition
|
|
if item.isDownloadList {
|
|
alphaTransition = .immediate
|
|
} else {
|
|
alphaTransition = .animated(duration: 0.3, curve: .easeInOut)
|
|
}
|
|
if downloadingString != nil {
|
|
alphaTransition.updateAlpha(node: self.descriptionProgressNode, alpha: 1.0)
|
|
alphaTransition.updateAlpha(node: self.descriptionNode, alpha: 0.0)
|
|
} else {
|
|
alphaTransition.updateAlpha(node: self.descriptionProgressNode, alpha: 0.0)
|
|
alphaTransition.updateAlpha(node: self.descriptionNode, alpha: 1.0)
|
|
}
|
|
|
|
let descriptionFont = Font.with(size: floorToScreenPixels(item.presentationData.fontSize.baseDisplaySize * 13.0 / 17.0), design: .regular, weight: .regular, traits: [.monospacedNumbers])
|
|
self.descriptionProgressNode.attributedText = NSAttributedString(string: downloadingString ?? "", font: descriptionFont, textColor: item.presentationData.theme.theme.list.itemSecondaryTextColor)
|
|
let descriptionSize = self.descriptionProgressNode.updateLayout(CGSize(width: size.width - 14.0, height: size.height))
|
|
transition.updateFrame(node: self.descriptionProgressNode, frame: CGRect(origin: CGPoint(x: self.descriptionNode.frame.minX, y: self.descriptionNode.frame.minY + floorToScreenPixels((self.descriptionNode.bounds.height - descriptionSize.height) / 2.0)), size: descriptionSize))
|
|
}
|
|
|
|
public func activateMedia() {
|
|
self.progressPressed()
|
|
}
|
|
|
|
func progressPressed() {
|
|
if let resourceStatus = self.resourceStatus {
|
|
switch resourceStatus {
|
|
case let .fetchStatus(fetchStatus):
|
|
switch fetchStatus {
|
|
case .Fetching:
|
|
if let cancel = self.fetchControls.with({ return $0?.cancel }) {
|
|
cancel()
|
|
}
|
|
case .Remote, .Paused:
|
|
if let fetch = self.fetchControls.with({ return $0?.fetch }) {
|
|
fetch()
|
|
}
|
|
case .Local:
|
|
if let item = self.item, let message = item.message, let interaction = self.interaction {
|
|
let _ = interaction.openMessage(message, .default)
|
|
}
|
|
}
|
|
case .playbackStatus:
|
|
if let context = self.context {
|
|
context.sharedContext.mediaManager.playlistControl(.playback(.togglePlayPause), type: nil)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
override public func headers() -> [ListViewItemHeader]? {
|
|
return self.item?.header.flatMap { [$0] }
|
|
}
|
|
|
|
override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
|
|
if let item = self.item, case .selectable = item.selection {
|
|
if self.bounds.contains(point) {
|
|
return self.view
|
|
}
|
|
}
|
|
return super.hitTest(point, with: event)
|
|
}
|
|
|
|
@objc private func statusPressed() {
|
|
guard let _ = self.item, let fetchStatus = self.fetchStatus else {
|
|
return
|
|
}
|
|
|
|
switch fetchStatus {
|
|
case .Fetching:
|
|
if let cancel = self.fetchControls.with({ return $0?.cancel }) {
|
|
cancel()
|
|
}
|
|
case .Remote, .Paused:
|
|
if let fetch = self.fetchControls.with({ return $0?.fetch }) {
|
|
fetch()
|
|
}
|
|
case .Local:
|
|
break
|
|
}
|
|
}
|
|
|
|
public override var canBeSelected: Bool {
|
|
return !self.currentIsRestricted
|
|
}
|
|
}
|
|
|
|
private final class LinearProgressNode: ASDisplayNode {
|
|
private let trackingNode: HierarchyTrackingNode
|
|
private let barNode: ASImageNode
|
|
private let shimmerNode: ASImageNode
|
|
private let shimmerClippingNode: ASDisplayNode
|
|
|
|
private var currentProgress: CGFloat = 0.0
|
|
private var currentProgressAnimation: (from: CGFloat, to: CGFloat, startTime: Double, completion: () -> Void)?
|
|
|
|
private var shimmerPhase: CGFloat = 0.0
|
|
|
|
private var inHierarchyValue: Bool = false
|
|
private var shouldAnimate: Bool = false
|
|
|
|
private let animator: ConstantDisplayLinkAnimator
|
|
|
|
override init() {
|
|
var updateInHierarchy: ((Bool) -> Void)?
|
|
self.trackingNode = HierarchyTrackingNode { value in
|
|
updateInHierarchy?(value)
|
|
}
|
|
|
|
var animationStep: (() -> Void)?
|
|
self.animator = ConstantDisplayLinkAnimator {
|
|
animationStep?()
|
|
}
|
|
|
|
|
|
self.barNode = ASImageNode()
|
|
self.barNode.isLayerBacked = true
|
|
|
|
self.shimmerNode = ASImageNode()
|
|
self.shimmerNode.contentMode = .scaleToFill
|
|
self.shimmerClippingNode = ASDisplayNode()
|
|
self.shimmerClippingNode.clipsToBounds = true
|
|
|
|
super.init()
|
|
|
|
self.addSubnode(trackingNode)
|
|
self.addSubnode(self.barNode)
|
|
|
|
self.shimmerClippingNode.addSubnode(self.shimmerNode)
|
|
self.addSubnode(self.shimmerClippingNode)
|
|
|
|
updateInHierarchy = { [weak self] value in
|
|
guard let strongSelf = self else {
|
|
return
|
|
}
|
|
if strongSelf.inHierarchyValue != value {
|
|
strongSelf.inHierarchyValue = value
|
|
strongSelf.updateAnimations()
|
|
}
|
|
}
|
|
|
|
animationStep = { [weak self] in
|
|
self?.update()
|
|
}
|
|
}
|
|
|
|
func updateTheme(theme: PresentationTheme) {
|
|
self.barNode.image = generateStretchableFilledCircleImage(diameter: 3.0, color: theme.list.itemAccentColor)
|
|
self.shimmerNode.image = generateImage(CGSize(width: 100.0, height: 3.0), opaque: false, rotatedContext: { size, context in
|
|
context.clear(CGRect(origin: CGPoint(), size: size))
|
|
|
|
let foregroundColor = theme.list.plainBackgroundColor.withAlphaComponent(0.4)
|
|
|
|
let transparentColor = foregroundColor.withAlphaComponent(0.0).cgColor
|
|
let peakColor = foregroundColor.cgColor
|
|
|
|
var locations: [CGFloat] = [0.0, 0.5, 1.0]
|
|
let colors: [CGColor] = [transparentColor, peakColor, transparentColor]
|
|
|
|
let colorSpace = CGColorSpaceCreateDeviceRGB()
|
|
let gradient = CGGradient(colorsSpace: colorSpace, colors: colors as CFArray, locations: &locations)!
|
|
|
|
context.drawLinearGradient(gradient, start: CGPoint(x: 0.0, y: 0.0), end: CGPoint(x: size.width, y: 0.0), options: CGGradientDrawingOptions())
|
|
})
|
|
}
|
|
|
|
func updateProgress(value: CGFloat, completion: @escaping () -> Void = {}) {
|
|
if self.currentProgress.isEqual(to: value) {
|
|
self.currentProgressAnimation = nil
|
|
completion()
|
|
} else {
|
|
self.currentProgressAnimation = (self.currentProgress, value, CACurrentMediaTime(), completion)
|
|
}
|
|
}
|
|
|
|
private func updateAnimations() {
|
|
let shouldAnimate = self.inHierarchyValue
|
|
if shouldAnimate != self.shouldAnimate {
|
|
self.shouldAnimate = shouldAnimate
|
|
self.animator.isPaused = !shouldAnimate
|
|
}
|
|
}
|
|
|
|
private func update() {
|
|
if let (fromValue, toValue, startTime, completion) = self.currentProgressAnimation {
|
|
let duration: Double = 0.15
|
|
let timestamp = CACurrentMediaTime()
|
|
let t = CGFloat((timestamp - startTime) / duration)
|
|
if t >= 1.0 {
|
|
self.currentProgress = toValue
|
|
self.currentProgressAnimation = nil
|
|
completion()
|
|
} else {
|
|
let clippedT = max(0.0, t)
|
|
self.currentProgress = (1.0 - clippedT) * fromValue + clippedT * toValue
|
|
}
|
|
|
|
var progressWidth: CGFloat = self.bounds.width * self.currentProgress
|
|
if progressWidth < 6.0 {
|
|
progressWidth = 0.0
|
|
}
|
|
let progressFrame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: progressWidth, height: 3.0))
|
|
self.barNode.frame = progressFrame
|
|
self.shimmerClippingNode.frame = progressFrame
|
|
}
|
|
|
|
self.shimmerPhase += 3.5
|
|
let shimmerWidth: CGFloat = 160.0
|
|
let shimmerOffset = self.shimmerPhase.remainder(dividingBy: self.bounds.width + shimmerWidth / 2.0)
|
|
self.shimmerNode.frame = CGRect(origin: CGPoint(x: shimmerOffset - shimmerWidth / 2.0, y: 0.0), size: CGSize(width: shimmerWidth, height: 3.0))
|
|
}
|
|
}
|
|
|
|
private enum DownloadIconNodeState: Equatable {
|
|
case download
|
|
case pause
|
|
}
|
|
|
|
private func generateDownloadIcon(color: UIColor) -> UIImage? {
|
|
let animation = ManagedAnimationNode(size: CGSize(width: 18.0, height: 18.0))
|
|
animation.customColor = color
|
|
animation.trackTo(item: ManagedAnimationItem(source: .local("anim_shareddownload"), frames: .range(startFrame: 0, endFrame: 0), duration: 0.01))
|
|
return animation.image
|
|
}
|
|
|
|
private final class DownloadIconNode: ASImageNode {
|
|
private var customColor: UIColor
|
|
private let duration: Double = 0.3
|
|
private var iconState: DownloadIconNodeState = .download
|
|
private var animationNode: ManagedAnimationNode?
|
|
|
|
init(theme: PresentationTheme) {
|
|
self.customColor = theme.list.itemAccentColor
|
|
|
|
super.init()
|
|
|
|
self.image = PresentationResourcesChat.sharedMediaFileDownloadStartIcon(theme, generate: {
|
|
return generateDownloadIcon(color: theme.list.itemAccentColor)
|
|
})
|
|
self.contentMode = .center
|
|
}
|
|
|
|
func updateTheme(theme: PresentationTheme) {
|
|
if self.image != nil {
|
|
self.image = PresentationResourcesChat.sharedMediaFileDownloadStartIcon(theme, generate: {
|
|
return generateDownloadIcon(color: theme.list.itemAccentColor)
|
|
})
|
|
}
|
|
self.customColor = theme.list.itemAccentColor
|
|
self.animationNode?.customColor = self.customColor
|
|
}
|
|
|
|
func enqueueState(_ state: DownloadIconNodeState, animated: Bool) {
|
|
guard self.iconState != state else {
|
|
return
|
|
}
|
|
|
|
if self.animationNode == nil {
|
|
let animationNode = ManagedAnimationNode(size: CGSize(width: 18.0, height: 18.0))
|
|
self.animationNode = animationNode
|
|
animationNode.frame = CGRect(origin: CGPoint(), size: CGSize(width: 18.0, height: 18.0))
|
|
animationNode.trackTo(item: ManagedAnimationItem(source: .local("anim_shareddownload"), frames: .range(startFrame: 0, endFrame: 0), duration: 0.01))
|
|
self.addSubnode(animationNode)
|
|
self.image = nil
|
|
}
|
|
|
|
guard let animationNode = self.animationNode else {
|
|
return
|
|
}
|
|
|
|
let previousState = self.iconState
|
|
self.iconState = state
|
|
|
|
switch previousState {
|
|
case .pause:
|
|
switch state {
|
|
case .download:
|
|
if animated {
|
|
animationNode.trackTo(item: ManagedAnimationItem(source: .local("anim_shareddownload"), frames: .range(startFrame: 100, endFrame: 120), duration: self.duration))
|
|
} else {
|
|
animationNode.trackTo(item: ManagedAnimationItem(source: .local("anim_shareddownload"), frames: .range(startFrame: 0, endFrame: 0), duration: 0.01))
|
|
}
|
|
case .pause:
|
|
break
|
|
}
|
|
case .download:
|
|
switch state {
|
|
case .pause:
|
|
if animated {
|
|
animationNode.trackTo(item: ManagedAnimationItem(source: .local("anim_shareddownload"), frames: .range(startFrame: 0, endFrame: 20), duration: self.duration))
|
|
} else {
|
|
animationNode.trackTo(item: ManagedAnimationItem(source: .local("anim_shareddownload"), frames: .range(startFrame: 60, endFrame: 60), duration: 0.01))
|
|
}
|
|
case .download:
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|