Merge commit '7621e2f8dec938cf48181c8b10afc9b01f444e68' into beta

This commit is contained in:
Ilya Laktyushin
2025-12-06 02:17:48 +04:00
commit 8344b97e03
28070 changed files with 7995182 additions and 0 deletions
+34
View File
@@ -0,0 +1,34 @@
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
swift_library(
name = "InstantPageUI",
module_name = "InstantPageUI",
srcs = glob([
"Sources/**/*.swift",
]),
copts = [
"-warnings-as-errors",
],
deps = [
"//submodules/SSignalKit/SwiftSignalKit:SwiftSignalKit",
"//submodules/AsyncDisplayKit:AsyncDisplayKit",
"//submodules/Display:Display",
"//submodules/Postbox:Postbox",
"//submodules/TelegramCore:TelegramCore",
"//submodules/TelegramPresentationData:TelegramPresentationData",
"//submodules/TelegramUIPreferences:TelegramUIPreferences",
"//submodules/GalleryUI:GalleryUI",
"//submodules/MusicAlbumArtResources:MusicAlbumArtResources",
"//submodules/LiveLocationPositionNode:LiveLocationPositionNode",
"//submodules/MosaicLayout:MosaicLayout",
"//submodules/LocationUI:LocationUI",
"//submodules/AppBundle:AppBundle",
"//submodules/LocationResources:LocationResources",
"//submodules/UndoUI:UndoUI",
"//submodules/TranslateUI:TranslateUI",
"//submodules/Tuples:Tuples",
],
visibility = [
"//visibility:public",
],
)
@@ -0,0 +1,356 @@
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import SwiftSignalKit
import Postbox
import TelegramCore
import TelegramPresentationData
import AccountContext
import PhotoResources
import GalleryUI
import Tuples
private struct InstantImageGalleryThumbnailItem: GalleryThumbnailItem {
let account: Account
let userLocation: MediaResourceUserLocation
let mediaReference: AnyMediaReference
func image(synchronous: Bool) -> (Signal<(TransformImageArguments) -> DrawingContext?, NoError>, CGSize) {
if let imageReferene = mediaReference.concrete(TelegramMediaImage.self), let representation = largestImageRepresentation(imageReferene.media.representations) {
return (mediaGridMessagePhoto(account: self.account, userLocation: self.userLocation, photoReference: imageReferene), representation.dimensions.cgSize)
} else if let fileReference = mediaReference.concrete(TelegramMediaFile.self), let dimensions = fileReference.media.dimensions {
return (mediaGridMessageVideo(postbox: account.postbox, userLocation: self.userLocation, videoReference: fileReference), dimensions.cgSize)
} else {
return (.single({ _ in return nil }), CGSize(width: 128.0, height: 128.0))
}
}
func isEqual(to: GalleryThumbnailItem) -> Bool {
if let to = to as? InstantImageGalleryThumbnailItem {
return self.mediaReference == to.mediaReference
} else {
return false
}
}
}
class InstantImageGalleryItem: GalleryItem {
var id: AnyHashable {
return self.itemId
}
let itemId: AnyHashable
let context: AccountContext
let presentationData: PresentationData
let userLocation: MediaResourceUserLocation
let imageReference: ImageMediaReference
let caption: NSAttributedString
let credit: NSAttributedString
let location: InstantPageGalleryEntryLocation?
let openUrl: (InstantPageUrlItem) -> Void
let openUrlOptions: (InstantPageUrlItem) -> Void
let getPreloadedResource: (String) -> Data?
init(context: AccountContext, presentationData: PresentationData, itemId: AnyHashable, userLocation: MediaResourceUserLocation, imageReference: ImageMediaReference, caption: NSAttributedString, credit: NSAttributedString, location: InstantPageGalleryEntryLocation?, openUrl: @escaping (InstantPageUrlItem) -> Void, openUrlOptions: @escaping (InstantPageUrlItem) -> Void, getPreloadedResource: @escaping (String) -> Data?) {
self.itemId = itemId
self.userLocation = userLocation
self.context = context
self.presentationData = presentationData
self.imageReference = imageReference
self.caption = caption
self.credit = credit
self.location = location
self.openUrl = openUrl
self.openUrlOptions = openUrlOptions
self.getPreloadedResource = getPreloadedResource
}
func node(synchronous: Bool) -> GalleryItemNode {
let node = InstantImageGalleryItemNode(context: self.context, presentationData: self.presentationData, openUrl: self.openUrl, openUrlOptions: self.openUrlOptions, getPreloadedResource: self.getPreloadedResource)
node.setImage(userLocation: self.userLocation, imageReference: self.imageReference)
if let location = self.location {
node._title.set(.single(self.presentationData.strings.Items_NOfM("\(location.position + 1)", "\(location.totalCount)").string))
}
node.setCaption(self.caption, credit: self.credit)
return node
}
func updateNode(node: GalleryItemNode, synchronous: Bool) {
if let node = node as? InstantImageGalleryItemNode {
if let location = self.location {
node._title.set(.single(self.presentationData.strings.Items_NOfM("\(location.position + 1)", "\(location.totalCount)").string))
}
node.setCaption(self.caption, credit: self.credit)
}
}
func thumbnailItem() -> (Int64, GalleryThumbnailItem)? {
return (0, InstantImageGalleryThumbnailItem(account: self.context.account, userLocation: self.userLocation, mediaReference: imageReference.abstract))
}
}
final class InstantImageGalleryItemNode: ZoomableContentGalleryItemNode {
private let context: AccountContext
private let imageNode: TransformImageNode
fileprivate let _ready = Promise<Void>()
fileprivate let _title = Promise<String>()
private let footerContentNode: InstantPageGalleryFooterContentNode
private var userLocation: MediaResourceUserLocation?
private var contextAndMedia: (AccountContext, AnyMediaReference)?
private var fetchDisposable = MetaDisposable()
private var getPreloadedResource: (String) -> Data?
init(context: AccountContext, presentationData: PresentationData, openUrl: @escaping (InstantPageUrlItem) -> Void, openUrlOptions: @escaping (InstantPageUrlItem) -> Void, getPreloadedResource: @escaping (String) -> Data?) {
self.context = context
self.getPreloadedResource = getPreloadedResource
self.imageNode = TransformImageNode()
self.footerContentNode = InstantPageGalleryFooterContentNode(context: context, presentationData: presentationData)
self.footerContentNode.openUrl = openUrl
self.footerContentNode.openUrlOptions = openUrlOptions
super.init()
self.imageNode.imageUpdated = { [weak self] _ in
self?._ready.set(.single(Void()))
}
self.imageNode.view.contentMode = .scaleAspectFill
self.imageNode.clipsToBounds = true
}
deinit {
self.fetchDisposable.dispose()
}
override func ready() -> Signal<Void, NoError> {
return self._ready.get()
}
override func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition) {
super.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: transition)
}
fileprivate func setCaption(_ caption: NSAttributedString, credit: NSAttributedString) {
self.footerContentNode.setCaption(caption, credit: credit)
}
fileprivate func setImage(userLocation: MediaResourceUserLocation, imageReference: ImageMediaReference) {
self.userLocation = userLocation
if self.contextAndMedia == nil || !self.contextAndMedia!.1.media.isEqual(to: imageReference.media) {
if let largestSize = largestRepresentationForPhoto(imageReference.media) {
let displaySize = largestSize.dimensions.cgSize.fitted(CGSize(width: 1280.0, height: 1280.0)).dividedByScreenScale().integralFloor
self.imageNode.asyncLayout()(TransformImageArguments(corners: ImageCorners(), imageSize: displaySize, boundingSize: displaySize, intrinsicInsets: UIEdgeInsets(), emptyColor: .black))()
self.zoomableContent = (largestSize.dimensions.cgSize, self.imageNode)
if let externalResource = largestSize.resource as? InstantPageExternalMediaResource {
var url = externalResource.url
if !url.hasPrefix("http") && !url.hasPrefix("https") && url.hasPrefix("//") {
url = "https:\(url)"
}
let photoData: Signal<Tuple4<Data?, Data?, ChatMessagePhotoQuality, Bool>, NoError>
if let preloadedData = getPreloadedResource(externalResource.url) {
photoData = .single(Tuple4(nil, preloadedData, .full, true))
} else {
photoData = self.context.engine.resources.httpData(url: url, preserveExactUrl: true)
|> map(Optional.init)
|> `catch` { _ -> Signal<Data?, NoError> in
return .single(nil)
}
|> map { data in
if let data {
return Tuple4(nil, data, .full, true)
} else {
return Tuple4(nil, nil, .full, false)
}
}
}
self.imageNode.setSignal(chatMessagePhotoInternal(photoData: photoData)
|> map { _, _, generate in
return generate
})
} else {
self.imageNode.setSignal(chatMessagePhoto(postbox: self.context.account.postbox, userLocation: userLocation, photoReference: imageReference), dispatchOnDisplayLink: false)
self.fetchDisposable.set(fetchedMediaResource(mediaBox: self.context.account.postbox.mediaBox, userLocation: userLocation, userContentType: .image, reference: imageReference.resourceReference(largestSize.resource)).start())
}
} else {
self._ready.set(.single(Void()))
}
}
self.contextAndMedia = (self.context, imageReference.abstract)
self.footerContentNode.setShareMedia(imageReference.abstract)
}
func setFile(context: AccountContext, userLocation: MediaResourceUserLocation, fileReference: FileMediaReference) {
self.userLocation = userLocation
if self.contextAndMedia == nil || !self.contextAndMedia!.1.media.isEqual(to: fileReference.media) {
if let largestSize = fileReference.media.dimensions {
let displaySize = largestSize.cgSize.dividedByScreenScale()
self.imageNode.asyncLayout()(TransformImageArguments(corners: ImageCorners(), imageSize: displaySize, boundingSize: displaySize, intrinsicInsets: UIEdgeInsets()))()
self.imageNode.setSignal(chatMessageImageFile(account: context.account, userLocation: userLocation, fileReference: fileReference, thumbnail: false), dispatchOnDisplayLink: false)
self.zoomableContent = (largestSize.cgSize, self.imageNode)
} else {
self._ready.set(.single(Void()))
}
}
self.contextAndMedia = (context, fileReference.abstract)
self.footerContentNode.setShareMedia(fileReference.abstract)
}
override func animateIn(from node: (ASDisplayNode, CGRect, () -> (UIView?, UIView?)), addToTransitionSurface: (UIView) -> Void, completion: @escaping () -> Void) {
var transformedFrame = node.0.view.convert(node.0.view.bounds, to: self.imageNode.view)
let transformedSuperFrame = node.0.view.convert(node.0.view.bounds, to: self.imageNode.view.superview)
let transformedSelfFrame = node.0.view.convert(node.0.view.bounds, to: self.view)
let transformedCopyViewFinalFrame = self.imageNode.view.convert(self.imageNode.view.bounds, to: self.view)
let surfaceCopyView = node.2().0!
let copyView = node.2().0!
addToTransitionSurface(surfaceCopyView)
var transformedSurfaceFrame: CGRect?
var transformedSurfaceFinalFrame: CGRect?
if let contentSurface = surfaceCopyView.superview {
transformedSurfaceFrame = node.0.view.convert(node.0.view.bounds, to: contentSurface)
transformedSurfaceFinalFrame = self.imageNode.view.convert(self.imageNode.view.bounds, to: contentSurface)
}
if let transformedSurfaceFrame = transformedSurfaceFrame {
surfaceCopyView.frame = transformedSurfaceFrame
}
self.view.insertSubview(copyView, belowSubview: self.scrollNode.view)
copyView.frame = transformedSelfFrame
copyView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2, removeOnCompletion: false)
surfaceCopyView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, removeOnCompletion: false)
let positionDuration: Double = 0.21
copyView.layer.animatePosition(from: CGPoint(x: transformedSelfFrame.midX, y: transformedSelfFrame.midY), to: CGPoint(x: transformedCopyViewFinalFrame.midX, y: transformedCopyViewFinalFrame.midY), duration: positionDuration, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, completion: { [weak copyView] _ in
copyView?.removeFromSuperview()
})
let scale = CGSize(width: transformedCopyViewFinalFrame.size.width / transformedSelfFrame.size.width, height: transformedCopyViewFinalFrame.size.height / transformedSelfFrame.size.height)
copyView.layer.animate(from: NSValue(caTransform3D: CATransform3DIdentity), to: NSValue(caTransform3D: CATransform3DMakeScale(scale.width, scale.height, 1.0)), keyPath: "transform", timingFunction: kCAMediaTimingFunctionSpring, duration: 0.25, removeOnCompletion: false)
if let transformedSurfaceFrame = transformedSurfaceFrame, let transformedSurfaceFinalFrame = transformedSurfaceFinalFrame {
surfaceCopyView.layer.animatePosition(from: CGPoint(x: transformedSurfaceFrame.midX, y: transformedSurfaceFrame.midY), to: CGPoint(x: transformedCopyViewFinalFrame.midX, y: transformedCopyViewFinalFrame.midY), duration: positionDuration, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, completion: { [weak surfaceCopyView] _ in
surfaceCopyView?.removeFromSuperview()
})
let scale = CGSize(width: transformedSurfaceFinalFrame.size.width / transformedSurfaceFrame.size.width, height: transformedSurfaceFinalFrame.size.height / transformedSurfaceFrame.size.height)
surfaceCopyView.layer.animate(from: NSValue(caTransform3D: CATransform3DIdentity), to: NSValue(caTransform3D: CATransform3DMakeScale(scale.width, scale.height, 1.0)), keyPath: "transform", timingFunction: kCAMediaTimingFunctionSpring, duration: 0.25, removeOnCompletion: false)
}
self.imageNode.layer.animatePosition(from: CGPoint(x: transformedSuperFrame.midX, y: transformedSuperFrame.midY), to: self.imageNode.layer.position, duration: positionDuration, timingFunction: kCAMediaTimingFunctionSpring)
self.imageNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.1)
transformedFrame.origin = CGPoint()
self.imageNode.layer.animateBounds(from: transformedFrame, to: self.imageNode.layer.bounds, duration: 0.25, timingFunction: kCAMediaTimingFunctionSpring)
/*self.statusNodeContainer.layer.animatePosition(from: CGPoint(x: transformedSuperFrame.midX, y: transformedSuperFrame.midY), to: self.statusNodeContainer.position, duration: positionDuration, timingFunction: kCAMediaTimingFunctionSpring)
self.statusNodeContainer.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25, timingFunction: kCAMediaTimingFunctionSpring)
self.statusNodeContainer.layer.animateScale(from: 0.5, to: 1.0, duration: 0.25, timingFunction: kCAMediaTimingFunctionSpring)*/
}
override func animateOut(to node: (ASDisplayNode, CGRect, () -> (UIView?, UIView?)), addToTransitionSurface: (UIView) -> Void, completion: @escaping () -> Void) {
self.fetchDisposable.set(nil)
var transformedFrame = node.0.view.convert(node.0.view.bounds, to: self.imageNode.view)
let transformedSuperFrame = node.0.view.convert(node.0.view.bounds, to: self.imageNode.view.superview)
let transformedSelfFrame = node.0.view.convert(node.0.view.bounds, to: self.view)
let transformedCopyViewInitialFrame = self.imageNode.view.convert(self.imageNode.view.bounds, to: self.view)
var positionCompleted = false
var boundsCompleted = false
var copyCompleted = false
let copyView = node.2().0!
let surfaceCopyView = node.2().0!
addToTransitionSurface(surfaceCopyView)
var transformedSurfaceFrame: CGRect?
var transformedSurfaceCopyViewInitialFrame: CGRect?
if let contentSurface = surfaceCopyView.superview {
transformedSurfaceFrame = node.0.view.convert(node.0.view.bounds, to: contentSurface)
transformedSurfaceCopyViewInitialFrame = self.imageNode.view.convert(self.imageNode.view.bounds, to: contentSurface)
}
self.view.insertSubview(copyView, belowSubview: self.scrollNode.view)
copyView.frame = transformedSelfFrame
let intermediateCompletion = { [weak copyView, weak surfaceCopyView] in
if positionCompleted && boundsCompleted && copyCompleted {
copyView?.removeFromSuperview()
surfaceCopyView?.removeFromSuperview()
completion()
}
}
copyView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.08, removeOnCompletion: false)
surfaceCopyView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.025, removeOnCompletion: false)
copyView.layer.animatePosition(from: CGPoint(x: transformedCopyViewInitialFrame.midX, y: transformedCopyViewInitialFrame.midY), to: CGPoint(x: transformedSelfFrame.midX, y: transformedSelfFrame.midY), duration: 0.25, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false)
let scale = CGSize(width: transformedCopyViewInitialFrame.size.width / transformedSelfFrame.size.width, height: transformedCopyViewInitialFrame.size.height / transformedSelfFrame.size.height)
copyView.layer.animate(from: NSValue(caTransform3D: CATransform3DMakeScale(scale.width, scale.height, 1.0)), to: NSValue(caTransform3D: CATransform3DIdentity), keyPath: "transform", timingFunction: kCAMediaTimingFunctionSpring, duration: 0.25, removeOnCompletion: false, completion: { _ in
copyCompleted = true
intermediateCompletion()
})
if let transformedSurfaceFrame = transformedSurfaceFrame, let transformedCopyViewInitialFrame = transformedSurfaceCopyViewInitialFrame {
surfaceCopyView.layer.animatePosition(from: CGPoint(x: transformedCopyViewInitialFrame.midX, y: transformedCopyViewInitialFrame.midY), to: CGPoint(x: transformedSurfaceFrame.midX, y: transformedSurfaceFrame.midY), duration: 0.25, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false)
let scale = CGSize(width: transformedCopyViewInitialFrame.size.width / transformedSurfaceFrame.size.width, height: transformedCopyViewInitialFrame.size.height / transformedSurfaceFrame.size.height)
surfaceCopyView.layer.animate(from: NSValue(caTransform3D: CATransform3DMakeScale(scale.width, scale.height, 1.0)), to: NSValue(caTransform3D: CATransform3DIdentity), keyPath: "transform", timingFunction: kCAMediaTimingFunctionSpring, duration: 0.25, removeOnCompletion: false)
}
self.imageNode.layer.animatePosition(from: self.imageNode.layer.position, to: CGPoint(x: transformedSuperFrame.midX, y: transformedSuperFrame.midY), duration: 0.25, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, completion: { _ in
positionCompleted = true
intermediateCompletion()
})
self.imageNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.08, removeOnCompletion: false)
transformedFrame.origin = CGPoint()
self.imageNode.layer.animateBounds(from: self.imageNode.layer.bounds, to: transformedFrame, duration: 0.25, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, completion: { _ in
boundsCompleted = true
intermediateCompletion()
})
/*self.statusNodeContainer.layer.animatePosition(from: self.statusNodeContainer.position, to: CGPoint(x: transformedSuperFrame.midX, y: transformedSuperFrame.midY), duration: 0.25, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false)
self.statusNodeContainer.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, timingFunction: CAMediaTimingFunctionName.easeIn.rawValue, removeOnCompletion: false)*/
}
override func visibilityUpdated(isVisible: Bool) {
super.visibilityUpdated(isVisible: isVisible)
if let (context, media) = self.contextAndMedia, let fileReference = media.concrete(TelegramMediaFile.self) {
if isVisible {
self.fetchDisposable.set(fetchedMediaResource(mediaBox: context.account.postbox.mediaBox, userLocation: self.userLocation ?? .other, userContentType: .file, reference: fileReference.resourceReference(fileReference.media.resource)).start())
} else {
self.fetchDisposable.set(nil)
}
}
}
override func title() -> Signal<String, NoError> {
return self._title.get()
}
override func footerContent() -> Signal<(GalleryFooterContentNode?, GalleryOverlayContentNode?), NoError> {
return .single((self.footerContentNode, nil))
}
}
@@ -0,0 +1,49 @@
import Foundation
import UIKit
import TelegramCore
import AsyncDisplayKit
import TelegramPresentationData
import TelegramUIPreferences
import AccountContext
import ContextUI
public final class InstantPageAnchorItem: InstantPageItem {
public let wantsNode: Bool = false
public let separatesTiles: Bool = false
public let medias: [InstantPageMedia] = []
public let anchor: String
public var frame: CGRect
init(frame: CGRect, anchor: String) {
self.frame = frame
self.anchor = anchor
}
public func matchesAnchor(_ anchor: String) -> Bool {
return anchor == self.anchor
}
public func drawInTile(context: CGContext) {
}
public func node(context: AccountContext, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, theme: InstantPageTheme, sourceLocation: InstantPageSourceLocation, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, openPeer: @escaping (EnginePeer) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void, updateWebEmbedHeight: @escaping (CGFloat) -> Void, updateDetailsExpanded: @escaping (Bool) -> Void, currentExpandedDetails: [Int : Bool]?, getPreloadedResource: @escaping (String) -> Data?) -> InstantPageNode? {
return nil
}
public func matchesNode(_ node: InstantPageNode) -> Bool {
return false
}
public func linkSelectionRects(at point: CGPoint) -> [CGRect] {
return []
}
public func distanceThresholdGroup() -> Int? {
return nil
}
public func distanceThresholdWithGroupCount(_ count: Int) -> CGFloat {
return 0.0
}
}
@@ -0,0 +1,121 @@
import Foundation
import UIKit
import TelegramCore
import AsyncDisplayKit
import TelegramPresentationData
import TelegramUIPreferences
import AccountContext
import ContextUI
public final class InstantPageArticleItem: InstantPageItem {
public var frame: CGRect
public let wantsNode: Bool = true
public let separatesTiles: Bool = false
public let medias: [InstantPageMedia] = []
let userLocation: MediaResourceUserLocation
let webPage: TelegramMediaWebpage
let contentItems: [InstantPageItem]
let contentSize: CGSize
let cover: TelegramMediaImage?
let url: String
let webpageId: EngineMedia.Id
let rtl: Bool
let hasRTL: Bool
init(frame: CGRect, userLocation: MediaResourceUserLocation, webPage: TelegramMediaWebpage, contentItems: [InstantPageItem], contentSize: CGSize, cover: TelegramMediaImage?, url: String, webpageId: EngineMedia.Id, rtl: Bool, hasRTL: Bool) {
self.frame = frame
self.userLocation = userLocation
self.webPage = webPage
self.contentItems = contentItems
self.contentSize = contentSize
self.cover = cover
self.url = url
self.webpageId = webpageId
self.rtl = rtl
self.hasRTL = hasRTL
}
public func node(context: AccountContext, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, theme: InstantPageTheme, sourceLocation: InstantPageSourceLocation, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, openPeer: @escaping (EnginePeer) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void, updateWebEmbedHeight: @escaping (CGFloat) -> Void, updateDetailsExpanded: @escaping (Bool) -> Void, currentExpandedDetails: [Int : Bool]?, getPreloadedResource: @escaping (String) -> Data?) -> InstantPageNode? {
return InstantPageArticleNode(context: context, item: self, webPage: self.webPage, strings: strings, theme: theme, contentItems: self.contentItems, contentSize: self.contentSize, cover: self.cover, url: self.url, webpageId: self.webpageId, openUrl: openUrl)
}
public func matchesAnchor(_ anchor: String) -> Bool {
return false
}
public func matchesNode(_ node: InstantPageNode) -> Bool {
if let node = node as? InstantPageArticleNode {
return self === node.item
} else {
return false
}
}
public func distanceThresholdGroup() -> Int? {
return 7
}
public func distanceThresholdWithGroupCount(_ count: Int) -> CGFloat {
if count > 3 {
return 1000.0
} else {
return CGFloat.greatestFiniteMagnitude
}
}
public func drawInTile(context: CGContext) {
}
public func linkSelectionRects(at point: CGPoint) -> [CGRect] {
return []
}
}
func layoutArticleItem(theme: InstantPageTheme, userLocation: MediaResourceUserLocation, webPage: TelegramMediaWebpage, title: NSAttributedString, description: NSAttributedString, cover: TelegramMediaImage?, url: String, webpageId: EngineMedia.Id, boundingWidth: CGFloat, rtl: Bool) -> InstantPageArticleItem {
let inset: CGFloat = 17.0
let imageSpacing: CGFloat = 10.0
var sideInset = inset
let imageSize = CGSize(width: 44.0, height: 44.0)
if cover != nil {
sideInset += imageSize.width + imageSpacing
}
var availableLines: Int = 3
var contentHeight: CGFloat = 15.0 * 2.0
var hasRTL = false
var contentItems: [InstantPageItem] = []
let (titleTextItem, titleItems, titleSize) = layoutTextItemWithString(title, boundingWidth: boundingWidth - inset - sideInset, offset: CGPoint(x: inset, y: 15.0), maxNumberOfLines: availableLines)
contentItems.append(contentsOf: titleItems)
contentHeight += titleSize.height
if let textItem = titleTextItem {
availableLines -= textItem.lines.count
if textItem.containsRTL {
hasRTL = true
}
}
var descriptionInset = inset
if hasRTL && cover != nil {
descriptionInset += imageSize.width + imageSpacing
for var item in titleItems {
item.frame = item.frame.offsetBy(dx: imageSize.width + imageSpacing, dy: 0.0)
}
}
if availableLines > 0 {
let (descriptionTextItem, descriptionItems, descriptionSize) = layoutTextItemWithString(description, boundingWidth: boundingWidth - inset - sideInset, alignment: hasRTL ? .right : .natural, offset: CGPoint(x: descriptionInset, y: 15.0 + titleSize.height + 14.0), maxNumberOfLines: availableLines)
contentItems.append(contentsOf: descriptionItems)
if let textItem = descriptionTextItem {
if textItem.containsRTL || hasRTL {
hasRTL = true
}
}
contentHeight += descriptionSize.height + 14.0
}
let contentSize = CGSize(width: boundingWidth, height: contentHeight)
return InstantPageArticleItem(frame: CGRect(origin: CGPoint(), size: CGSize(width: boundingWidth, height: contentSize.height)), userLocation: userLocation, webPage: webPage, contentItems: contentItems, contentSize: contentSize, cover: cover, url: url, webpageId: webpageId, rtl: rtl || hasRTL, hasRTL: hasRTL)
}
@@ -0,0 +1,134 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import TelegramCore
import SwiftSignalKit
import TelegramPresentationData
import AccountContext
import PhotoResources
final class InstantPageArticleNode: ASDisplayNode, InstantPageNode {
let item: InstantPageArticleItem
private let highlightedBackgroundNode: ASDisplayNode
private let buttonNode: HighlightableButtonNode
private let contentTile: InstantPageTile
private let contentTileNode: InstantPageTileNode
private var imageNode: TransformImageNode?
let url: String
let webpageId: EngineMedia.Id
let cover: TelegramMediaImage?
private let openUrl: (InstantPageUrlItem) -> Void
private var fetchedDisposable = MetaDisposable()
init(context: AccountContext, item: InstantPageArticleItem, webPage: TelegramMediaWebpage, strings: PresentationStrings, theme: InstantPageTheme, contentItems: [InstantPageItem], contentSize: CGSize, cover: TelegramMediaImage?, url: String, webpageId: EngineMedia.Id, openUrl: @escaping (InstantPageUrlItem) -> Void) {
self.item = item
self.url = url
self.webpageId = webpageId
self.cover = cover
self.openUrl = openUrl
self.highlightedBackgroundNode = ASDisplayNode()
self.highlightedBackgroundNode.isLayerBacked = true
self.highlightedBackgroundNode.alpha = 0.0
self.buttonNode = HighlightableButtonNode()
self.contentTile = InstantPageTile(frame: CGRect(x: 0.0, y: 0.0, width: contentSize.width, height: contentSize.height))
self.contentTile.items.append(contentsOf: contentItems)
self.contentTileNode = InstantPageTileNode(tile: self.contentTile, backgroundColor: .clear)
super.init()
self.addSubnode(self.highlightedBackgroundNode)
self.addSubnode(self.buttonNode)
self.addSubnode(self.contentTileNode)
if let image = cover {
let imageNode = TransformImageNode()
imageNode.isUserInteractionEnabled = false
let imageReference = ImageMediaReference.webPage(webPage: WebpageReference(webPage), media: image)
imageNode.setSignal(chatMessagePhoto(postbox: context.account.postbox, userLocation: item.userLocation, photoReference: imageReference))
self.fetchedDisposable.set(chatMessagePhotoInteractiveFetched(context: context, userLocation: item.userLocation, photoReference: imageReference, displayAtSize: nil, storeToDownloadsPeerId: nil).start())
self.imageNode = imageNode
self.addSubnode(imageNode)
}
self.buttonNode.addTarget(self, action: #selector(self.buttonPressed), forControlEvents: .touchUpInside)
self.buttonNode.highligthedChanged = { [weak self] highlighted in
if let strongSelf = self {
if highlighted {
strongSelf.highlightedBackgroundNode.layer.removeAnimation(forKey: "opacity")
strongSelf.highlightedBackgroundNode.alpha = 1.0
} else {
strongSelf.highlightedBackgroundNode.alpha = 0.0
strongSelf.highlightedBackgroundNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2)
}
}
}
self.update(strings: strings, theme: theme)
}
deinit {
self.fetchedDisposable.dispose()
}
@objc func buttonPressed() {
self.openUrl(InstantPageUrlItem(url: self.url, webpageId: self.webpageId))
}
override func layout() {
super.layout()
let size = self.bounds.size
let inset: CGFloat = 17.0
let imageSize = CGSize(width: 44.0, height: 44.0)
self.highlightedBackgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -UIScreenPixel), size: CGSize(width: size.width, height: size.height + UIScreenPixel))
self.buttonNode.frame = CGRect(origin: CGPoint(), size: size)
self.contentTileNode.frame = self.bounds
if let imageNode = self.imageNode, let image = self.cover, let largest = largestImageRepresentation(image.representations) {
let size = largest.dimensions.cgSize.aspectFilled(imageSize)
let boundingSize = imageSize
let makeLayout = imageNode.asyncLayout()
let apply = makeLayout(TransformImageArguments(corners: ImageCorners(radius: 5.0), imageSize: size, boundingSize: boundingSize, intrinsicInsets: UIEdgeInsets()))
apply()
}
if let imageNode = self.imageNode {
if item.hasRTL {
imageNode.frame = CGRect(origin: CGPoint(x: inset, y: 11.0), size: imageSize)
} else {
imageNode.frame = CGRect(origin: CGPoint(x: size.width - inset - imageSize.width, y: 11.0), size: imageSize)
}
}
}
func updateIsVisible(_ isVisible: Bool) {
}
func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition) {
}
func transitionNode(media: InstantPageMedia) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? {
return nil
}
func updateHiddenMedia(media: InstantPageMedia?) {
}
func update(strings: PresentationStrings, theme: InstantPageTheme) {
self.highlightedBackgroundNode.backgroundColor = theme.panelHighlightedBackgroundColor
}
}
@@ -0,0 +1,61 @@
import Foundation
import UIKit
import TelegramCore
import AsyncDisplayKit
import TelegramPresentationData
import TelegramUIPreferences
import AccountContext
import ContextUI
public final class InstantPageAudioItem: InstantPageItem {
public var frame: CGRect
public let wantsNode: Bool = true
public let separatesTiles: Bool = false
public let medias: [InstantPageMedia]
let media: InstantPageMedia
let webpage: TelegramMediaWebpage
public init(frame: CGRect, media: InstantPageMedia, webpage: TelegramMediaWebpage) {
self.frame = frame
self.media = media
self.webpage = webpage
self.medias = [media]
}
public func node(context: AccountContext, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, theme: InstantPageTheme, sourceLocation: InstantPageSourceLocation, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, openPeer: @escaping (EnginePeer) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void, updateWebEmbedHeight: @escaping (CGFloat) -> Void, updateDetailsExpanded: @escaping (Bool) -> Void, currentExpandedDetails: [Int : Bool]?, getPreloadedResource: @escaping (String) -> Data?) -> InstantPageNode? {
return InstantPageAudioNode(context: context, strings: strings, theme: theme, webPage: self.webpage, media: self.media, openMedia: openMedia)
}
public func matchesAnchor(_ anchor: String) -> Bool {
return false
}
public func matchesNode(_ node: InstantPageNode) -> Bool {
if let node = node as? InstantPageAudioNode {
return self.media == node.media
} else {
return false
}
}
public func distanceThresholdGroup() -> Int? {
return 4
}
public func distanceThresholdWithGroupCount(_ count: Int) -> CGFloat {
if count > 3 {
return 1000.0
} else {
return CGFloat.greatestFiniteMagnitude
}
}
public func linkSelectionRects(at point: CGPoint) -> [CGRect] {
return []
}
public func drawInTile(context: CGContext) {
}
}
@@ -0,0 +1,284 @@
import Foundation
import UIKit
import TelegramCore
import SwiftSignalKit
import AsyncDisplayKit
import Display
import TelegramPresentationData
import UniversalMediaPlayer
import AccountContext
import RadialStatusNode
private func generatePlayButton(color: UIColor) -> UIImage? {
return generateImage(CGSize(width: 48.0, height: 48.0), rotatedContext: { size, context in
context.clear(CGRect(origin: CGPoint(), size: size))
context.setFillColor(color.cgColor)
context.setStrokeColor(color.cgColor)
context.setLineWidth(1.65)
let _ = try? drawSvgPath(context, path: "M24,0.825 C11.2008009,0.825 0.825,11.2008009 0.825,24 C0.825,36.7991991 11.2008009,47.175 24,47.175 C36.7991991,47.175 47.175,36.7991991 47.175,24 C47.175,11.2008009 36.7991991,0.825 24,0.825 S ")
let _ = try? drawSvgPath(context, path: "M19,16.8681954 L19,32.1318046 L19,32.1318046 C19,32.6785665 19.4432381,33.1218046 19.99,33.1218046 C20.1882157,33.1218046 20.3818677,33.0623041 20.5458864,32.9510057 L31.7927564,25.319201 L31.7927564,25.319201 C32.2451886,25.0121934 32.3630786,24.3965458 32.056071,23.9441136 C31.9857457,23.8404762 31.8963938,23.7511243 31.7927564,23.680799 L20.5458864,16.0489943 L20.5458864,16.0489943 C20.0934542,15.7419868 19.4778066,15.8598767 19.170799,16.312309 C19.0595006,16.4763277 19,16.6699796 19,16.8681954 Z ")
})
}
private func generatePauseButton(color: UIColor) -> UIImage? {
return generateImage(CGSize(width: 48.0, height: 48.0), rotatedContext: { size, context in
context.clear(CGRect(origin: CGPoint(), size: size))
context.setFillColor(color.cgColor)
context.setStrokeColor(color.cgColor)
context.setLineWidth(1.65)
let _ = try? drawSvgPath(context, path: "M24,0.825 C11.2008009,0.825 0.825,11.2008009 0.825,24 C0.825,36.7991991 11.2008009,47.175 24,47.175 C36.7991991,47.175 47.175,36.7991991 47.175,24 C47.175,11.2008009 36.7991991,0.825 24,0.825 S ")
let _ = try? drawSvgPath(context, path: "M17,16 L21,16 C21.5567619,16 22,16.4521029 22,17 L22,32 C22,32.5478971 21.5567619,33 21,33 L17,33 C16.4432381,33 16,32.5478971 16,32 L16,17 C16,16.4521029 16.4432381,16 17,16 Z ")
let _ = try? drawSvgPath(context, path: "M26.99,16 L31.01,16 C31.5567619,16 32,16.4432381 32,16.99 L32,32.01 C32,32.5567619 31.5567619,33 31.01,33 L26.99,33 C26.4432381,33 26,32.5567619 26,32.01 L26,16.99 C26,16.4432381 26.4432381,16 26.99,16 Z ")
})
}
private func titleString(media: InstantPageMedia, theme: InstantPageTheme, strings: PresentationStrings) -> NSAttributedString {
let string = NSMutableAttributedString()
if case let .file(file) = media.media {
loop: for attribute in file.attributes {
if case let .Audio(isVoice, _, title, performer, _) = attribute, !isVoice {
let titleText: String = title ?? strings.MediaPlayer_UnknownTrack
let subtitleText: String = performer ?? strings.MediaPlayer_UnknownArtist
let titleString = NSAttributedString(string: titleText, font: Font.semibold(17.0), textColor: theme.textCategories.paragraph.color)
let subtitleString = NSAttributedString(string: "\(subtitleText)", font: Font.regular(17.0), textColor: theme.textCategories.paragraph.color)
string.append(titleString)
string.append(subtitleString)
break loop
}
}
}
return string
}
final class InstantPageAudioNode: ASDisplayNode, InstantPageNode {
private let context: AccountContext
let media: InstantPageMedia
private let openMedia: (InstantPageMedia) -> Void
private var strings: PresentationStrings
private var theme: InstantPageTheme
private let playlistType: MediaManagerPlayerType
private var playImage: UIImage
private var pauseImage: UIImage
private let buttonNode: HighlightableButtonNode
private let statusNode: RadialStatusNode
private let titleNode: ASTextNode
private let scrubbingNode: MediaPlayerScrubbingNode
private var playbackStatusDisposable: Disposable?
private var playerStatusDisposable: Disposable?
private var isPlaying: Bool = false
private var playbackState: SharedMediaPlayerItemPlaybackState?
init(context: AccountContext, strings: PresentationStrings, theme: InstantPageTheme, webPage: TelegramMediaWebpage, media: InstantPageMedia, openMedia: @escaping (InstantPageMedia) -> Void) {
self.context = context
self.strings = strings
self.theme = theme
self.media = media
self.openMedia = openMedia
self.playImage = generatePlayButton(color: theme.textCategories.paragraph.color)!
self.pauseImage = generatePauseButton(color: theme.textCategories.paragraph.color)!
self.buttonNode = HighlightableButtonNode()
self.statusNode = RadialStatusNode(backgroundNodeColor: .clear)
self.titleNode = ASTextNode()
self.titleNode.maximumNumberOfLines = 1
var backgroundAlpha: CGFloat = 0.1
var brightness: CGFloat = 0.0
theme.textCategories.paragraph.color.getHue(nil, saturation: nil, brightness: &brightness, alpha: nil)
if brightness > 0.5 {
backgroundAlpha = 0.4
}
self.scrubbingNode = MediaPlayerScrubbingNode(content: .standard(lineHeight: 3.0, lineCap: .round, scrubberHandle: .line, backgroundColor: theme.textCategories.paragraph.color.withAlphaComponent(backgroundAlpha), foregroundColor: theme.textCategories.paragraph.color, bufferingColor: theme.textCategories.paragraph.color.withAlphaComponent(0.5), chapters: []))
let playlistType: MediaManagerPlayerType
if case let .file(file) = self.media.media {
playlistType = file.isVoice ? .voice : .music
} else {
playlistType = .music
}
self.playlistType = playlistType
super.init()
self.titleNode.attributedText = titleString(media: media, theme: theme, strings: strings)
self.addSubnode(self.statusNode)
self.addSubnode(self.buttonNode)
self.addSubnode(self.titleNode)
self.addSubnode(self.scrubbingNode)
self.statusNode.transitionToState(RadialStatusNodeState.customIcon(self.playImage), animated: false, completion: {})
self.buttonNode.addTarget(self, action: #selector(self.buttonPressed), forControlEvents: .touchUpInside)
self.buttonNode.highligthedChanged = { [weak self] highlighted in
if let strongSelf = self {
if highlighted {
strongSelf.statusNode.layer.removeAnimation(forKey: "opacity")
strongSelf.statusNode.alpha = 0.4
} else {
strongSelf.statusNode.alpha = 1.0
strongSelf.statusNode.layer.animateAlpha(from: 0.4, to: 1.0, duration: 0.2)
}
}
}
self.scrubbingNode.seek = { [weak self] timestamp in
if let strongSelf = self {
if let _ = strongSelf.playbackState {
strongSelf.context.sharedContext.mediaManager.playlistControl(.seek(timestamp), type: strongSelf.playlistType)
}
}
}
/*if let applicationContext = account.applicationContext as? TelegramApplicationContext, let (playlistId, itemId) = instantPageAudioPlaylistAndItemIds(webpage: webpage, media: self.media) {
let playbackStatus: Signal<MediaPlayerPlaybackStatus?, NoError> = applicationContext.mediaManager.filteredPlaylistPlayerStateAndStatus(playlistId: playlistId, itemId: itemId)
|> mapToSignal { status -> Signal<MediaPlayerPlaybackStatus?, NoError> in
if let status = status, let playbackStatus = status.status {
return playbackStatus
|> map { playbackStatus -> MediaPlayerPlaybackStatus? in
return playbackStatus.status
}
|> distinctUntilChanged(isEqual: { lhs, rhs in
return lhs == rhs
})
} else {
return .single(nil)
}
}*/
/*self.playbackStatusDisposable = (playbackStatus |> deliverOnMainQueue).start(next: { [weak self] status in
if let strongSelf = self {
var isPlaying = false
if let status = status {
switch status {
case .paused:
break
case let .buffering(_, whilePlaying):
isPlaying = whilePlaying
case .playing:
isPlaying = true
}
}
if strongSelf.isPlaying != isPlaying {
strongSelf.isPlaying = isPlaying
if isPlaying {
strongSelf.statusNode.transitionToState(RadialStatusNodeState.customIcon(strongSelf.pauseImage), animated: false, completion: {})
} else {
strongSelf.statusNode.transitionToState(RadialStatusNodeState.customIcon(strongSelf.playImage), animated: false, completion: {})
}
}
}
})*/
self.scrubbingNode.status = context.sharedContext.mediaManager.filteredPlaylistState(accountId: context.account.id, playlistId: InstantPageMediaPlaylistId(webpageId: webPage.webpageId), itemId: InstantPageMediaPlaylistItemId(index: self.media.index), type: self.playlistType)
|> map { playbackState -> MediaPlayerStatus in
return playbackState?.status ?? MediaPlayerStatus(generationTimestamp: 0.0, duration: 0.0, dimensions: CGSize(), timestamp: 0.0, baseRate: 1.0, seekId: 0, status: .paused, soundEnabled: true)
}
self.playerStatusDisposable = (context.sharedContext.mediaManager.filteredPlaylistState(accountId: context.account.id, playlistId: InstantPageMediaPlaylistId(webpageId: webPage.webpageId), itemId: InstantPageMediaPlaylistItemId(index: self.media.index), type: playlistType)
|> deliverOnMainQueue).start(next: { [weak self] playbackState in
guard let strongSelf = self else {
return
}
strongSelf.playbackState = playbackState
let isPlaying: Bool
if let status = playbackState?.status {
if case .playing = status.status {
isPlaying = true
} else {
isPlaying = false
}
} else {
isPlaying = false
}
if strongSelf.isPlaying != isPlaying {
strongSelf.isPlaying = isPlaying
if isPlaying {
strongSelf.statusNode.transitionToState(RadialStatusNodeState.customIcon(strongSelf.pauseImage), animated: false, completion: {})
} else {
strongSelf.statusNode.transitionToState(RadialStatusNodeState.customIcon(strongSelf.playImage), animated: false, completion: {})
}
}
})
}
deinit {
self.playerStatusDisposable?.dispose()
}
func update(strings: PresentationStrings, theme: InstantPageTheme) {
if self.strings !== strings || self.theme !== theme {
let themeUpdated = self.theme !== theme
self.strings = strings
self.theme = theme
if themeUpdated {
self.playImage = generatePlayButton(color: theme.textCategories.paragraph.color)!
self.pauseImage = generatePauseButton(color: theme.textCategories.paragraph.color)!
self.titleNode.attributedText = titleString(media: self.media, theme: theme, strings: strings)
var brightness: CGFloat = 0.0
theme.textCategories.paragraph.color.getHue(nil, saturation: nil, brightness: &brightness, alpha: nil)
self.setNeedsLayout()
}
}
}
func transitionNode(media: InstantPageMedia) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? {
return nil
}
func updateHiddenMedia(media: InstantPageMedia?) {
}
func updateIsVisible(_ isVisible: Bool) {
}
func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition) {
}
@objc func buttonPressed() {
if let _ = self.playbackState {
self.context.sharedContext.mediaManager.playlistControl(.playback(.togglePlayPause), type: self.playlistType)
} else {
self.openMedia(self.media)
}
}
override func layout() {
super.layout()
let size = self.bounds.size
let insets = UIEdgeInsets(top: 18.0, left: 17.0, bottom: 18.0, right: 17.0)
let leftInset: CGFloat = 46.0 + 10.0
let rightInset: CGFloat = 0.0
let maxTitleWidth = max(1.0, size.width - insets.left - leftInset - rightInset - insets.right)
let titleSize = self.titleNode.measure(CGSize(width: maxTitleWidth, height: size.height))
self.titleNode.frame = CGRect(origin: CGPoint(x: insets.left + leftInset, y: 2.0), size: titleSize)
self.buttonNode.frame = CGRect(origin: CGPoint(x: insets.left, y: 0.0), size: CGSize(width: 48.0, height: 48.0))
self.statusNode.frame = CGRect(origin: CGPoint(x: insets.left, y: 0.0), size: CGSize(width: 48.0, height: 48.0))
var topOffset: CGFloat = 0.0
if self.titleNode.attributedText == nil || self.titleNode.attributedText!.length == 0 {
topOffset = -10.0
}
let leftScrubberInset: CGFloat = insets.left + 46.0 + 10.0
let rightScrubberInset: CGFloat = insets.right
self.scrubbingNode.frame = CGRect(origin: CGPoint(x: leftScrubberInset, y: 26.0 + topOffset), size: CGSize(width: size.width - leftScrubberInset - rightScrubberInset, height: 15.0))
}
}
@@ -0,0 +1,459 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import TelegramCore
import SwiftSignalKit
import TelegramPresentationData
import TelegramUIPreferences
import AccountContext
import ContextUI
public final class InstantPageContentNode : ASDisplayNode {
private let context: AccountContext
private let strings: PresentationStrings
private let nameDisplayOrder: PresentationPersonNameOrder
private let sourceLocation: InstantPageSourceLocation
private let theme: InstantPageTheme
private let openMedia: (InstantPageMedia) -> Void
private let longPressMedia: (InstantPageMedia) -> Void
private let openPeer: (EnginePeer) -> Void
private let openUrl: (InstantPageUrlItem) -> Void
private let activatePinchPreview: ((PinchSourceContainerNode) -> Void)?
private let pinchPreviewFinished: ((InstantPageNode) -> Void)?
private let getPreloadedResource: (String) -> Data?
var currentLayoutTiles: [InstantPageTile] = []
var currentLayoutItemsWithNodes: [InstantPageItem] = []
var distanceThresholdGroupCount: [Int: Int] = [:]
var visibleTiles: [Int: InstantPageTileNode] = [:]
var visibleItemsWithNodes: [Int: InstantPageNode] = [:]
var currentWebEmbedHeights: [Int : CGFloat] = [:]
var currentExpandedDetails: [Int : Bool]?
var currentDetailsItems: [InstantPageDetailsItem] = []
var requestLayoutUpdate: ((Bool) -> Void)?
var currentLayout: InstantPageLayout
let contentSize: CGSize
let inOverlayPanel: Bool
private var previousVisibleBounds: CGRect?
init(context: AccountContext, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, sourceLocation: InstantPageSourceLocation, theme: InstantPageTheme, items: [InstantPageItem], contentSize: CGSize, inOverlayPanel: Bool = false, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, openPeer: @escaping (EnginePeer) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void, getPreloadedResource: @escaping (String) -> Data?) {
self.context = context
self.strings = strings
self.nameDisplayOrder = nameDisplayOrder
self.sourceLocation = sourceLocation
self.theme = theme
self.openMedia = openMedia
self.longPressMedia = longPressMedia
self.activatePinchPreview = activatePinchPreview
self.pinchPreviewFinished = pinchPreviewFinished
self.openPeer = openPeer
self.openUrl = openUrl
self.getPreloadedResource = getPreloadedResource
self.currentLayout = InstantPageLayout(origin: CGPoint(), contentSize: contentSize, items: items)
self.contentSize = contentSize
self.inOverlayPanel = inOverlayPanel
super.init()
self.updateLayout()
}
private func updateLayout() {
for (_, tileNode) in self.visibleTiles {
tileNode.removeFromSupernode()
}
self.visibleTiles.removeAll()
let currentLayoutTiles = instantPageTilesFromLayout(currentLayout, boundingWidth: contentSize.width)
var currentDetailsItems: [InstantPageDetailsItem] = []
var currentLayoutItemsWithViews: [InstantPageItem] = []
var distanceThresholdGroupCount: [Int : Int] = [:]
var expandedDetails: [Int : Bool] = [:]
var detailsIndex = -1
for item in self.currentLayout.items {
if item.wantsNode {
currentLayoutItemsWithViews.append(item)
if let group = item.distanceThresholdGroup() {
let count: Int
if let currentCount = distanceThresholdGroupCount[Int(group)] {
count = currentCount
} else {
count = 0
}
distanceThresholdGroupCount[Int(group)] = count + 1
}
if let detailsItem = item as? InstantPageDetailsItem {
detailsIndex += 1
expandedDetails[detailsIndex] = detailsItem.initiallyExpanded
currentDetailsItems.append(detailsItem)
}
}
}
if self.currentExpandedDetails == nil {
self.currentExpandedDetails = expandedDetails
}
self.currentLayoutTiles = currentLayoutTiles
self.currentLayoutItemsWithNodes = currentLayoutItemsWithViews
self.currentDetailsItems = currentDetailsItems
self.distanceThresholdGroupCount = distanceThresholdGroupCount
}
var effectiveContentSize: CGSize {
var contentSize = self.contentSize
for item in self.currentDetailsItems {
let expanded = self.currentExpandedDetails?[item.index] ?? item.initiallyExpanded
contentSize.height += -item.frame.height + (expanded ? self.effectiveSizeForDetails(item).height : item.titleHeight)
}
return contentSize
}
func updateVisibleItems(visibleBounds: CGRect, animated: Bool = false) {
var visibleTileIndices = Set<Int>()
var visibleItemIndices = Set<Int>()
self.previousVisibleBounds = visibleBounds
var topNode: ASDisplayNode?
let topTileNode = topNode
if let scrollSubnodes = self.subnodes {
for node in scrollSubnodes.reversed() {
if let node = node as? InstantPageTileNode {
topNode = node
break
}
}
}
var collapseOffset: CGFloat = 0.0
let transition: ContainedViewLayoutTransition
if animated {
transition = .animated(duration: 0.3, curve: .spring)
} else {
transition = .immediate
}
var itemIndex = -1
var embedIndex = -1
var detailsIndex = -1
for item in self.currentLayoutItemsWithNodes {
itemIndex += 1
if item is InstantPageWebEmbedItem {
embedIndex += 1
}
if item is InstantPageDetailsItem {
detailsIndex += 1
}
var itemThreshold: CGFloat = 0.0
if let group = item.distanceThresholdGroup() {
var count: Int = 0
if let currentCount = self.distanceThresholdGroupCount[group] {
count = currentCount
}
itemThreshold = item.distanceThresholdWithGroupCount(count)
}
var itemFrame = item.frame.offsetBy(dx: 0.0, dy: -collapseOffset)
var thresholdedItemFrame = itemFrame
thresholdedItemFrame.origin.y -= itemThreshold
thresholdedItemFrame.size.height += itemThreshold * 2.0
if let detailsItem = item as? InstantPageDetailsItem, let expanded = self.currentExpandedDetails?[detailsIndex] {
let height = expanded ? self.effectiveSizeForDetails(detailsItem).height : detailsItem.titleHeight
collapseOffset += itemFrame.height - height
itemFrame = CGRect(origin: itemFrame.origin, size: CGSize(width: itemFrame.width, height: height))
}
if visibleBounds.intersects(thresholdedItemFrame) {
visibleItemIndices.insert(itemIndex)
var itemNode = self.visibleItemsWithNodes[itemIndex]
if let currentItemNode = itemNode {
if !item.matchesNode(currentItemNode) {
currentItemNode.removeFromSupernode()
self.visibleItemsWithNodes.removeValue(forKey: itemIndex)
itemNode = nil
}
}
if itemNode == nil {
let itemIndex = itemIndex
let detailsIndex = detailsIndex
if let newNode = item.node(context: self.context, strings: self.strings, nameDisplayOrder: self.nameDisplayOrder, theme: theme, sourceLocation: self.sourceLocation, openMedia: { [weak self] media in
self?.openMedia(media)
}, longPressMedia: { [weak self] media in
self?.longPressMedia(media)
},
activatePinchPreview: { [weak self] node in
self?.activatePinchPreview?(node)
},
pinchPreviewFinished: { [weak self] node in
self?.pinchPreviewFinished?(node)
},
openPeer: { [weak self] peerId in
self?.openPeer(peerId)
}, openUrl: { [weak self] url in
self?.openUrl(url)
}, updateWebEmbedHeight: { _ in
}, updateDetailsExpanded: { [weak self] expanded in
self?.updateDetailsExpanded(detailsIndex, expanded)
}, currentExpandedDetails: self.currentExpandedDetails, getPreloadedResource: self.getPreloadedResource) {
newNode.frame = itemFrame
newNode.updateLayout(size: itemFrame.size, transition: transition)
if let topNode = topNode {
self.insertSubnode(newNode, aboveSubnode: topNode)
} else {
self.insertSubnode(newNode, at: 0)
}
topNode = newNode
self.visibleItemsWithNodes[itemIndex] = newNode
itemNode = newNode
if let itemNode = itemNode as? InstantPageDetailsNode {
itemNode.requestLayoutUpdate = { [weak self] animated in
self?.requestLayoutUpdate?(animated)
}
}
}
} else {
if let itemNode = itemNode, itemNode.frame != itemFrame {
transition.updateFrame(node: itemNode, frame: itemFrame)
itemNode.updateLayout(size: itemFrame.size, transition: transition)
}
}
if let itemNode = itemNode as? InstantPageDetailsNode {
itemNode.updateVisibleItems(visibleBounds: visibleBounds.offsetBy(dx: -itemNode.frame.minX, dy: -itemNode.frame.minY), animated: animated)
}
}
}
topNode = topTileNode
var tileIndex = -1
for tile in self.currentLayoutTiles {
tileIndex += 1
let tileFrame = effectiveFrameForTile(tile)
var tileVisibleFrame = tileFrame
tileVisibleFrame.origin.y -= 400.0
tileVisibleFrame.size.height += 400.0 * 2.0
if tileVisibleFrame.intersects(visibleBounds) {
visibleTileIndices.insert(tileIndex)
if self.visibleTiles[tileIndex] == nil {
let tileNode = InstantPageTileNode(tile: tile, backgroundColor: self.inOverlayPanel ? self.theme.overlayPanelColor : self.theme.pageBackgroundColor)
tileNode.frame = tileFrame
if let topNode = topNode {
self.insertSubnode(tileNode, aboveSubnode: topNode)
} else {
self.insertSubnode(tileNode, at: 0)
}
topNode = tileNode
self.visibleTiles[tileIndex] = tileNode
} else {
if visibleTiles[tileIndex]!.frame != tileFrame {
transition.updateFrame(node: self.visibleTiles[tileIndex]!, frame: tileFrame)
}
}
}
}
var removeTileIndices: [Int] = []
for (index, tileNode) in self.visibleTiles {
if !visibleTileIndices.contains(index) {
removeTileIndices.append(index)
tileNode.removeFromSupernode()
}
}
for index in removeTileIndices {
self.visibleTiles.removeValue(forKey: index)
}
var removeItemIndices: [Int] = []
for (index, itemNode) in self.visibleItemsWithNodes {
if !visibleItemIndices.contains(index) {
removeItemIndices.append(index)
itemNode.removeFromSupernode()
} else {
var itemFrame = itemNode.frame
let itemThreshold: CGFloat = 200.0
itemFrame.origin.y -= itemThreshold
itemFrame.size.height += itemThreshold * 2.0
itemNode.updateIsVisible(visibleBounds.intersects(itemFrame))
}
}
for index in removeItemIndices {
self.visibleItemsWithNodes.removeValue(forKey: index)
}
}
private func updateWebEmbedHeight(_ index: Int, _ height: CGFloat) {
// let currentHeight = self.currentWebEmbedHeights[index]
// if height != currentHeight {
// if let currentHeight = currentHeight, currentHeight > height {
// return
// }
// self.currentWebEmbedHeights[index] = height
//
// let signal: Signal<Void, NoError> = (.complete() |> delay(0.08, queue: Queue.mainQueue()))
// self.updateLayoutDisposable.set(signal.start(completed: { [weak self] in
// if let strongSelf = self {
// strongSelf.updateLayout()
// strongSelf.updateVisibleItems()
// }
// }))
// }
}
public func updateDetailsExpanded(_ index: Int, _ expanded: Bool, animated: Bool = true, requestLayout: Bool = true) {
if var currentExpandedDetails = self.currentExpandedDetails {
currentExpandedDetails[index] = expanded
self.currentExpandedDetails = currentExpandedDetails
}
self.requestLayoutUpdate?(animated)
}
func transitionNode(media: InstantPageMedia) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? {
for (_, itemNode) in self.visibleItemsWithNodes {
if let transitionNode = itemNode.transitionNode(media: media) {
return transitionNode
}
}
return nil
}
func updateHiddenMedia(media: InstantPageMedia?) {
for (_, itemNode) in self.visibleItemsWithNodes {
itemNode.updateHiddenMedia(media: media)
}
}
func scrollableContentOffset(item: InstantPageScrollableItem) -> CGPoint {
var contentOffset = CGPoint()
for (_, itemNode) in self.visibleItemsWithNodes {
if let itemNode = itemNode as? InstantPageScrollableNode, itemNode.item === item {
contentOffset = itemNode.contentOffset
break
}
}
return contentOffset
}
public func nodeForDetailsItem(_ item: InstantPageDetailsItem) -> InstantPageDetailsNode? {
for (_, itemNode) in self.visibleItemsWithNodes {
if let detailsNode = itemNode as? InstantPageDetailsNode, detailsNode.item === item {
return detailsNode
}
}
return nil
}
private func effectiveSizeForDetails(_ item: InstantPageDetailsItem) -> CGSize {
if let node = nodeForDetailsItem(item) {
return CGSize(width: item.frame.width, height: node.effectiveContentSize.height + item.titleHeight)
} else {
return item.frame.size
}
}
private func effectiveFrameForTile(_ tile: InstantPageTile) -> CGRect {
let layoutOrigin = tile.frame.origin
var origin = layoutOrigin
for item in self.currentDetailsItems {
let expanded = self.currentExpandedDetails?[item.index] ?? item.initiallyExpanded
if layoutOrigin.y >= item.frame.maxY {
let height = expanded ? self.effectiveSizeForDetails(item).height : item.titleHeight
origin.y += height - item.frame.height
}
}
return CGRect(origin: origin, size: tile.frame.size)
}
func effectiveFrameForItem(_ item: InstantPageItem) -> CGRect {
let layoutOrigin = item.frame.origin
var origin = layoutOrigin
for item in self.currentDetailsItems {
let expanded = self.currentExpandedDetails?[item.index] ?? item.initiallyExpanded
if layoutOrigin.y >= item.frame.maxY {
let height = expanded ? self.effectiveSizeForDetails(item).height : item.titleHeight
origin.y += height - item.frame.height
}
}
if let item = item as? InstantPageDetailsItem {
let expanded = self.currentExpandedDetails?[item.index] ?? item.initiallyExpanded
let height = expanded ? self.effectiveSizeForDetails(item).height : item.titleHeight
return CGRect(origin: origin, size: CGSize(width: item.frame.width, height: height))
} else {
return CGRect(origin: origin, size: item.frame.size)
}
}
func textItemAtLocation(_ location: CGPoint) -> (InstantPageTextItem, CGPoint)? {
for item in self.currentLayout.items {
let itemFrame = self.effectiveFrameForItem(item)
if itemFrame.contains(location) {
if let item = item as? InstantPageTextItem, item.selectable {
return (item, CGPoint(x: itemFrame.minX - item.frame.minX, y: itemFrame.minY - item.frame.minY))
} else if let item = item as? InstantPageScrollableItem {
let contentOffset = scrollableContentOffset(item: item)
if let (textItem, parentOffset) = item.textItemAtLocation(location.offsetBy(dx: -itemFrame.minX + contentOffset.x, dy: -itemFrame.minY)) {
return (textItem, itemFrame.origin.offsetBy(dx: parentOffset.x - contentOffset.x, dy: parentOffset.y))
}
} else if let item = item as? InstantPageDetailsItem {
for (_, itemNode) in self.visibleItemsWithNodes {
if let itemNode = itemNode as? InstantPageDetailsNode, itemNode.item === item {
if let (textItem, parentOffset) = itemNode.textItemAtLocation(location.offsetBy(dx: -itemFrame.minX, dy: -itemFrame.minY)) {
return (textItem, itemFrame.origin.offsetBy(dx: parentOffset.x, dy: parentOffset.y))
}
}
}
}
}
}
return nil
}
func tapActionAtPoint(_ point: CGPoint) -> TapLongTapOrDoubleTapGestureRecognizerAction {
for item in self.currentLayout.items {
let frame = self.effectiveFrameForItem(item)
if frame.contains(point) {
if item is InstantPagePeerReferenceItem {
return .fail
} else if item is InstantPageAudioItem {
return .fail
} else if item is InstantPageArticleItem {
return .fail
} else if item is InstantPageFeedbackItem {
return .fail
} else if let item = item as? InstantPageDetailsItem {
for (_, itemNode) in self.visibleItemsWithNodes {
if let itemNode = itemNode as? InstantPageDetailsNode, itemNode.item === item {
return itemNode.tapActionAtPoint(point.offsetBy(dx: -itemNode.frame.minX, dy: -itemNode.frame.minY))
}
}
}
break
}
}
return .waitForSingleTap
}
}
@@ -0,0 +1,186 @@
import Foundation
import UIKit
import TelegramCore
import Postbox
import SwiftSignalKit
import Display
import TelegramPresentationData
import TelegramUIPreferences
import AccountContext
public func instantPageAndAnchor(message: Message) -> (TelegramMediaWebpage, String?)? {
for media in message.media {
if let webpage = media as? TelegramMediaWebpage, case let .Loaded(content) = webpage.content {
if let _ = content.instantPage {
var textUrl: String?
if let pageUrl = URL(string: content.url) {
inner: for attribute in message.attributes {
if let attribute = attribute as? TextEntitiesMessageAttribute {
for entity in attribute.entities {
switch entity.type {
case let .TextUrl(url):
if let parsedUrl = URL(string: url) {
if pageUrl.scheme == parsedUrl.scheme && pageUrl.host == parsedUrl.host && pageUrl.path == parsedUrl.path {
textUrl = url
}
}
case .Url:
let nsText = message.text as NSString
var entityRange = NSRange(location: entity.range.lowerBound, length: entity.range.upperBound - entity.range.lowerBound)
if entityRange.location + entityRange.length > nsText.length {
entityRange.location = max(0, nsText.length - entityRange.length)
entityRange.length = nsText.length - entityRange.location
}
let url = nsText.substring(with: entityRange)
if let parsedUrl = URL(string: url) {
if pageUrl.scheme == parsedUrl.scheme && pageUrl.host == parsedUrl.host && pageUrl.path == parsedUrl.path {
textUrl = url
}
}
default:
break
}
}
break inner
}
}
}
var anchor: String?
if let textUrl = textUrl, let anchorRange = textUrl.range(of: "#") {
anchor = String(textUrl[anchorRange.upperBound...])
}
return (webpage, anchor)
}
break
}
}
return nil
}
public final class InstantPageController: ViewController {
private let context: AccountContext
private var webPage: TelegramMediaWebpage
private let sourceLocation: InstantPageSourceLocation
private let anchor: String?
private var presentationData: PresentationData
private let _ready = Promise<Bool>()
override public var ready: Promise<Bool> {
return self._ready
}
private var controllerNode: InstantPageControllerNode {
return self.displayNode as! InstantPageControllerNode
}
private var webpageDisposable: Disposable?
private var storedStateDisposable: Disposable?
private var settings: InstantPagePresentationSettings?
private var settingsDisposable: Disposable?
private var themeSettings: PresentationThemeSettings?
public init(context: AccountContext, webPage: TelegramMediaWebpage, sourceLocation: InstantPageSourceLocation, anchor: String? = nil) {
self.context = context
self.presentationData = context.sharedContext.currentPresentationData.with { $0 }
self.webPage = webPage
self.anchor = anchor
self.sourceLocation = sourceLocation
super.init(navigationBarPresentationData: nil)
self.navigationPresentation = .modalInLargeLayout
self.statusBar.statusBarStyle = .White
self.webpageDisposable = (actualizedWebpage(account: context.account, webpage: webPage) |> deliverOnMainQueue).start(next: { [weak self] result in
if let strongSelf = self {
strongSelf.webPage = result
if strongSelf.isNodeLoaded {
strongSelf.controllerNode.updateWebPage(result, anchor: strongSelf.anchor)
}
}
})
self.settingsDisposable = (self.context.sharedContext.accountManager.sharedData(keys: [ApplicationSpecificSharedDataKeys.instantPagePresentationSettings, ApplicationSpecificSharedDataKeys.presentationThemeSettings])
|> deliverOnMainQueue).start(next: { [weak self] sharedData in
if let strongSelf = self {
let settings: InstantPagePresentationSettings
if let current = sharedData.entries[ApplicationSpecificSharedDataKeys.instantPagePresentationSettings]?.get(InstantPagePresentationSettings.self) {
settings = current
} else {
settings = InstantPagePresentationSettings.defaultSettings
}
let themeSettings: PresentationThemeSettings
if let current = sharedData.entries[ApplicationSpecificSharedDataKeys.presentationThemeSettings]?.get(PresentationThemeSettings.self) {
themeSettings = current
} else {
themeSettings = PresentationThemeSettings.defaultSettings
}
strongSelf.settings = settings
strongSelf.themeSettings = themeSettings
if strongSelf.isNodeLoaded {
strongSelf.controllerNode.update(settings: settings, themeSettings: themeSettings, strings: strongSelf.presentationData.strings)
}
}
})
}
required public init(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
self.webpageDisposable?.dispose()
self.storedStateDisposable?.dispose()
self.settingsDisposable?.dispose()
}
override public func viewWillDisappear(_ animated: Bool) {
let _ = updateInstantPageStoredStateInteractively(engine: self.context.engine, webPage: self.webPage, state: self.controllerNode.currentState).start()
}
override public func loadDisplayNode() {
self.displayNode = InstantPageControllerNode(controller: self, context: self.context, settings: self.settings, themeSettings: self.themeSettings, presentationTheme: self.presentationData.theme, strings: self.presentationData.strings, dateTimeFormat: self.presentationData.dateTimeFormat, nameDisplayOrder: self.presentationData.nameDisplayOrder, autoNightModeTriggered: self.presentationData.autoNightModeTriggered, statusBar: self.statusBar, sourceLocation: self.sourceLocation, getNavigationController: { [weak self] in
return self?.navigationController as? NavigationController
}, present: { [weak self] c, a in
self?.present(c, in: .window(.root), with: a, blockInteraction: true)
}, pushController: { [weak self] c in
(self?.navigationController as? NavigationController)?.pushViewController(c)
}, openPeer: { [weak self] peer in
if let strongSelf = self, let navigationController = strongSelf.navigationController as? NavigationController {
strongSelf.context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: strongSelf.context, chatLocation: .peer(peer), animated: true))
}
}, navigateBack: { [weak self] in
if let strongSelf = self, let controllers = strongSelf.navigationController?.viewControllers.reversed() {
for controller in controllers {
if !(controller is InstantPageController) {
strongSelf.navigationController?.popToViewController(controller, animated: true)
return
}
}
strongSelf.navigationController?.popViewController(animated: true)
}
})
self.storedStateDisposable = (instantPageStoredState(engine: self.context.engine, webPage: self.webPage)
|> deliverOnMainQueue).start(next: { [weak self] state in
if let strongSelf = self {
strongSelf.controllerNode.updateWebPage(strongSelf.webPage, anchor: strongSelf.anchor, state: state)
strongSelf._ready.set(.single(true))
}
})
self.displayNodeDidLoad()
}
override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) {
super.containerLayoutUpdated(layout, transition: transition)
self.controllerNode.containerLayoutUpdated(layout, navigationBarHeight: self.navigationLayout(layout: layout).navigationFrame.maxY, transition: transition)
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,114 @@
import Foundation
import UIKit
import TelegramCore
import AsyncDisplayKit
import Display
import TelegramPresentationData
import TelegramUIPreferences
import AccountContext
import ContextUI
public final class InstantPageDetailsItem: InstantPageItem {
public var frame: CGRect
public let wantsNode: Bool = true
public let separatesTiles: Bool = true
public var medias: [InstantPageMedia] {
var result: [InstantPageMedia] = []
for item in self.items {
result.append(contentsOf: item.medias)
}
return result
}
public let titleItems: [InstantPageItem]
public let titleHeight: CGFloat
public let items: [InstantPageItem]
let safeInset: CGFloat
let rtl: Bool
public let initiallyExpanded: Bool
public let index: Int
init(frame: CGRect, titleItems: [InstantPageItem], titleHeight: CGFloat, items: [InstantPageItem], safeInset: CGFloat, rtl: Bool, initiallyExpanded: Bool, index: Int) {
self.frame = frame
self.titleItems = titleItems
self.titleHeight = titleHeight
self.items = items
self.safeInset = safeInset
self.rtl = rtl
self.initiallyExpanded = initiallyExpanded
self.index = index
}
public func node(context: AccountContext, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, theme: InstantPageTheme, sourceLocation: InstantPageSourceLocation, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, openPeer: @escaping (EnginePeer) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void, updateWebEmbedHeight: @escaping (CGFloat) -> Void, updateDetailsExpanded: @escaping (Bool) -> Void, currentExpandedDetails: [Int : Bool]?, getPreloadedResource: @escaping (String) -> Data?) -> InstantPageNode? {
var expanded: Bool?
if let expandedDetails = currentExpandedDetails, let currentlyExpanded = expandedDetails[self.index] {
expanded = currentlyExpanded
}
return InstantPageDetailsNode(context: context, sourceLocation: sourceLocation, strings: strings, nameDisplayOrder: nameDisplayOrder, theme: theme, item: self, openMedia: openMedia, longPressMedia: longPressMedia, activatePinchPreview: activatePinchPreview, pinchPreviewFinished: pinchPreviewFinished, openPeer: openPeer, openUrl: openUrl, currentlyExpanded: expanded, updateDetailsExpanded: updateDetailsExpanded, getPreloadedResource: getPreloadedResource)
}
public func matchesAnchor(_ anchor: String) -> Bool {
return false
}
public func matchesNode(_ node: InstantPageNode) -> Bool {
if let node = node as? InstantPageDetailsNode {
return self === node.item
} else {
return false
}
}
public func distanceThresholdGroup() -> Int? {
return 8
}
public func distanceThresholdWithGroupCount(_ count: Int) -> CGFloat {
return CGFloat.greatestFiniteMagnitude
}
public func drawInTile(context: CGContext) {
}
public func linkSelectionRects(at point: CGPoint) -> [CGRect] {
if point.y < self.titleHeight {
for item in self.titleItems {
if item.frame.contains(point) {
let rects = item.linkSelectionRects(at: point.offsetBy(dx: -item.frame.minX, dy: -item.frame.minY))
return rects.map { $0.offsetBy(dx: item.frame.minX, dy: item.frame.minY) }
}
}
} else {
let convertedPoint = point.offsetBy(dx: 0.0, dy: -self.titleHeight)
for item in self.items {
if item.frame.contains(convertedPoint) {
let rects = item.linkSelectionRects(at: convertedPoint.offsetBy(dx: -item.frame.minX, dy: -item.frame.minY))
if !rects.isEmpty {
return rects.map { $0.offsetBy(dx: item.frame.minX, dy: item.frame.minY + self.titleHeight) }
}
}
}
}
return []
}
}
func layoutDetailsItem(theme: InstantPageTheme, title: NSAttributedString, boundingWidth: CGFloat, items: [InstantPageItem], contentSize: CGSize, safeInset: CGFloat, rtl: Bool, initiallyExpanded: Bool, index: Int) -> InstantPageDetailsItem {
let detailsInset: CGFloat = 17.0 + safeInset
let titleInset: CGFloat = 22.0
let (_, titleItems, titleSize) = layoutTextItemWithString(title, boundingWidth: boundingWidth - detailsInset * 2.0 - titleInset, offset: CGPoint(x: detailsInset + titleInset, y: 0.0))
let titleHeight = max(44.0, titleSize.height + 26.0)
var offset: CGFloat?
for var item in titleItems {
var itemOffset = floorToScreenPixels((titleHeight - item.frame.height) / 2.0)
if item is InstantPageTextItem {
offset = itemOffset
} else if let offset = offset {
itemOffset = offset
}
item.frame = item.frame.offsetBy(dx: 0.0, dy: itemOffset)
}
return InstantPageDetailsItem(frame: CGRect(x: 0.0, y: 0.0, width: boundingWidth, height: contentSize.height + titleHeight), titleItems: titleItems, titleHeight: titleHeight, items: items, safeInset: safeInset, rtl: rtl, initiallyExpanded: initiallyExpanded, index: index)
}
@@ -0,0 +1,313 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import TelegramCore
import SwiftSignalKit
import TelegramPresentationData
import TelegramUIPreferences
import AccountContext
import ContextUI
private let detailsInset: CGFloat = 17.0
private let titleInset: CGFloat = 22.0
public final class InstantPageDetailsNode: ASDisplayNode, InstantPageNode {
private let context: AccountContext
private let strings: PresentationStrings
private let nameDisplayOrder: PresentationPersonNameOrder
private let theme: InstantPageTheme
public let item: InstantPageDetailsItem
private let titleTile: InstantPageTile
private let titleTileNode: InstantPageTileNode
private let highlightedBackgroundNode: ASDisplayNode
private let buttonNode: HighlightableButtonNode
private let arrowNode: InstantPageDetailsArrowNode
let separatorNode: ASDisplayNode
public let contentNode: InstantPageContentNode
private let updateExpanded: (Bool) -> Void
var expanded: Bool
public var previousNode: InstantPageDetailsNode?
public var requestLayoutUpdate: ((Bool) -> Void)?
init(context: AccountContext, sourceLocation: InstantPageSourceLocation, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, theme: InstantPageTheme, item: InstantPageDetailsItem, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, openPeer: @escaping (EnginePeer) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void, currentlyExpanded: Bool?, updateDetailsExpanded: @escaping (Bool) -> Void, getPreloadedResource: @escaping (String) -> Data?) {
self.context = context
self.strings = strings
self.nameDisplayOrder = nameDisplayOrder
self.theme = theme
self.item = item
self.updateExpanded = updateDetailsExpanded
let frame = item.frame
self.highlightedBackgroundNode = ASDisplayNode()
self.highlightedBackgroundNode.isLayerBacked = true
self.highlightedBackgroundNode.alpha = 0.0
self.buttonNode = HighlightableButtonNode()
self.titleTile = InstantPageTile(frame: CGRect(x: 0.0, y: 0.0, width: frame.width, height: item.titleHeight))
self.titleTile.items.append(contentsOf: item.titleItems)
self.titleTileNode = InstantPageTileNode(tile: self.titleTile, backgroundColor: .clear)
if let expanded = currentlyExpanded {
self.expanded = expanded
} else {
self.expanded = item.initiallyExpanded
}
self.arrowNode = InstantPageDetailsArrowNode(color: theme.controlColor, open: self.expanded)
self.separatorNode = ASDisplayNode()
self.contentNode = InstantPageContentNode(context: context, strings: strings, nameDisplayOrder: nameDisplayOrder, sourceLocation: sourceLocation, theme: theme, items: item.items, contentSize: CGSize(width: item.frame.width, height: item.frame.height - item.titleHeight), openMedia: openMedia, longPressMedia: longPressMedia, activatePinchPreview: activatePinchPreview, pinchPreviewFinished: pinchPreviewFinished, openPeer: openPeer, openUrl: openUrl, getPreloadedResource: getPreloadedResource)
super.init()
self.clipsToBounds = true
self.addSubnode(self.contentNode)
self.addSubnode(self.highlightedBackgroundNode)
self.addSubnode(self.buttonNode)
self.addSubnode(self.titleTileNode)
self.addSubnode(self.arrowNode)
self.addSubnode(self.separatorNode)
self.buttonNode.addTarget(self, action: #selector(self.buttonPressed), forControlEvents: .touchUpInside)
self.buttonNode.highligthedChanged = { [weak self] highlighted in
if let strongSelf = self {
if highlighted {
strongSelf.highlightedBackgroundNode.layer.removeAnimation(forKey: "opacity")
strongSelf.highlightedBackgroundNode.alpha = 1.0
strongSelf.separatorNode.alpha = 0.0
if let previousSeparator = strongSelf.previousNode?.separatorNode {
previousSeparator.alpha = 0.0
}
} else {
strongSelf.highlightedBackgroundNode.alpha = 0.0
strongSelf.highlightedBackgroundNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2)
strongSelf.separatorNode.alpha = 1.0
strongSelf.separatorNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
if let previousSeparator = strongSelf.previousNode?.separatorNode {
previousSeparator.alpha = 1.0
previousSeparator.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
}
}
}
}
self.contentNode.requestLayoutUpdate = { [weak self] animated in
self?.requestLayoutUpdate?(animated)
}
self.update(strings: strings, theme: theme)
}
@objc func buttonPressed() {
self.setExpanded(!self.expanded, animated: true)
self.updateExpanded(expanded)
}
public func setExpanded(_ expanded: Bool, animated: Bool) {
self.expanded = expanded
self.arrowNode.setOpen(expanded, animated: animated)
}
public func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition) {
}
public override func layout() {
super.layout()
let size = self.bounds.size
let inset = detailsInset + self.item.safeInset
self.titleTileNode.frame = self.titleTile.frame
self.highlightedBackgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: size.width, height: self.item.titleHeight + UIScreenPixel))
self.buttonNode.frame = CGRect(origin: CGPoint(), size: CGSize(width: size.width, height: self.item.titleHeight))
self.arrowNode.frame = CGRect(x: inset, y: floorToScreenPixels((self.item.titleHeight - 8.0) / 2.0) + 1.0, width: 13.0, height: 8.0)
self.contentNode.frame = CGRect(x: 0.0, y: self.item.titleHeight, width: size.width, height: self.item.frame.height - self.item.titleHeight)
let lineSize = CGSize(width: self.frame.width - inset, height: UIScreenPixel)
self.separatorNode.frame = CGRect(origin: CGPoint(x: self.item.rtl ? 0.0 : inset, y: self.item.titleHeight - lineSize.height), size: lineSize)
}
public func updateIsVisible(_ isVisible: Bool) {
}
public func transitionNode(media: InstantPageMedia) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? {
return self.contentNode.transitionNode(media: media)
}
public func updateHiddenMedia(media: InstantPageMedia?) {
self.contentNode.updateHiddenMedia(media: media)
}
public func update(strings: PresentationStrings, theme: InstantPageTheme) {
self.arrowNode.color = theme.controlColor
self.separatorNode.backgroundColor = theme.controlColor
self.highlightedBackgroundNode.backgroundColor = theme.panelHighlightedBackgroundColor
}
public func updateVisibleItems(visibleBounds: CGRect, animated: Bool) {
if self.bounds.height > self.item.titleHeight {
self.contentNode.updateVisibleItems(visibleBounds: visibleBounds.offsetBy(dx: -self.contentNode.frame.minX, dy: -self.contentNode.frame.minY), animated: animated)
}
}
public func textItemAtLocation(_ location: CGPoint) -> (InstantPageTextItem, CGPoint)? {
if self.titleTileNode.frame.contains(location) {
for case let item as InstantPageTextItem in self.item.titleItems {
if item.frame.contains(location) {
return (item, self.titleTileNode.frame.origin)
}
}
}
else if let (textItem, parentOffset) = self.contentNode.textItemAtLocation(location.offsetBy(dx: -self.contentNode.frame.minX, dy: -self.contentNode.frame.minY)) {
return (textItem, self.contentNode.frame.origin.offsetBy(dx: parentOffset.x, dy: parentOffset.y))
}
return nil
}
public func tapActionAtPoint(_ point: CGPoint) -> TapLongTapOrDoubleTapGestureRecognizerAction {
if self.titleTileNode.frame.contains(point) {
if self.item.linkSelectionRects(at: point).isEmpty {
return .fail
}
} else if self.contentNode.frame.contains(point) {
return self.contentNode.tapActionAtPoint(_: point.offsetBy(dx: -self.contentNode.frame.minX, dy: -self.contentNode.frame.minY))
}
return .waitForSingleTap
}
public var effectiveContentSize: CGSize {
return self.contentNode.effectiveContentSize
}
public func effectiveFrameForItem(_ item: InstantPageItem) -> CGRect {
return self.contentNode.effectiveFrameForItem(item).offsetBy(dx: 0.0, dy: self.item.titleHeight)
}
}
private final class InstantPageDetailsArrowNodeParameters: NSObject {
let color: UIColor
let progress: CGFloat
init(color: UIColor, progress: CGFloat) {
self.color = color
self.progress = progress
}
}
final class InstantPageDetailsArrowNode : ASDisplayNode {
var color: UIColor {
didSet {
self.setNeedsDisplay()
}
}
private(set) var open: Bool
private var progress: CGFloat = 0.0
private var targetProgress: CGFloat?
private var displayLink: CADisplayLink?
init(color: UIColor, open: Bool) {
self.color = color
self.open = open
self.progress = open ? 1.0 : 0.0
super.init()
self.isOpaque = false
self.isLayerBacked = true
class DisplayLinkProxy: NSObject {
weak var target: InstantPageDetailsArrowNode?
init(target: InstantPageDetailsArrowNode) {
self.target = target
}
@objc func displayLinkEvent() {
self.target?.displayLinkEvent()
}
}
self.displayLink = CADisplayLink(target: DisplayLinkProxy(target: self), selector: #selector(DisplayLinkProxy.displayLinkEvent))
self.displayLink?.isPaused = true
self.displayLink?.add(to: RunLoop.main, forMode: .common)
}
deinit {
self.displayLink?.invalidate()
}
func setOpen(_ open: Bool, animated: Bool) {
self.open = open
let openProgress: CGFloat = open ? 1.0 : 0.0
if animated {
self.targetProgress = openProgress
self.displayLink?.isPaused = false
} else {
self.progress = openProgress
self.targetProgress = nil
self.displayLink?.isPaused = true
}
}
override func willEnterHierarchy() {
super.willEnterHierarchy()
if self.targetProgress != nil {
self.displayLink?.isPaused = false
}
}
override func didExitHierarchy() {
super.didExitHierarchy()
self.displayLink?.isPaused = true
}
private func displayLinkEvent() {
if let targetProgress = self.targetProgress {
let sign = CGFloat(targetProgress - self.progress > 0 ? 1 : -1)
self.progress += 0.14 * sign
if sign > 0 && self.progress > targetProgress {
self.progress = 1.0
self.targetProgress = nil
self.displayLink?.isPaused = true
} else if sign < 0 && self.progress < targetProgress {
self.progress = 0.0
self.targetProgress = nil
self.displayLink?.isPaused = true
}
}
self.setNeedsDisplay()
}
override func drawParameters(forAsyncLayer layer: _ASDisplayLayer) -> NSObjectProtocol? {
return InstantPageDetailsArrowNodeParameters(color: self.color, progress: self.progress)
}
@objc override class func draw(_ bounds: CGRect, withParameters parameters: Any?, isCancelled: () -> Bool, isRasterizing: Bool) {
let context = UIGraphicsGetCurrentContext()!
if let parameters = parameters as? InstantPageDetailsArrowNodeParameters {
context.setStrokeColor(parameters.color.cgColor)
context.setLineCap(.round)
context.setLineWidth(2.0)
context.move(to: CGPoint(x: 1.0, y: 1.0 + 5.0 * parameters.progress))
context.addLine(to: CGPoint(x: 6.0, y: 6.0 - 5.0 * parameters.progress))
context.addLine(to: CGPoint(x: 11.0, y: 1.0 + 5.0 * parameters.progress))
context.strokePath()
}
}
}
@@ -0,0 +1,48 @@
import Foundation
import Postbox
import TelegramCore
import PersistentStringHash
public struct InstantPageExternalMediaResourceId {
public let url: String
public var uniqueId: String {
return "instantpage-media-\(persistentHash32(self.url))"
}
public var hashValue: Int {
return self.uniqueId.hashValue
}
}
public class InstantPageExternalMediaResource: TelegramMediaResource {
public let url: String
public var size: Int64? {
return nil
}
public init(url: String) {
self.url = url
}
public required init(decoder: PostboxDecoder) {
self.url = decoder.decodeStringForKey("u", orElse: "")
}
public func encode(_ encoder: PostboxEncoder) {
encoder.encodeString(self.url, forKey: "u")
}
public var id: MediaResourceId {
return MediaResourceId(InstantPageExternalMediaResourceId(url: self.url).uniqueId)
}
public func isEqual(to: MediaResource) -> Bool {
if let to = to as? InstantPageExternalMediaResource {
return self.url == to.url
} else {
return false
}
}
}
@@ -0,0 +1,52 @@
import Foundation
import UIKit
import TelegramCore
import AsyncDisplayKit
import TelegramPresentationData
import TelegramUIPreferences
import AccountContext
import ContextUI
public final class InstantPageFeedbackItem: InstantPageItem {
public var frame: CGRect
public let wantsNode: Bool = true
public let separatesTiles: Bool = false
public let medias: [InstantPageMedia] = []
let webPage: TelegramMediaWebpage
init(frame: CGRect, webPage: TelegramMediaWebpage) {
self.frame = frame
self.webPage = webPage
}
public func node(context: AccountContext, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, theme: InstantPageTheme, sourceLocation: InstantPageSourceLocation, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, openPeer: @escaping (EnginePeer) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void, updateWebEmbedHeight: @escaping (CGFloat) -> Void, updateDetailsExpanded: @escaping (Bool) -> Void, currentExpandedDetails: [Int : Bool]?, getPreloadedResource: @escaping (String) -> Data?) -> InstantPageNode? {
return InstantPageFeedbackNode(context: context, strings: strings, theme: theme, webPage: self.webPage, openUrl: openUrl)
}
public func matchesAnchor(_ anchor: String) -> Bool {
return false
}
public func matchesNode(_ node: InstantPageNode) -> Bool {
if let node = node as? InstantPageFeedbackNode, case let .Loaded(content) = node.webPage.content, case let .Loaded(updatedContent) = self.webPage.content, content.instantPage?.views == updatedContent.instantPage?.views {
return true
}
return false
}
public func distanceThresholdGroup() -> Int? {
return 8
}
public func distanceThresholdWithGroupCount(_ count: Int) -> CGFloat {
return CGFloat.greatestFiniteMagnitude
}
public func linkSelectionRects(at point: CGPoint) -> [CGRect] {
return []
}
public func drawInTile(context: CGContext) {
}
}
@@ -0,0 +1,125 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import TelegramCore
import SwiftSignalKit
import TelegramPresentationData
import AccountContext
final class InstantPageFeedbackNode: ASDisplayNode, InstantPageNode {
private let context: AccountContext
let webPage: TelegramMediaWebpage
private let openUrl: (InstantPageUrlItem) -> Void
private let highlightedBackgroundNode: ASDisplayNode
private let buttonNode: HighlightableButtonNode
private let labelNode: ASTextNode
private let viewsNode: ASTextNode
private let resolveDisposable = MetaDisposable()
init(context: AccountContext, strings: PresentationStrings, theme: InstantPageTheme, webPage: TelegramMediaWebpage, openUrl: @escaping (InstantPageUrlItem) -> Void) {
self.context = context
self.webPage = webPage
self.openUrl = openUrl
self.highlightedBackgroundNode = ASDisplayNode()
self.highlightedBackgroundNode.isLayerBacked = true
self.highlightedBackgroundNode.alpha = 0.0
self.buttonNode = HighlightableButtonNode()
self.labelNode = ASTextNode()
self.labelNode.isLayerBacked = true
self.labelNode.maximumNumberOfLines = 2
self.viewsNode = ASTextNode()
self.viewsNode.isLayerBacked = true
self.viewsNode.maximumNumberOfLines = 2
super.init()
if case let .Loaded(content) = webPage.content, let views = content.instantPage?.views {
self.viewsNode.attributedText = NSAttributedString(string: strings.InstantPage_Views(views), font: Font.regular(13.0), textColor: theme.panelSecondaryColor)
}
self.addSubnode(self.highlightedBackgroundNode)
self.addSubnode(self.buttonNode)
self.addSubnode(self.labelNode)
self.addSubnode(self.viewsNode)
self.buttonNode.addTarget(self, action: #selector(self.buttonPressed), forControlEvents: .touchUpInside)
self.buttonNode.highligthedChanged = { [weak self] highlighted in
if let strongSelf = self {
if highlighted {
strongSelf.labelNode.layer.removeAnimation(forKey: "opacity")
strongSelf.labelNode.alpha = 0.4
} else {
strongSelf.labelNode.alpha = 1.0
strongSelf.labelNode.layer.animateAlpha(from: 0.4, to: 1.0, duration: 0.2)
}
}
}
self.update(strings: strings, theme: theme)
}
deinit {
self.resolveDisposable.dispose()
}
@objc func buttonPressed() {
self.resolveDisposable.set((self.context.engine.peers.resolvePeerByName(name: "previews", referrer: nil)
|> mapToSignal { result -> Signal<EnginePeer?, NoError> in
guard case let .result(result) = result else {
return .complete()
}
return .single(result)
}
|> deliverOnMainQueue).start(next: { [weak self] peer in
if let strongSelf = self, let _ = peer, let webPageId = strongSelf.webPage.id?.id {
strongSelf.openUrl(InstantPageUrlItem(url: "https://t.me/previews?start=webpage\(webPageId)", webpageId: nil))
}
}))
}
override func layout() {
super.layout()
let size = self.bounds.size
let inset: CGFloat = 16.0
self.highlightedBackgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -UIScreenPixel), size: CGSize(width: size.width, height: size.height + UIScreenPixel))
let viewsSize = self.viewsNode.measure(CGSize(width: size.width - inset * 2.0, height: size.height))
self.viewsNode.frame = CGRect(origin: CGPoint(x: inset, y: floorToScreenPixels((size.height - viewsSize.height) / 2.0)), size: viewsSize)
let labelSize = self.labelNode.measure(CGSize(width: size.width - inset * 2.0, height: size.height))
self.labelNode.frame = CGRect(origin: CGPoint(x: size.width - labelSize.width - inset, y: floorToScreenPixels((size.height - labelSize.height) / 2.0)), size: labelSize)
self.buttonNode.frame = CGRect(origin: CGPoint(x: size.width - labelSize.width - inset * 2.0, y: 0.0), size: size)
}
func updateIsVisible(_ isVisible: Bool) {
}
func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition) {
}
func transitionNode(media: InstantPageMedia) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? {
return nil
}
func updateHiddenMedia(media: InstantPageMedia?) {
}
func update(strings: PresentationStrings, theme: InstantPageTheme) {
self.backgroundColor = theme.panelBackgroundColor
self.highlightedBackgroundNode.backgroundColor = theme.panelHighlightedBackgroundColor
self.labelNode.attributedText = NSAttributedString(string: strings.InstantPage_FeedbackButtonShort, font: Font.regular(13.0), textColor: theme.panelSecondaryColor)
self.viewsNode.attributedText = NSAttributedString(string: self.viewsNode.attributedText?.string ?? "", font: Font.regular(13.0), textColor: theme.panelSecondaryColor)
}
}
@@ -0,0 +1,473 @@
import Foundation
import UIKit
import Display
import QuickLook
import Postbox
import SwiftSignalKit
import AsyncDisplayKit
import TelegramCore
import SafariServices
import TelegramPresentationData
import AccountContext
import GalleryUI
import TelegramUniversalVideoContent
import OpenInExternalAppUI
public struct InstantPageGalleryEntryLocation: Equatable {
public let position: Int32
public let totalCount: Int32
public init(position: Int32, totalCount: Int32) {
self.position = position
self.totalCount = totalCount
}
public static func ==(lhs: InstantPageGalleryEntryLocation, rhs: InstantPageGalleryEntryLocation) -> Bool {
return lhs.position == rhs.position && lhs.totalCount == rhs.totalCount
}
}
public struct InstantPageGalleryEntry: Equatable {
public let index: Int32
public let pageId: MediaId
public let media: InstantPageMedia
public let caption: RichText?
public let credit: RichText?
public let location: InstantPageGalleryEntryLocation?
public init(index: Int32, pageId: MediaId, media: InstantPageMedia, caption: RichText?, credit: RichText?, location: InstantPageGalleryEntryLocation?) {
self.index = index
self.pageId = pageId
self.media = media
self.caption = caption
self.credit = credit
self.location = location
}
public static func ==(lhs: InstantPageGalleryEntry, rhs: InstantPageGalleryEntry) -> Bool {
return lhs.index == rhs.index && lhs.pageId == rhs.pageId && lhs.media == rhs.media && lhs.caption == rhs.caption && lhs.credit == rhs.credit && lhs.location == rhs.location
}
func item(context: AccountContext, userLocation: MediaResourceUserLocation, webPage: TelegramMediaWebpage, message: Message?, presentationData: PresentationData, fromPlayingVideo: Bool, landscape: Bool, openUrl: @escaping (InstantPageUrlItem) -> Void, openUrlOptions: @escaping (InstantPageUrlItem) -> Void, getPreloadedResource: @escaping (String) -> Data?) -> GalleryItem {
let caption: NSAttributedString
let credit: NSAttributedString
let styleStack = InstantPageTextStyleStack()
styleStack.push(.fontSize(16.0))
styleStack.push(.textColor(.white))
styleStack.push(.markerColor(UIColor(rgb: 0x313131)))
styleStack.push(.linkColor(UIColor(rgb: 0x5ac8fa)))
styleStack.push(.linkMarkerColor(UIColor(rgb: 0x5ac8fa, alpha: 0.2)))
styleStack.push(.fontSerif(false))
if let url = self.media.url {
styleStack.push(.lineSpacingFactor(1.45))
let titleString = RichText.bold(.plain(presentationData.strings.InstantPage_TapToOpenLink + "\n"))
let urlString = RichText.url(text: .plain(url.url), url: url.url, webpageId: url.webpageId)
let concatText: RichText
if let mediaCaption = self.media.caption {
concatText = RichText.concat([titleString, urlString, .plain("\n\n"), mediaCaption])
} else {
concatText = RichText.concat([titleString, urlString])
}
caption = attributedStringForRichText(concatText, styleStack: styleStack)
credit = NSAttributedString(string: "")
} else {
if let mediaCaption = self.media.caption {
caption = attributedStringForRichText(mediaCaption, styleStack: styleStack)
} else {
caption = NSAttributedString(string: "")
}
if let mediaCredit = self.media.credit {
let styleStack = InstantPageTextStyleStack()
styleStack.push(.fontSize(14.0))
styleStack.push(.textColor(.white))
styleStack.push(.markerColor(UIColor(rgb: 0x313131)))
styleStack.push(.linkColor(UIColor(rgb: 0x5ac8fa)))
styleStack.push(.linkMarkerColor(UIColor(rgb: 0x5ac8fa, alpha: 0.2)))
styleStack.push(.fontSerif(false))
credit = attributedStringForRichText(mediaCredit, styleStack: styleStack)
} else {
credit = NSAttributedString(string: "")
}
}
if case let .image(image) = self.media.media {
return InstantImageGalleryItem(context: context, presentationData: presentationData, itemId: self.index, userLocation: userLocation, imageReference: .webPage(webPage: WebpageReference(webPage), media: image), caption: caption, credit: credit, location: self.location, openUrl: openUrl, openUrlOptions: openUrlOptions, getPreloadedResource: getPreloadedResource)
} else if case let .file(file) = self.media.media {
if file.isVideo {
var indexData: GalleryItemIndexData?
if let location = self.location {
indexData = GalleryItemIndexData(position: location.position, totalCount: location.totalCount)
}
let nativeId: NativeVideoContentId
if let message = message, case let .Loaded(content) = webPage.content, content.file?.fileId == file.fileId {
nativeId = .message(message.stableId, file.fileId)
} else {
nativeId = .instantPage(self.pageId, file.fileId)
}
return UniversalVideoGalleryItem(context: context, presentationData: presentationData, content: NativeVideoContent(id: nativeId, userLocation: userLocation, fileReference: .webPage(webPage: WebpageReference(webPage), media: file), streamVideo: isMediaStreamable(media: file) ? .conservative : .none, storeAfterDownload: nil), originData: nil, indexData: indexData, contentInfo: .webPage(webPage, file, nil), caption: caption, credit: credit, fromPlayingVideo: fromPlayingVideo, landscape: landscape, playbackRate: { nil }, performAction: { _ in }, openActionOptions: { _, _ in }, storeMediaPlaybackState: { _, _, _ in }, present: { _, _ in })
} else {
var representations: [TelegramMediaImageRepresentation] = []
representations.append(contentsOf: file.previewRepresentations)
if let dimensions = file.dimensions {
representations.append(TelegramMediaImageRepresentation(dimensions: dimensions, resource: file.resource, progressiveSizes: [], immediateThumbnailData: nil, hasVideo: false, isPersonal: false))
}
let image = TelegramMediaImage(imageId: MediaId(namespace: 0, id: 0), representations: representations, immediateThumbnailData: file.immediateThumbnailData, reference: nil, partialReference: nil, flags: [])
return InstantImageGalleryItem(context: context, presentationData: presentationData, itemId: self.index, userLocation: userLocation, imageReference: .webPage(webPage: WebpageReference(webPage), media: image), caption: caption, credit: credit, location: self.location, openUrl: openUrl, openUrlOptions: openUrlOptions, getPreloadedResource: getPreloadedResource)
}
} else if case let .webpage(embedWebpage) = self.media.media, case let .Loaded(webpageContent) = embedWebpage.content {
if webpageContent.url.hasSuffix(".m3u8") {
let content = PlatformVideoContent(id: .instantPage(embedWebpage.webpageId, embedWebpage.webpageId), userLocation: userLocation, content: .url(webpageContent.url), streamVideo: true, loopVideo: false)
return UniversalVideoGalleryItem(context: context, presentationData: presentationData, content: content, originData: nil, indexData: nil, contentInfo: .webPage(webPage, embedWebpage, { makeArguments, navigationController, present in
let gallery = InstantPageGalleryController(context: context, userLocation: userLocation, webPage: webPage, entries: [self], centralIndex: 0, replaceRootController: { [weak navigationController] controller, ready in
if let navigationController = navigationController {
navigationController.replaceTopController(controller, animated: false, ready: ready)
}
}, baseNavigationController: navigationController)
present(gallery, InstantPageGalleryControllerPresentationArguments(transitionArguments: { entry -> GalleryTransitionArguments? in
return makeArguments()
}))
}), caption: NSAttributedString(string: ""), fromPlayingVideo: fromPlayingVideo, landscape: landscape, playbackRate: { nil }, performAction: { _ in }, openActionOptions: { _, _ in }, storeMediaPlaybackState: { _, _, _ in }, present: { _, _ in })
} else {
if let content = WebEmbedVideoContent(userLocation: userLocation, webPage: embedWebpage, webpageContent: webpageContent, openUrl: { url in
}) {
return UniversalVideoGalleryItem(context: context, presentationData: presentationData, content: content, originData: nil, indexData: nil, contentInfo: .webPage(webPage, embedWebpage, nil), caption: NSAttributedString(string: ""), fromPlayingVideo: fromPlayingVideo, landscape: landscape, playbackRate: { nil }, performAction: { _ in }, openActionOptions: { _, _ in }, storeMediaPlaybackState: { _, _, _ in }, present: { _, _ in })
} else {
preconditionFailure()
}
}
} else {
preconditionFailure()
}
}
}
public final class InstantPageGalleryControllerPresentationArguments {
let transitionArguments: (InstantPageGalleryEntry) -> GalleryTransitionArguments?
public init(transitionArguments: @escaping (InstantPageGalleryEntry) -> GalleryTransitionArguments?) {
self.transitionArguments = transitionArguments
}
}
public class InstantPageGalleryController: ViewController, StandalonePresentableController {
private var galleryNode: GalleryControllerNode {
return self.displayNode as! GalleryControllerNode
}
private let context: AccountContext
private let userLocation: MediaResourceUserLocation
private let webPage: TelegramMediaWebpage
private let message: Message?
private var presentationData: PresentationData
private let _ready = Promise<Bool>()
override public var ready: Promise<Bool> {
return self._ready
}
private var didSetReady = false
private let disposable = MetaDisposable()
private var entries: [InstantPageGalleryEntry] = []
private var centralEntryIndex: Int?
private let fromPlayingVideo: Bool
private let landscape: Bool
private let centralItemTitle = Promise<String>()
private let centralItemTitleView = Promise<UIView?>()
private let centralItemRightBarButtonItem = Promise<UIBarButtonItem?>()
private let centralItemRightBarButtonItems = Promise<[UIBarButtonItem]?>(nil)
private let centralItemNavigationStyle = Promise<GalleryItemNodeNavigationStyle>()
private let centralItemFooterContentNode = Promise<(GalleryFooterContentNode?, GalleryOverlayContentNode?)>()
private let centralItemAttributesDisposable = DisposableSet();
private let _hiddenMedia = Promise<InstantPageGalleryEntry?>(nil)
public var hiddenMedia: Signal<InstantPageGalleryEntry?, NoError> {
return self._hiddenMedia.get()
}
private let replaceRootController: (ViewController, Promise<Bool>?) -> Void
private let baseNavigationController: NavigationController?
public var openUrl: ((InstantPageUrlItem) -> Void)?
private var innerOpenUrl: (InstantPageUrlItem) -> Void
private var openUrlOptions: (InstantPageUrlItem) -> Void
private let getPreloadedResource: (String) -> Data?
public init(context: AccountContext, userLocation: MediaResourceUserLocation, webPage: TelegramMediaWebpage, message: Message? = nil, entries: [InstantPageGalleryEntry], centralIndex: Int, fromPlayingVideo: Bool = false, landscape: Bool = false, timecode: Double? = nil, replaceRootController: @escaping (ViewController, Promise<Bool>?) -> Void, baseNavigationController: NavigationController?, getPreloadedResource: @escaping (String) -> Data? = { _ in return nil }) {
self.context = context
self.userLocation = userLocation
self.webPage = webPage
self.message = message
self.fromPlayingVideo = fromPlayingVideo
self.landscape = landscape
self.replaceRootController = replaceRootController
self.baseNavigationController = baseNavigationController
self.getPreloadedResource = getPreloadedResource
self.presentationData = context.sharedContext.currentPresentationData.with { $0 }
var openLinkImpl: ((InstantPageUrlItem) -> Void)?
self.innerOpenUrl = { url in
openLinkImpl?(url)
}
var openLinkOptionsImpl: ((InstantPageUrlItem) -> Void)?
self.openUrlOptions = { url in
openLinkOptionsImpl?(url)
}
super.init(navigationBarPresentationData: NavigationBarPresentationData(theme: GalleryController.darkNavigationTheme, strings: NavigationBarStrings(presentationStrings: self.presentationData.strings)))
let backItem = UIBarButtonItem(backButtonAppearanceWithTitle: presentationData.strings.Common_Back, target: self, action: #selector(self.donePressed))
self.navigationItem.leftBarButtonItem = backItem
self.statusBar.statusBarStyle = .White
let entriesSignal: Signal<[InstantPageGalleryEntry], NoError> = .single(entries)
self.disposable.set((entriesSignal |> deliverOnMainQueue).start(next: { [weak self] entries in
if let strongSelf = self {
strongSelf.entries = entries
strongSelf.centralEntryIndex = centralIndex
if strongSelf.isViewLoaded {
strongSelf.galleryNode.pager.replaceItems(strongSelf.entries.map({
$0.item(context: context, userLocation: userLocation, webPage: webPage, message: message, presentationData: strongSelf.presentationData, fromPlayingVideo: fromPlayingVideo, landscape: landscape, openUrl: strongSelf.innerOpenUrl, openUrlOptions: strongSelf.openUrlOptions, getPreloadedResource: strongSelf.getPreloadedResource)
}), centralItemIndex: centralIndex)
let ready = strongSelf.galleryNode.pager.ready() |> timeout(2.0, queue: Queue.mainQueue(), alternate: .single(Void())) |> afterNext { [weak strongSelf] _ in
strongSelf?.didSetReady = true
}
strongSelf._ready.set(ready |> map { true })
}
}
}))
self.centralItemAttributesDisposable.add(self.centralItemTitle.get().start(next: { [weak self] title in
self?.navigationItem.title = title
}))
self.centralItemAttributesDisposable.add(self.centralItemTitleView.get().start(next: { [weak self] titleView in
self?.navigationItem.titleView = titleView
}))
self.centralItemAttributesDisposable.add(combineLatest(self.centralItemRightBarButtonItem.get(), self.centralItemRightBarButtonItems.get()).start(next: { [weak self] rightBarButtonItem, rightBarButtonItems in
if let rightBarButtonItem = rightBarButtonItem {
self?.navigationItem.rightBarButtonItem = rightBarButtonItem
} else if let rightBarButtonItems = rightBarButtonItems {
self?.navigationItem.rightBarButtonItems = rightBarButtonItems
} else {
self?.navigationItem.rightBarButtonItem = nil
self?.navigationItem.rightBarButtonItems = nil
}
}))
self.centralItemAttributesDisposable.add(self.centralItemFooterContentNode.get().start(next: { [weak self] footerContentNode, _ in
self?.galleryNode.updatePresentationState({
$0.withUpdatedFooterContentNode(footerContentNode)
}, transition: .immediate)
}))
openLinkImpl = { [weak self] url in
if let strongSelf = self {
strongSelf.dismiss(forceAway: false)
strongSelf.openUrl?(url)
}
}
openLinkOptionsImpl = { [weak self] url in
if let strongSelf = self {
var presentationData = strongSelf.presentationData
if !presentationData.theme.overallDarkAppearance {
presentationData = presentationData.withUpdated(theme: defaultDarkColorPresentationTheme)
}
let canOpenIn = availableOpenInOptions(context: context, item: .url(url: url.url)).count > 1
let openText = canOpenIn ? strongSelf.presentationData.strings.Conversation_FileOpenIn : strongSelf.presentationData.strings.Conversation_LinkDialogOpen
let actionSheet = ActionSheetController(presentationData: presentationData)
actionSheet.setItemGroups([ActionSheetItemGroup(items: [
ActionSheetTextItem(title: url.url),
ActionSheetButtonItem(title: openText, color: .accent, action: { [weak actionSheet] in
actionSheet?.dismissAnimated()
openLinkImpl?(url)
}),
ActionSheetButtonItem(title: strongSelf.presentationData.strings.ShareMenu_CopyShareLink, color: .accent, action: { [weak actionSheet] in
actionSheet?.dismissAnimated()
UIPasteboard.general.string = url.url
}),
ActionSheetButtonItem(title: strongSelf.presentationData.strings.Conversation_AddToReadingList, color: .accent, action: { [weak actionSheet] in
actionSheet?.dismissAnimated()
if let link = URL(string: url.url) {
let _ = try? SSReadingList.default()?.addItem(with: link, title: nil, previewText: nil)
}
})
]), ActionSheetItemGroup(items: [
ActionSheetButtonItem(title: strongSelf.presentationData.strings.Common_Cancel, color: .accent, font: .bold, action: { [weak actionSheet] in
actionSheet?.dismissAnimated()
})
])])
strongSelf.present(actionSheet, in: .window(.root))
}
}
}
required public init(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
self.disposable.dispose()
self.centralItemAttributesDisposable.dispose()
}
@objc private func donePressed() {
self.dismiss(forceAway: false)
}
private func dismiss(forceAway: Bool) {
var animatedOutNode = true
var animatedOutInterface = false
let completion = { [weak self] in
if animatedOutNode && animatedOutInterface {
self?._hiddenMedia.set(.single(nil))
self?.presentingViewController?.dismiss(animated: false, completion: nil)
}
}
if let centralItemNode = self.galleryNode.pager.centralItemNode(), let presentationArguments = self.presentationArguments as? InstantPageGalleryControllerPresentationArguments {
if !self.entries.isEmpty {
if let transitionArguments = presentationArguments.transitionArguments(self.entries[centralItemNode.index]), !forceAway {
animatedOutNode = false
centralItemNode.animateOut(to: transitionArguments.transitionNode, addToTransitionSurface: transitionArguments.addToTransitionSurface, completion: {
animatedOutNode = true
completion()
})
}
}
}
self.galleryNode.animateOut(animateContent: animatedOutNode, completion: {
animatedOutInterface = true
completion()
})
}
override public func loadDisplayNode() {
let controllerInteraction = GalleryControllerInteraction(presentController: { [weak self] controller, arguments in
if let strongSelf = self {
strongSelf.present(controller, in: .window(.root), with: arguments, blockInteraction: true)
}
}, pushController: { _ in
}, dismissController: { [weak self] in
self?.dismiss(forceAway: true)
}, replaceRootController: { [weak self] controller, ready in
if let strongSelf = self {
strongSelf.replaceRootController(controller, ready)
}
}, editMedia: { _ in
}, controller: { [weak self] in
return self
})
self.displayNode = GalleryControllerNode(context: self.context,controllerInteraction: controllerInteraction)
self.displayNodeDidLoad()
self.galleryNode.statusBar = self.statusBar
self.galleryNode.navigationBar = self.navigationBar
self.galleryNode.transitionDataForCentralItem = { [weak self] in
if let strongSelf = self {
if let centralItemNode = strongSelf.galleryNode.pager.centralItemNode(), let presentationArguments = strongSelf.presentationArguments as? InstantPageGalleryControllerPresentationArguments {
if let transitionArguments = presentationArguments.transitionArguments(strongSelf.entries[centralItemNode.index]) {
return (transitionArguments.transitionNode, transitionArguments.addToTransitionSurface)
}
}
}
return nil
}
self.galleryNode.dismiss = { [weak self] in
self?._hiddenMedia.set(.single(nil))
self?.presentingViewController?.dismiss(animated: false, completion: nil)
}
self.galleryNode.completeCustomDismiss = { [weak self] _ in
self?._hiddenMedia.set(.single(nil))
self?.presentingViewController?.dismiss(animated: false, completion: nil)
}
self.galleryNode.pager.replaceItems(self.entries.map({
$0.item(context: self.context, userLocation: self.userLocation, webPage: self.webPage, message: self.message, presentationData: self.presentationData, fromPlayingVideo: self.fromPlayingVideo, landscape: self.landscape, openUrl: self.innerOpenUrl, openUrlOptions: self.openUrlOptions, getPreloadedResource: self.getPreloadedResource)
}), centralItemIndex: self.centralEntryIndex)
self.galleryNode.pager.centralItemIndexUpdated = { [weak self] index in
if let strongSelf = self {
var hiddenItem: InstantPageGalleryEntry?
if let index = index {
hiddenItem = strongSelf.entries[index]
if let node = strongSelf.galleryNode.pager.centralItemNode() {
strongSelf.centralItemTitle.set(node.title())
strongSelf.centralItemTitleView.set(node.titleView())
strongSelf.centralItemRightBarButtonItem.set(node.rightBarButtonItem())
strongSelf.centralItemRightBarButtonItems.set(node.rightBarButtonItems())
strongSelf.centralItemNavigationStyle.set(node.navigationStyle())
strongSelf.centralItemFooterContentNode.set(node.footerContent())
}
}
if strongSelf.didSetReady {
strongSelf._hiddenMedia.set(.single(hiddenItem))
}
}
}
let baseNavigationController = self.baseNavigationController
self.galleryNode.baseNavigationController = { [weak baseNavigationController] in
return baseNavigationController
}
let ready = self.galleryNode.pager.ready() |> timeout(2.0, queue: Queue.mainQueue(), alternate: .single(Void())) |> afterNext { [weak self] _ in
self?.didSetReady = true
}
self._ready.set(ready |> map { true })
}
override public func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
var nodeAnimatesItself = false
if let centralItemNode = self.galleryNode.pager.centralItemNode(), let presentationArguments = self.presentationArguments as? InstantPageGalleryControllerPresentationArguments {
self.centralItemTitle.set(centralItemNode.title())
self.centralItemTitleView.set(centralItemNode.titleView())
self.centralItemRightBarButtonItem.set(centralItemNode.rightBarButtonItem())
self.centralItemRightBarButtonItems.set(centralItemNode.rightBarButtonItems())
self.centralItemNavigationStyle.set(centralItemNode.navigationStyle())
self.centralItemFooterContentNode.set(centralItemNode.footerContent())
if let transitionArguments = presentationArguments.transitionArguments(self.entries[centralItemNode.index]) {
nodeAnimatesItself = true
centralItemNode.activateAsInitial()
centralItemNode.animateIn(from: transitionArguments.transitionNode, addToTransitionSurface: transitionArguments.addToTransitionSurface, completion: {})
self._hiddenMedia.set(.single(self.entries[centralItemNode.index]))
}
}
self.galleryNode.animateIn(animateContent: !nodeAnimatesItself, useSimpleAnimation: false)
}
override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) {
super.containerLayoutUpdated(layout, transition: transition)
self.galleryNode.frame = CGRect(origin: CGPoint(), size: layout.size)
self.galleryNode.containerLayoutUpdated(layout, navigationBarHeight: self.navigationLayout(layout: layout).navigationFrame.maxY, transition: transition)
}
}
@@ -0,0 +1,153 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import Postbox
import TelegramCore
import SwiftSignalKit
import Photos
import TelegramPresentationData
import TextFormat
import AccountContext
import ShareController
import GalleryUI
import AppBundle
private let actionImage = generateTintedImage(image: UIImage(bundleImageName: "Chat/Input/Accessory Panels/MessageSelectionForward"), color: .white)
private let textFont = Font.regular(16.0)
final class InstantPageGalleryFooterContentNode: GalleryFooterContentNode {
private let context: AccountContext
private var theme: PresentationTheme
private var strings: PresentationStrings
private var shareMedia: AnyMediaReference?
private let actionButton: UIButton
private let textNode: ImmediateTextNode
private var currentMessageText: NSAttributedString?
var openUrl: ((InstantPageUrlItem) -> Void)?
var openUrlOptions: ((InstantPageUrlItem) -> Void)?
init(context: AccountContext, presentationData: PresentationData) {
self.context = context
self.theme = presentationData.theme
self.strings = presentationData.strings
self.actionButton = UIButton()
self.actionButton.setImage(actionImage, for: [.normal])
self.textNode = ImmediateTextNode()
self.textNode.maximumNumberOfLines = 10
self.textNode.insets = UIEdgeInsets(top: 8.0, left: 0.0, bottom: 8.0, right: 0.0)
self.textNode.linkHighlightColor = UIColor(rgb: 0x5ac8fa, alpha: 0.2)
super.init()
self.textNode.highlightAttributeAction = { attributes in
if let _ = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.URL)] {
return NSAttributedString.Key(rawValue: TelegramTextAttributes.URL)
} else {
return nil
}
}
self.textNode.tapAttributeAction = { [weak self] attributes, _ in
if let strongSelf = self, let url = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.URL)] as? InstantPageUrlItem {
strongSelf.openUrl?(url)
}
}
self.textNode.longTapAttributeAction = { [weak self] attributes, _ in
if let strongSelf = self, let url = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.URL)] as? InstantPageUrlItem {
strongSelf.openUrlOptions?(url)
}
}
self.view.addSubview(self.actionButton)
self.addSubnode(self.textNode)
self.actionButton.addTarget(self, action: #selector(self.actionButtonPressed), for: [.touchUpInside])
}
func setCaption(_ caption: NSAttributedString, credit: NSAttributedString) {
if self.currentMessageText != caption {
self.currentMessageText = caption
var attributedText: NSMutableAttributedString?
if caption.length > 0 {
attributedText = NSMutableAttributedString(attributedString: caption)
}
if credit.length > 0 {
if attributedText != nil {
attributedText?.append(NSAttributedString(string: "\n"))
attributedText?.append(credit)
} else {
attributedText = NSMutableAttributedString(attributedString: credit)
}
}
if let attributedText = attributedText {
self.textNode.isHidden = false
self.textNode.attributedText = attributedText
} else {
self.textNode.isHidden = true
self.textNode.attributedText = nil
}
self.requestLayout?(.immediate)
}
}
func setShareMedia(_ shareMedia: AnyMediaReference?) {
self.shareMedia = shareMedia
self.actionButton.isHidden = shareMedia == nil
}
override func updateLayout(size: CGSize, metrics: LayoutMetrics, leftInset: CGFloat, rightInset: CGFloat, bottomInset: CGFloat, contentInset: CGFloat, transition: ContainedViewLayoutTransition) -> CGFloat {
let width = size.width
var panelHeight: CGFloat = 44.0 + bottomInset + contentInset
if !self.textNode.isHidden {
let sideInset: CGFloat = leftInset + 8.0
let topInset: CGFloat = 0.0
let bottomInset: CGFloat = 0.0
let textSize = self.textNode.updateLayout(CGSize(width: width - sideInset * 2.0, height: CGFloat.greatestFiniteMagnitude))
var x = sideInset
if let hasRTL = self.textNode.cachedLayout?.hasRTL, hasRTL {
x = width - rightInset - 8.0 - textSize.width
}
panelHeight += textSize.height + topInset + bottomInset
transition.updateFrame(node: self.textNode, frame: CGRect(origin: CGPoint(x: x, y: topInset), size: textSize))
}
self.actionButton.frame = CGRect(origin: CGPoint(x: leftInset, y: panelHeight - bottomInset - 44.0), size: CGSize(width: 44.0, height: 44.0))
return panelHeight
}
override func animateIn(fromHeight: CGFloat, previousContentNode: GalleryFooterContentNode, transition: ContainedViewLayoutTransition) {
transition.animatePositionAdditive(node: self.textNode, offset: CGPoint(x: 0.0, y: self.bounds.height - fromHeight))
self.textNode.alpha = 1.0
self.actionButton.alpha = 1.0
self.textNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15)
}
override func animateOut(toHeight: CGFloat, nextContentNode: GalleryFooterContentNode, transition: ContainedViewLayoutTransition, completion: @escaping () -> Void) {
transition.updateFrame(node: self.textNode, frame: self.textNode.frame.offsetBy(dx: 0.0, dy: self.bounds.height - toHeight))
self.textNode.alpha = 0.0
self.actionButton.alpha = 0.0
self.textNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, completion: { _ in
completion()
})
}
@objc func actionButtonPressed() {
if let shareMedia = self.shareMedia {
self.controllerInteraction?.presentController(ShareController(context: self.context, subject: .media(shareMedia, nil), preferredAction: .saveToCameraRoll, showInChat: nil, externalShare: true, immediateExternalShare: false), nil)
}
}
}
@@ -0,0 +1,81 @@
import Foundation
import UIKit
import TelegramCore
import AsyncDisplayKit
import TelegramPresentationData
import TelegramUIPreferences
import AccountContext
import ContextUI
protocol InstantPageImageAttribute {
}
struct InstantPageMapAttribute: InstantPageImageAttribute {
let zoom: Int32
let dimensions: CGSize
}
public final class InstantPageImageItem: InstantPageItem {
public var frame: CGRect
let webPage: TelegramMediaWebpage
public let media: InstantPageMedia
let attributes: [InstantPageImageAttribute]
public var medias: [InstantPageMedia] {
return [self.media]
}
public let interactive: Bool
let roundCorners: Bool
let fit: Bool
public let wantsNode: Bool = true
public let separatesTiles: Bool = false
init(frame: CGRect, webPage: TelegramMediaWebpage, media: InstantPageMedia, attributes: [InstantPageImageAttribute] = [], interactive: Bool, roundCorners: Bool, fit: Bool) {
self.frame = frame
self.webPage = webPage
self.media = media
self.attributes = attributes
self.interactive = interactive
self.roundCorners = roundCorners
self.fit = fit
}
public func node(context: AccountContext, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, theme: InstantPageTheme, sourceLocation: InstantPageSourceLocation, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, openPeer: @escaping (EnginePeer) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void, updateWebEmbedHeight: @escaping (CGFloat) -> Void, updateDetailsExpanded: @escaping (Bool) -> Void, currentExpandedDetails: [Int : Bool]?, getPreloadedResource: @escaping (String) -> Data?) -> InstantPageNode? {
return InstantPageImageNode(context: context, sourceLocation: sourceLocation, theme: theme, webPage: self.webPage, media: self.media, attributes: self.attributes, interactive: self.interactive, roundCorners: self.roundCorners, fit: self.fit, openMedia: openMedia, longPressMedia: longPressMedia, activatePinchPreview: activatePinchPreview, pinchPreviewFinished: pinchPreviewFinished, getPreloadedResource: getPreloadedResource)
}
public func matchesAnchor(_ anchor: String) -> Bool {
return false
}
public func matchesNode(_ node: InstantPageNode) -> Bool {
if let node = node as? InstantPageImageNode {
return node.media == self.media
} else {
return false
}
}
public func distanceThresholdGroup() -> Int? {
return 1
}
public func distanceThresholdWithGroupCount(_ count: Int) -> CGFloat {
if count > 3 {
return 400.0
} else {
return CGFloat.greatestFiniteMagnitude
}
}
public func drawInTile(context: CGContext) {
}
public func linkSelectionRects(at point: CGPoint) -> [CGRect] {
return []
}
}
@@ -0,0 +1,381 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import TelegramCore
import SwiftSignalKit
import TelegramPresentationData
import AccountContext
import RadialStatusNode
import PhotoResources
import MediaResources
import LocationResources
import LiveLocationPositionNode
import AppBundle
import TelegramUIPreferences
import ContextUI
import Tuples
private struct FetchControls {
let fetch: (Bool) -> Void
let cancel: () -> Void
}
final class InstantPageImageNode: ASDisplayNode, InstantPageNode {
private let context: AccountContext
private let webPage: TelegramMediaWebpage
private var theme: InstantPageTheme
let media: InstantPageMedia
let attributes: [InstantPageImageAttribute]
private let interactive: Bool
private let roundCorners: Bool
private let fit: Bool
private let openMedia: (InstantPageMedia) -> Void
private let longPressMedia: (InstantPageMedia) -> Void
private var fetchControls: FetchControls?
private let pinchContainerNode: PinchSourceContainerNode
private let imageNode: TransformImageNode
private let statusNode: RadialStatusNode
private let linkIconNode: ASImageNode
private let pinNode: ChatMessageLiveLocationPositionNode
private var currentSize: CGSize?
private var fetchStatus: EngineMediaResource.FetchStatus?
private var fetchedDisposable = MetaDisposable()
private var statusDisposable = MetaDisposable()
private var themeUpdated: Bool = false
init(context: AccountContext, sourceLocation: InstantPageSourceLocation, theme: InstantPageTheme, webPage: TelegramMediaWebpage, media: InstantPageMedia, attributes: [InstantPageImageAttribute], interactive: Bool, roundCorners: Bool, fit: Bool, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, getPreloadedResource: @escaping (String) -> Data?) {
self.context = context
self.theme = theme
self.webPage = webPage
self.media = media
self.attributes = attributes
self.interactive = interactive
self.roundCorners = roundCorners
self.fit = fit
self.openMedia = openMedia
self.longPressMedia = longPressMedia
self.pinchContainerNode = PinchSourceContainerNode()
self.imageNode = TransformImageNode()
self.statusNode = RadialStatusNode(backgroundNodeColor: UIColor(white: 0.0, alpha: 0.6))
self.linkIconNode = ASImageNode()
self.pinNode = ChatMessageLiveLocationPositionNode()
super.init()
self.pinchContainerNode.contentNode.addSubnode(self.imageNode)
self.addSubnode(self.pinchContainerNode)
if case let .image(image) = media.media, let largest = largestImageRepresentation(image.representations) {
if let externalResource = largest.resource as? InstantPageExternalMediaResource {
var url = externalResource.url
if !url.hasPrefix("http") && !url.hasPrefix("https") && url.hasPrefix("//") {
url = "https:\(url)"
}
let photoData: Signal<Tuple4<Data?, Data?, ChatMessagePhotoQuality, Bool>, NoError>
if let preloadedData = getPreloadedResource(externalResource.url) {
photoData = .single(Tuple4(nil, preloadedData, .full, true))
} else {
photoData = context.engine.resources.httpData(url: url, preserveExactUrl: true)
|> map(Optional.init)
|> `catch` { _ -> Signal<Data?, NoError> in
return .single(nil)
}
|> map { data in
if let data {
return Tuple4(nil, data, .full, true)
} else {
return Tuple4(nil, nil, .full, false)
}
}
}
self.imageNode.setSignal(chatMessagePhotoInternal(photoData: photoData)
|> map { _, _, generate in
return generate
})
} else {
let imageReference = ImageMediaReference.webPage(webPage: WebpageReference(webPage), media: image)
self.imageNode.setSignal(chatMessagePhoto(postbox: context.account.postbox, userLocation: sourceLocation.userLocation, photoReference: imageReference))
if !interactive || shouldDownloadMediaAutomatically(settings: context.sharedContext.currentAutomaticMediaDownloadSettings, peerType: sourceLocation.peerType, networkType: MediaAutoDownloadNetworkType(context.account.immediateNetworkType), authorPeerId: nil, contactsPeerIds: Set(), media: image) {
self.fetchedDisposable.set(chatMessagePhotoInteractiveFetched(context: context, userLocation: sourceLocation.userLocation, photoReference: imageReference, displayAtSize: nil, storeToDownloadsPeerId: nil).start())
}
self.fetchControls = FetchControls(fetch: { [weak self] manual in
if let strongSelf = self {
strongSelf.fetchedDisposable.set(chatMessagePhotoInteractiveFetched(context: context, userLocation: sourceLocation.userLocation, photoReference: imageReference, displayAtSize: nil, storeToDownloadsPeerId: nil).start())
}
}, cancel: {
chatMessagePhotoCancelInteractiveFetch(account: context.account, photoReference: imageReference)
})
if interactive {
self.statusDisposable.set((context.account.postbox.mediaBox.resourceStatus(largest.resource) |> deliverOnMainQueue).start(next: { [weak self] status in
displayLinkDispatcher.dispatch {
if let strongSelf = self {
strongSelf.fetchStatus = EngineMediaResource.FetchStatus(status)
strongSelf.updateFetchStatus()
}
}
}))
if media.url != nil {
self.linkIconNode.image = UIImage(bundleImageName: "Instant View/ImageLink")
self.pinchContainerNode.contentNode.addSubnode(self.linkIconNode)
}
self.pinchContainerNode.contentNode.addSubnode(self.statusNode)
}
}
} else if case let .file(file) = media.media {
if let externalResource = file.resource as? InstantPageExternalMediaResource {
let photoData: Signal<Tuple4<Data?, Data?, ChatMessagePhotoQuality, Bool>, NoError>
if let preloadedData = getPreloadedResource(externalResource.url) {
photoData = .single(Tuple4(nil, preloadedData, .full, true))
} else {
photoData = context.engine.resources.httpData(url: externalResource.url, preserveExactUrl: true)
|> map(Optional.init)
|> `catch` { _ -> Signal<Data?, NoError> in
return .single(nil)
}
|> map { data in
if let data {
return Tuple4(nil, data, .full, true)
} else {
return Tuple4(nil, nil, .full, false)
}
}
}
self.imageNode.setSignal(chatMessagePhotoInternal(photoData: photoData)
|> map { _, _, generate in
return generate
})
} else {
let fileReference = FileMediaReference.webPage(webPage: WebpageReference(webPage), media: file)
if file.mimeType.hasPrefix("image/") {
if !interactive || shouldDownloadMediaAutomatically(settings: context.sharedContext.currentAutomaticMediaDownloadSettings, peerType: sourceLocation.peerType, networkType: MediaAutoDownloadNetworkType(context.account.immediateNetworkType), authorPeerId: nil, contactsPeerIds: Set(), media: file) {
_ = freeMediaFileInteractiveFetched(account: context.account, userLocation: sourceLocation.userLocation, fileReference: fileReference).start()
}
self.imageNode.setSignal(instantPageImageFile(account: context.account, userLocation: sourceLocation.userLocation, fileReference: fileReference, fetched: true))
} else {
self.imageNode.setSignal(chatMessageVideo(postbox: context.account.postbox, userLocation: sourceLocation.userLocation, videoReference: fileReference))
}
if file.isVideo {
self.statusNode.transitionToState(.play(.white), animated: false, completion: {})
self.pinchContainerNode.contentNode.addSubnode(self.statusNode)
}
}
} else if case let .geo(map) = media.media {
self.addSubnode(self.pinNode)
var dimensions = CGSize(width: 200.0, height: 100.0)
for attribute in self.attributes {
if let mapAttribute = attribute as? InstantPageMapAttribute {
dimensions = mapAttribute.dimensions
break
}
}
let resource = MapSnapshotMediaResource(latitude: map.latitude, longitude: map.longitude, width: Int32(dimensions.width), height: Int32(dimensions.height))
self.imageNode.setSignal(chatMapSnapshotImage(engine: context.engine, resource: resource))
} else if case let .webpage(webPage) = media.media, case let .Loaded(content) = webPage.content, let image = content.image {
let imageReference = ImageMediaReference.webPage(webPage: WebpageReference(webPage), media: image)
self.imageNode.setSignal(chatMessagePhoto(postbox: context.account.postbox, userLocation: sourceLocation.userLocation, photoReference: imageReference))
self.fetchedDisposable.set(chatMessagePhotoInteractiveFetched(context: context, userLocation: sourceLocation.userLocation, photoReference: imageReference, displayAtSize: nil, storeToDownloadsPeerId: nil).start())
self.statusNode.transitionToState(.play(.white), animated: false, completion: {})
self.pinchContainerNode.contentNode.addSubnode(self.statusNode)
}
if let activatePinchPreview = activatePinchPreview {
self.pinchContainerNode.activate = { sourceNode in
activatePinchPreview(sourceNode)
}
self.pinchContainerNode.animatedOut = { [weak self] in
guard let strongSelf = self else {
return
}
pinchPreviewFinished?(strongSelf)
}
}
}
deinit {
self.fetchedDisposable.dispose()
self.statusDisposable.dispose()
}
override func didLoad() {
super.didLoad()
if self.interactive {
let recognizer = TapLongTapOrDoubleTapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:)))
recognizer.delaysTouchesBegan = false
self.view.addGestureRecognizer(recognizer)
} else {
self.view.isUserInteractionEnabled = false
}
}
func updateIsVisible(_ isVisible: Bool) {
}
func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition) {
}
func update(strings: PresentationStrings, theme: InstantPageTheme) {
if self.theme.imageTintColor != theme.imageTintColor {
self.theme = theme
self.themeUpdated = true
self.setNeedsLayout()
}
}
private func updateFetchStatus() {
var state: RadialStatusNodeState = .none
if let fetchStatus = self.fetchStatus {
switch fetchStatus {
case let .Fetching(_, progress):
let adjustedProgress = max(progress, 0.027)
state = .progress(color: .white, lineWidth: nil, value: CGFloat(adjustedProgress), cancelEnabled: true, animateRotation: true)
case .Remote:
state = .download(.white)
default:
break
}
}
self.statusNode.transitionToState(state, completion: { [weak statusNode] in
if state == .none {
statusNode?.removeFromSupernode()
}
})
}
override func layout() {
super.layout()
let size = self.bounds.size
if self.currentSize != size || self.themeUpdated {
self.currentSize = size
self.themeUpdated = false
self.pinchContainerNode.frame = CGRect(origin: CGPoint(), size: size)
self.pinchContainerNode.update(size: size, transition: .immediate)
self.imageNode.frame = CGRect(origin: CGPoint(), size: size)
let radialStatusSize: CGFloat = 50.0
self.statusNode.frame = CGRect(x: floorToScreenPixels((size.width - radialStatusSize) / 2.0), y: floorToScreenPixels((size.height - radialStatusSize) / 2.0), width: radialStatusSize, height: radialStatusSize)
if case let .image(image) = self.media.media, let largest = largestImageRepresentation(image.representations) {
let imageSize = largest.dimensions.cgSize.aspectFilled(size)
let boundingSize = size
let radius: CGFloat = self.roundCorners ? floor(min(imageSize.width, imageSize.height) / 2.0) : 0.0
let makeLayout = self.imageNode.asyncLayout()
let apply = makeLayout(TransformImageArguments(corners: ImageCorners(radius: radius), imageSize: imageSize, boundingSize: boundingSize, intrinsicInsets: UIEdgeInsets(), emptyColor: self.theme.panelBackgroundColor))
apply()
self.linkIconNode.frame = CGRect(x: size.width - 38.0, y: 14.0, width: 24.0, height: 24.0)
} else if case let .file(file) = self.media.media, let dimensions = file.dimensions {
let emptyColor = file.mimeType.hasPrefix("image/") ? self.theme.imageTintColor : nil
let imageSize = dimensions.cgSize.aspectFilled(size)
let boundingSize = size
let makeLayout = self.imageNode.asyncLayout()
let apply = makeLayout(TransformImageArguments(corners: ImageCorners(), imageSize: imageSize, boundingSize: boundingSize, intrinsicInsets: UIEdgeInsets(), emptyColor: emptyColor))
apply()
} else if case .geo = self.media.media {
for attribute in self.attributes {
if let mapAttribute = attribute as? InstantPageMapAttribute {
let imageSize = mapAttribute.dimensions.aspectFilled(size)
let boundingSize = size
let radius: CGFloat = self.roundCorners ? floor(min(imageSize.width, imageSize.height) / 2.0) : 0.0
let makeLayout = self.imageNode.asyncLayout()
let apply = makeLayout(TransformImageArguments(corners: ImageCorners(radius: radius), imageSize: imageSize, boundingSize: boundingSize, intrinsicInsets: UIEdgeInsets()))
apply()
break
}
}
let makePinLayout = self.pinNode.asyncLayout()
let theme = self.context.sharedContext.currentPresentationData.with { $0 }.theme
let (pinSize, pinApply) = makePinLayout(self.context, theme, .location(nil))
self.pinNode.frame = CGRect(origin: CGPoint(x: floor((size.width - pinSize.width) / 2.0), y: floor(size.height * 0.5 - 10.0 - pinSize.height / 2.0)), size: pinSize)
pinApply()
} else if case let .webpage(webPage) = media.media, case let .Loaded(content) = webPage.content, let image = content.image, let largest = largestImageRepresentation(image.representations) {
let imageSize = largest.dimensions.cgSize.aspectFilled(size)
let boundingSize = size
let radius: CGFloat = self.roundCorners ? floor(min(imageSize.width, imageSize.height) / 2.0) : 0.0
let makeLayout = self.imageNode.asyncLayout()
let apply = makeLayout(TransformImageArguments(corners: ImageCorners(radius: radius), imageSize: imageSize, boundingSize: boundingSize, intrinsicInsets: UIEdgeInsets(), emptyColor: self.theme.pageBackgroundColor))
apply()
}
}
}
func transitionNode(media: InstantPageMedia) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? {
if media == self.media {
let imageNode = self.imageNode
return (self.imageNode, self.imageNode.bounds, { [weak imageNode] in
return (imageNode?.view.snapshotContentTree(unhide: true), nil)
})
} else {
return nil
}
}
func updateHiddenMedia(media: InstantPageMedia?) {
self.imageNode.isHidden = self.media == media
self.statusNode.isHidden = self.imageNode.isHidden
}
@objc private func tapGesture(_ recognizer: TapLongTapOrDoubleTapGestureRecognizer) {
switch recognizer.state {
case .ended:
if let (gesture, _) = recognizer.lastRecognizedGestureAndLocation {
if let fetchStatus = self.fetchStatus {
switch fetchStatus {
case .Local:
switch gesture {
case .tap:
if case .image = self.media.media, self.media.index == -1 {
return
}
self.openMedia(self.media)
case .longTap:
self.longPressMedia(self.media)
default:
break
}
case .Remote, .Paused:
if case .tap = gesture {
self.fetchControls?.fetch(true)
}
case .Fetching:
if case .tap = gesture {
self.fetchControls?.cancel()
}
}
} else {
switch gesture {
case .tap:
if case .image = self.media.media, self.media.index == -1 {
return
}
self.openMedia(self.media)
case .longTap:
self.longPressMedia(self.media)
default:
break
}
}
}
default:
break
}
}
}
@@ -0,0 +1,24 @@
import Foundation
import UIKit
import TelegramCore
import AsyncDisplayKit
import TelegramPresentationData
import TelegramUIPreferences
import AccountContext
import ContextUI
public protocol InstantPageItem {
var frame: CGRect { get set }
var wantsNode: Bool { get }
var medias: [InstantPageMedia] { get }
var separatesTiles: Bool { get }
func matchesAnchor(_ anchor: String) -> Bool
func drawInTile(context: CGContext)
func node(context: AccountContext, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, theme: InstantPageTheme, sourceLocation: InstantPageSourceLocation, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, openPeer: @escaping (EnginePeer) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void, updateWebEmbedHeight: @escaping (CGFloat) -> Void, updateDetailsExpanded: @escaping (Bool) -> Void, currentExpandedDetails: [Int : Bool]?, getPreloadedResource: @escaping (String) -> Data?) -> InstantPageNode?
func matchesNode(_ node: InstantPageNode) -> Bool
func linkSelectionRects(at point: CGPoint) -> [CGRect]
func distanceThresholdGroup() -> Int?
func distanceThresholdWithGroupCount(_ count: Int) -> CGFloat
}
@@ -0,0 +1,894 @@
import Foundation
import UIKit
import TelegramCore
import Display
import TelegramPresentationData
import TelegramUIPreferences
import TelegramStringFormatting
import MosaicLayout
public final class InstantPageLayout {
public let origin: CGPoint
public let contentSize: CGSize
public let items: [InstantPageItem]
public init(origin: CGPoint, contentSize: CGSize, items: [InstantPageItem]) {
self.origin = origin
self.contentSize = contentSize
self.items = items
}
public func flattenedItemsWithOrigin(_ origin: CGPoint) -> [InstantPageItem] {
return self.items.map({ item in
var item = item
let itemFrame = item.frame.offsetBy(dx: origin.x, dy: origin.y)
item.frame = itemFrame
return item
})
}
}
private func setupStyleStack(_ stack: InstantPageTextStyleStack, theme: InstantPageTheme, category: InstantPageTextCategoryType, link: Bool) {
let attributes = theme.textCategories.attributes(type: category, link: link)
stack.push(.textColor(attributes.color))
stack.push(.markerColor(theme.markerColor))
stack.push(.linkColor(theme.linkColor))
stack.push(.linkMarkerColor(theme.linkHighlightColor))
switch attributes.font.style {
case .sans:
stack.push(.fontSerif(false))
case .serif:
stack.push(.fontSerif(true))
}
stack.push(.fontSize(attributes.font.size))
stack.push(.lineSpacingFactor(attributes.font.lineSpacingFactor))
if attributes.underline {
stack.push(.underline)
}
}
public func layoutInstantPageBlock(webpage: TelegramMediaWebpage, userLocation: MediaResourceUserLocation, rtl: Bool, block: InstantPageBlock, boundingWidth: CGFloat, horizontalInset: CGFloat, safeInset: CGFloat, isCover: Bool, previousItems: [InstantPageItem], fillToSize: CGSize?, media: [EngineMedia.Id: EngineMedia], mediaIndexCounter: inout Int, embedIndexCounter: inout Int, detailsIndexCounter: inout Int, theme: InstantPageTheme, strings: PresentationStrings, dateTimeFormat: PresentationDateTimeFormat, webEmbedHeights: [Int : CGFloat] = [:], excludeCaptions: Bool) -> InstantPageLayout {
let layoutCaption: (InstantPageCaption, CGSize) -> ([InstantPageItem], CGSize) = { caption, contentSize in
var items: [InstantPageItem] = []
var offset = contentSize.height
var contentSize = CGSize()
var rtl = rtl
if case .empty = caption.text {
} else {
contentSize.height += 14.0
offset += 14.0
let styleStack = InstantPageTextStyleStack()
setupStyleStack(styleStack, theme: theme, category: .caption, link: false)
let (textItem, captionItems, captionContentSize) = layoutTextItemWithString(attributedStringForRichText(caption.text, styleStack: styleStack), boundingWidth: boundingWidth - horizontalInset * 2.0, offset: CGPoint(x: horizontalInset, y: offset), media: media, webpage: webpage)
contentSize.height += captionContentSize.height
offset += captionContentSize.height
items.append(contentsOf: captionItems)
rtl = textItem?.containsRTL ?? rtl
}
if case .empty = caption.credit {
} else {
if case .empty = caption.text {
contentSize.height += 14.0
offset += 14.0
} else {
contentSize.height += 10.0
offset += 10.0
}
let styleStack = InstantPageTextStyleStack()
setupStyleStack(styleStack, theme: theme, category: .credit, link: false)
let (_, captionItems, captionContentSize) = layoutTextItemWithString(attributedStringForRichText(caption.credit, styleStack: styleStack), boundingWidth: boundingWidth - horizontalInset * 2.0, alignment: rtl ? .right : .natural, offset: CGPoint(x: horizontalInset, y: offset), media: media, webpage: webpage)
contentSize.height += captionContentSize.height
offset += captionContentSize.height
items.append(contentsOf: captionItems)
}
if contentSize.height > 0.0 && isCover {
contentSize.height += 14.0
}
return (items, contentSize)
}
let stringForDate: (Int32) -> String = { date in
let dateFormatter = DateFormatter()
dateFormatter.locale = localeWithStrings(strings)
dateFormatter.dateStyle = .long
dateFormatter.timeStyle = .none
return dateFormatter.string(from: Date(timeIntervalSince1970: Double(date)))
}
switch block {
case let .cover(block):
return layoutInstantPageBlock(webpage: webpage, userLocation: userLocation, rtl: rtl, block: block, boundingWidth: boundingWidth, horizontalInset: horizontalInset, safeInset: safeInset, isCover: true, previousItems:previousItems, fillToSize: fillToSize, media: media, mediaIndexCounter: &mediaIndexCounter, embedIndexCounter: &embedIndexCounter, detailsIndexCounter: &detailsIndexCounter, theme: theme, strings: strings, dateTimeFormat: dateTimeFormat, webEmbedHeights: webEmbedHeights, excludeCaptions: false)
case let .title(text):
let styleStack = InstantPageTextStyleStack()
setupStyleStack(styleStack, theme: theme, category: .header, link: false)
let (_, items, contentSize) = layoutTextItemWithString(attributedStringForRichText(text, styleStack: styleStack), boundingWidth: boundingWidth - horizontalInset * 2.0, offset: CGPoint(x: horizontalInset, y: 0.0), media: media, webpage: webpage)
return InstantPageLayout(origin: CGPoint(), contentSize: contentSize, items: items)
case let .subtitle(text):
let styleStack = InstantPageTextStyleStack()
setupStyleStack(styleStack, theme: theme, category: .subheader, link: false)
let (_, items, contentSize) = layoutTextItemWithString(attributedStringForRichText(text, styleStack: styleStack), boundingWidth: boundingWidth - horizontalInset * 2.0, offset: CGPoint(x: horizontalInset, y: 0.0), media: media, webpage: webpage)
return InstantPageLayout(origin: CGPoint(), contentSize: contentSize, items: items)
case let .authorDate(author: author, date: date):
let styleStack = InstantPageTextStyleStack()
setupStyleStack(styleStack, theme: theme, category: .caption, link: false)
var text: RichText?
if case .empty = author {
if date != 0 {
text = .plain(stringForDate(date))
}
} else {
if date != 0 {
let dateText = RichText.plain(stringForDate(date))
let formatString = strings.InstantPage_AuthorAndDateTitle("%1$@", "%2$@").string
let authorRange = formatString.range(of: "%1$@")!
let dateRange = formatString.range(of: "%2$@")!
if authorRange.lowerBound < dateRange.lowerBound {
let byPart = String(formatString[formatString.startIndex ..< authorRange.lowerBound])
let middlePart = String(formatString[authorRange.upperBound ..< dateRange.lowerBound])
let endPart = String(formatString[dateRange.upperBound...])
text = .concat([.plain(byPart), author, .plain(middlePart), dateText, .plain(endPart)])
} else {
let beforePart = String(formatString[formatString.startIndex ..< dateRange.lowerBound])
let middlePart = String(formatString[dateRange.upperBound ..< authorRange.lowerBound])
let endPart = String(formatString[authorRange.upperBound...])
text = .concat([.plain(beforePart), dateText, .plain(middlePart), author, .plain(endPart)])
}
} else {
text = author
}
}
if let text = text {
var previousItemHasRTL = false
if let previousItem = previousItems.last as? InstantPageTextItem, previousItem.containsRTL {
previousItemHasRTL = true
}
let (_, items, contentSize) = layoutTextItemWithString(attributedStringForRichText(text, styleStack: styleStack), boundingWidth: boundingWidth - horizontalInset * 2.0, alignment: rtl || previousItemHasRTL ? .right : .natural, offset: CGPoint(x: horizontalInset, y: 0.0), media: media, webpage: webpage)
return InstantPageLayout(origin: CGPoint(), contentSize: contentSize, items: items)
} else {
return InstantPageLayout(origin: CGPoint(), contentSize: CGSize(), items: [])
}
case let .kicker(text):
let styleStack = InstantPageTextStyleStack()
setupStyleStack(styleStack, theme: theme, category: .kicker, link: false)
let (_, items, contentSize) = layoutTextItemWithString(attributedStringForRichText(text, styleStack: styleStack), boundingWidth: boundingWidth - horizontalInset * 2.0, offset: CGPoint(x: horizontalInset, y: 0.0), media: media, webpage: webpage)
return InstantPageLayout(origin: CGPoint(), contentSize: contentSize, items: items)
case let .header(text):
let styleStack = InstantPageTextStyleStack()
setupStyleStack(styleStack, theme: theme, category: .header, link: false)
let (_, items, contentSize) = layoutTextItemWithString(attributedStringForRichText(text, styleStack: styleStack), boundingWidth: boundingWidth - horizontalInset * 2.0, offset: CGPoint(x: horizontalInset, y: 0.0), media: media, webpage: webpage)
return InstantPageLayout(origin: CGPoint(), contentSize: contentSize, items: items)
case let .subheader(text):
let styleStack = InstantPageTextStyleStack()
setupStyleStack(styleStack, theme: theme, category: .subheader, link: false)
let (_, items, contentSize) = layoutTextItemWithString(attributedStringForRichText(text, styleStack: styleStack), boundingWidth: boundingWidth - horizontalInset * 2.0, offset: CGPoint(x: horizontalInset, y: 0.0), media: media, webpage: webpage)
return InstantPageLayout(origin: CGPoint(), contentSize: contentSize, items: items)
case let .paragraph(text):
let styleStack = InstantPageTextStyleStack()
setupStyleStack(styleStack, theme: theme, category: .paragraph, link: false)
let (_, items, contentSize) = layoutTextItemWithString(attributedStringForRichText(text, styleStack: styleStack), boundingWidth: boundingWidth - horizontalInset * 2.0, horizontalInset: horizontalInset, offset: CGPoint(x: horizontalInset, y: 0.0), media: media, webpage: webpage)
return InstantPageLayout(origin: CGPoint(), contentSize: contentSize, items: items)
case let .preformatted(text):
let styleStack = InstantPageTextStyleStack()
setupStyleStack(styleStack, theme: theme, category: .paragraph, link: false)
let backgroundInset: CGFloat = 14.0
let (_, items, contentSize) = layoutTextItemWithString(attributedStringForRichText(text, styleStack: styleStack), boundingWidth: boundingWidth - horizontalInset * 2.0 - backgroundInset * 2.0, offset: CGPoint(x: 17.0, y: backgroundInset), media: media, webpage: webpage, opaqueBackground: true)
let backgroundItem = InstantPageShapeItem(frame: CGRect(origin: CGPoint(), size: CGSize(width: boundingWidth, height: contentSize.height + backgroundInset * 2.0)), shapeFrame: CGRect(origin: CGPoint(), size: CGSize(width: boundingWidth, height: contentSize.height + backgroundInset * 2.0)), shape: .rect, color: theme.codeBlockBackgroundColor)
var allItems: [InstantPageItem] = [backgroundItem]
allItems.append(contentsOf: items)
return InstantPageLayout(origin: CGPoint(), contentSize: CGSize(width: boundingWidth, height: contentSize.height + backgroundInset * 2.0), items: allItems)
case let .footer(text):
let styleStack = InstantPageTextStyleStack()
setupStyleStack(styleStack, theme: theme, category: .caption, link: false)
let (_, items, contentSize) = layoutTextItemWithString(attributedStringForRichText(text, styleStack: styleStack), boundingWidth: boundingWidth - horizontalInset * 2.0, offset: CGPoint(x: horizontalInset, y: 0.0), media: media, webpage: webpage)
return InstantPageLayout(origin: CGPoint(), contentSize: contentSize, items: items)
case .divider:
let lineWidth = floor(boundingWidth / 2.0)
let shapeItem = InstantPageShapeItem(frame: CGRect(origin: CGPoint(x: floor((boundingWidth - lineWidth) / 2.0), y: 0.0), size: CGSize(width: lineWidth, height: 1.0)), shapeFrame: CGRect(origin: CGPoint(), size: CGSize(width: lineWidth, height: 1.0)), shape: .rect, color: theme.textCategories.caption.color)
return InstantPageLayout(origin: CGPoint(), contentSize: shapeItem.frame.size, items: [shapeItem])
case let .list(contentItems, ordered):
var contentSize = CGSize(width: boundingWidth, height: 0.0)
var maxIndexWidth: CGFloat = 0.0
var listItems: [InstantPageItem] = []
var indexItems: [InstantPageItem] = []
var hasNums = false
if ordered {
for item in contentItems {
if let num = item.num, !num.isEmpty {
hasNums = true
break
}
}
}
for i in 0 ..< contentItems.count {
let item = contentItems[i]
if ordered {
let styleStack = InstantPageTextStyleStack()
setupStyleStack(styleStack, theme: theme, category: .paragraph, link: false)
let value: String
if hasNums {
if let num = item.num {
value = "\(num)."
} else {
value = " "
}
} else {
value = "\(i + 1)."
}
let (textItem, _, _) = layoutTextItemWithString(attributedStringForRichText(.plain(value), styleStack: styleStack), boundingWidth: boundingWidth - horizontalInset * 2.0, offset: CGPoint())
if let textItem = textItem, let line = textItem.lines.first {
textItem.selectable = false
maxIndexWidth = max(maxIndexWidth, line.frame.width)
indexItems.append(textItem)
}
} else {
let shapeItem = InstantPageShapeItem(frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: 6.0, height: 12.0)), shapeFrame: CGRect(origin: CGPoint(x: 0.0, y: 3.0), size: CGSize(width: 6.0, height: 6.0)), shape: .ellipse, color: theme.textCategories.paragraph.color)
indexItems.append(shapeItem)
}
}
let indexSpacing: CGFloat = ordered ? 12.0 : 20.0
for (i, item) in contentItems.enumerated() {
if (i != 0) {
contentSize.height += 18.0
}
let styleStack = InstantPageTextStyleStack()
setupStyleStack(styleStack, theme: theme, category: .paragraph, link: false)
var effectiveItem = item
if case let .blocks(blocks, num) = effectiveItem, blocks.isEmpty {
effectiveItem = .text(.plain(" "), num)
}
switch effectiveItem {
case let .text(text, _):
let (textItem, textItems, textItemSize) = layoutTextItemWithString(attributedStringForRichText(text, styleStack: styleStack), boundingWidth: boundingWidth - horizontalInset * 2.0 - indexSpacing - maxIndexWidth, offset: CGPoint(x: horizontalInset + indexSpacing + maxIndexWidth, y: contentSize.height), media: media, webpage: webpage)
contentSize.height += textItemSize.height
let indexItem = indexItems[i]
var itemFrame = indexItem.frame
var lineMidY: CGFloat = 0.0
if let textItem = textItem {
if let line = textItem.lines.first {
lineMidY = textItem.frame.minY + line.frame.midY
} else {
lineMidY = textItem.frame.midY
}
}
if let textIndexItem = indexItem as? InstantPageTextItem, let line = textIndexItem.lines.first {
itemFrame = itemFrame.offsetBy(dx: horizontalInset + maxIndexWidth - line.frame.width, dy: floorToScreenPixels(lineMidY - (itemFrame.height / 2.0)))
} else {
itemFrame = itemFrame.offsetBy(dx: horizontalInset, dy: floorToScreenPixels(lineMidY - itemFrame.height / 2.0))
}
indexItems[i].frame = itemFrame
listItems.append(indexItems[i])
listItems.append(contentsOf: textItems)
case let .blocks(blocks, _):
var previousBlock: InstantPageBlock?
var originY: CGFloat = contentSize.height
for subBlock in blocks {
let subLayout = layoutInstantPageBlock(webpage: webpage, userLocation: userLocation, rtl: rtl, block: subBlock, boundingWidth: boundingWidth - horizontalInset * 2.0 - indexSpacing - maxIndexWidth, horizontalInset: 0.0, safeInset: 0.0, isCover: false, previousItems: listItems, fillToSize: nil, media: media, mediaIndexCounter: &mediaIndexCounter, embedIndexCounter: &embedIndexCounter, detailsIndexCounter: &detailsIndexCounter, theme: theme, strings: strings, dateTimeFormat: dateTimeFormat, webEmbedHeights: webEmbedHeights, excludeCaptions: false)
let spacing: CGFloat = previousBlock != nil && subLayout.contentSize.height > 0.0 ? spacingBetweenBlocks(upper: previousBlock, lower: subBlock) : 0.0
let blockItems = subLayout.flattenedItemsWithOrigin(CGPoint(x: horizontalInset + indexSpacing + maxIndexWidth, y: contentSize.height + spacing))
if previousBlock == nil {
originY += spacing
}
listItems.append(contentsOf: blockItems)
contentSize.height += subLayout.contentSize.height + spacing
previousBlock = subBlock
}
let indexItem = indexItems[i]
var indexItemFrame = indexItem.frame
if let textIndexItem = indexItem as? InstantPageTextItem, let line = textIndexItem.lines.first {
indexItemFrame = indexItemFrame.offsetBy(dx: horizontalInset + maxIndexWidth - line.frame.width, dy: originY)
} else {
indexItemFrame = indexItemFrame.offsetBy(dx: horizontalInset, dy: originY)
}
indexItems[i].frame = indexItemFrame
listItems.append(indexItems[i])
break
default:
break
}
}
return InstantPageLayout(origin: CGPoint(), contentSize: contentSize, items: listItems)
case let .blockQuote(text, caption):
let lineInset: CGFloat = 20.0
let verticalInset: CGFloat = 4.0
var contentSize = CGSize(width: boundingWidth, height: verticalInset)
var items: [InstantPageItem] = []
let styleStack = InstantPageTextStyleStack()
setupStyleStack(styleStack, theme: theme, category: .paragraph, link: false)
styleStack.push(.italic)
let (_, textItems, textContentSize) = layoutTextItemWithString(attributedStringForRichText(text, styleStack: styleStack), boundingWidth: boundingWidth - horizontalInset * 2.0 - lineInset, offset: CGPoint(x: horizontalInset + lineInset, y: contentSize.height), media: media, webpage: webpage)
contentSize.height += textContentSize.height
items.append(contentsOf: textItems)
if case .empty = caption {
} else {
contentSize.height += 14.0
let styleStack = InstantPageTextStyleStack()
setupStyleStack(styleStack, theme: theme, category: .caption, link: false)
let (_, captionItems, captionContentSize) = layoutTextItemWithString(attributedStringForRichText(caption, styleStack: styleStack), boundingWidth: boundingWidth - horizontalInset * 2.0 - lineInset, offset: CGPoint(x: horizontalInset + lineInset, y: contentSize.height), media: media, webpage: webpage)
contentSize.height += captionContentSize.height
items.append(contentsOf: captionItems)
}
contentSize.height += verticalInset
let shapeItem = InstantPageShapeItem(frame: CGRect(origin: CGPoint(x: horizontalInset, y: 0.0), size: CGSize(width: 3.0, height: contentSize.height)), shapeFrame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: 3.0, height: contentSize.height)), shape: .roundLine, color: theme.textCategories.paragraph.color)
items.append(shapeItem)
return InstantPageLayout(origin: CGPoint(), contentSize: contentSize, items: items)
case let .pullQuote(text, caption):
let verticalInset: CGFloat = 4.0
var contentSize = CGSize(width: boundingWidth, height: verticalInset)
var items: [InstantPageItem] = []
let styleStack = InstantPageTextStyleStack()
setupStyleStack(styleStack, theme: theme, category: .paragraph, link: false)
styleStack.push(.italic)
let (_, textItems, textContentSize) = layoutTextItemWithString(attributedStringForRichText(text, styleStack: styleStack), boundingWidth: boundingWidth - horizontalInset * 2.0, alignment: .center, offset: CGPoint(x: 0.0, y: contentSize.height), media: media, webpage: webpage)
for var item in textItems {
item.frame = item.frame.offsetBy(dx: horizontalInset, dy: 0.0)
}
contentSize.height += textContentSize.height
items.append(contentsOf: textItems)
if case .empty = caption {
} else {
contentSize.height += 14.0
let styleStack = InstantPageTextStyleStack()
setupStyleStack(styleStack, theme: theme, category: .caption, link: false)
let (_, captionItems, captionContentSize) = layoutTextItemWithString(attributedStringForRichText(caption, styleStack: styleStack), boundingWidth: boundingWidth - horizontalInset * 2.0, alignment: .center, offset: CGPoint(x: 0.0, y: contentSize.height), media: media, webpage: webpage)
for var item in textItems {
item.frame = item.frame.offsetBy(dx: horizontalInset, dy: 0.0)
}
contentSize.height += captionContentSize.height
items.append(contentsOf: captionItems)
}
contentSize.height += verticalInset
return InstantPageLayout(origin: CGPoint(), contentSize: contentSize, items: items)
case let .image(id, caption, url, webpageId):
if case let .image(image) = media[id], let largest = largestImageRepresentation(image.representations) {
let imageSize = largest.dimensions
var filledSize = imageSize.cgSize.aspectFitted(CGSize(width: boundingWidth - safeInset * 2.0, height: 1200.0))
if let size = fillToSize {
filledSize = size
} else if isCover {
filledSize = imageSize.cgSize.aspectFilled(CGSize(width: boundingWidth - safeInset * 2.0, height: 1.0))
if !filledSize.height.isZero {
filledSize = filledSize.cropped(CGSize(width: boundingWidth - safeInset * 2.0, height: floor((boundingWidth - safeInset * 2.0) * 3.0 / 5.0)))
}
}
let mediaIndex = mediaIndexCounter
mediaIndexCounter += 1
var contentSize = CGSize(width: boundingWidth - safeInset * 2.0, height: 0.0)
var items: [InstantPageItem] = []
var mediaUrl: InstantPageUrlItem?
if let url = url {
mediaUrl = InstantPageUrlItem(url: url, webpageId: webpageId)
}
let mediaItem = InstantPageImageItem(frame: CGRect(origin: CGPoint(x: floor((boundingWidth - filledSize.width) / 2.0), y: 0.0), size: filledSize), webPage: webpage, media: InstantPageMedia(index: mediaIndex, media: .image(image), url: mediaUrl, caption: caption.text, credit: caption.credit), interactive: true, roundCorners: false, fit: false)
items.append(mediaItem)
contentSize.height += filledSize.height
if !excludeCaptions {
let (captionItems, captionSize) = layoutCaption(caption, contentSize)
items.append(contentsOf: captionItems)
contentSize.height += captionSize.height
}
return InstantPageLayout(origin: CGPoint(), contentSize: contentSize, items: items)
} else {
return InstantPageLayout(origin: CGPoint(), contentSize: CGSize(), items: [])
}
case let .video(id, caption, autoplay, _):
if case let .file(file) = media[id], let dimensions = file.dimensions {
let imageSize = dimensions
var filledSize = imageSize.cgSize.aspectFitted(CGSize(width: boundingWidth - safeInset * 2.0, height: 1200.0))
if let size = fillToSize {
filledSize = size
} else if isCover {
filledSize = imageSize.cgSize.aspectFilled(CGSize(width: boundingWidth - safeInset * 2.0, height: 1.0))
if !filledSize.height.isZero {
filledSize = filledSize.cropped(CGSize(width: boundingWidth - safeInset * 2.0, height: floor((boundingWidth - safeInset * 2.0) * 3.0 / 5.0)))
}
}
let mediaIndex = mediaIndexCounter
mediaIndexCounter += 1
var contentSize = CGSize(width: boundingWidth - safeInset * 2.0, height: 0.0)
var items: [InstantPageItem] = []
if autoplay {
let mediaItem = InstantPagePlayableVideoItem(frame: CGRect(origin: CGPoint(x: floor((boundingWidth - filledSize.width) / 2.0), y: 0.0), size: filledSize), webPage: webpage, media: InstantPageMedia(index: mediaIndex, media: .file(file), url: nil, caption: caption.text, credit: caption.credit), interactive: true)
items.append(mediaItem)
} else {
let mediaItem = InstantPageImageItem(frame: CGRect(origin: CGPoint(x: floor((boundingWidth - filledSize.width) / 2.0), y: 0.0), size: filledSize), webPage: webpage, media: InstantPageMedia(index: mediaIndex, media: .file(file), url: nil, caption: caption.text, credit: caption.credit), interactive: true, roundCorners: false, fit: false)
items.append(mediaItem)
}
contentSize.height += filledSize.height
if !excludeCaptions {
let (captionItems, captionSize) = layoutCaption(caption, contentSize)
items.append(contentsOf: captionItems)
contentSize.height += captionSize.height
}
return InstantPageLayout(origin: CGPoint(), contentSize: contentSize, items: items)
} else {
return InstantPageLayout(origin: CGPoint(), contentSize: CGSize(), items: [])
}
case let .collage(innerItems, caption):
var items: [InstantPageItem] = []
var itemSizes: [CGSize] = []
for subItem in innerItems {
var size = CGSize()
switch subItem {
case let .image(id, _, _, _):
if case let .image(image) = media[id], let largest = largestImageRepresentation(image.representations) {
size = largest.dimensions.cgSize
}
case let .video(id, _, _, _):
if case let .file(file) = media[id], let dimensions = file.dimensions {
size = dimensions.cgSize
}
default:
break
}
itemSizes.append(size)
}
let (mosaicLayout, mosaicSize) = chatMessageBubbleMosaicLayout(maxSize: CGSize(width: boundingWidth, height: boundingWidth), itemSizes: itemSizes)
var i = 0
for subItem in innerItems {
let frame = mosaicLayout[i].0
let subLayout = layoutInstantPageBlock(webpage: webpage, userLocation: userLocation, rtl: rtl, block: subItem, boundingWidth: frame.width, horizontalInset: 0.0, safeInset: 0.0, isCover: false, previousItems: items, fillToSize: frame.size, media: media, mediaIndexCounter: &mediaIndexCounter, embedIndexCounter: &embedIndexCounter, detailsIndexCounter: &detailsIndexCounter, theme: theme, strings: strings, dateTimeFormat: dateTimeFormat, webEmbedHeights: webEmbedHeights, excludeCaptions: true)
items.append(contentsOf: subLayout.flattenedItemsWithOrigin(frame.origin))
i += 1
}
var contentSize = CGSize(width: boundingWidth - safeInset * 2.0, height: mosaicSize.height)
let (captionItems, captionSize) = layoutCaption(caption, contentSize)
items.append(contentsOf: captionItems)
contentSize.height += captionSize.height
return InstantPageLayout(origin: CGPoint(), contentSize: contentSize, items: items)
case let .postEmbed(_, _, avatarId, author, date, blocks, caption):
var contentSize = CGSize(width: boundingWidth, height: 0.0)
let lineInset: CGFloat = 20.0
let verticalInset: CGFloat = 4.0
let itemSpacing: CGFloat = 10.0
var avatarInset: CGFloat = 0.0
var avatarVerticalInset: CGFloat = 0.0
contentSize.height += verticalInset
var items: [InstantPageItem] = []
if !author.isEmpty {
let avatar: TelegramMediaImage? = avatarId.flatMap { id -> TelegramMediaImage? in
if case let .image(image) = media[id] {
return image
} else {
return nil
}
}
if let avatar = avatar {
let avatarItem = InstantPageImageItem(frame: CGRect(origin: CGPoint(x: horizontalInset + lineInset + 1.0, y: contentSize.height - 2.0), size: CGSize(width: 50.0, height: 50.0)), webPage: webpage, media: InstantPageMedia(index: -1, media: .image(avatar), url: nil, caption: nil, credit: nil), interactive: false, roundCorners: true, fit: false)
items.append(avatarItem)
avatarInset += 62.0
avatarVerticalInset += 6.0
if date == 0 {
avatarVerticalInset += 11.0
}
}
let styleStack = InstantPageTextStyleStack()
setupStyleStack(styleStack, theme: theme, category: .paragraph, link: false)
styleStack.push(.bold)
let (_, textItems, textContentSize) = layoutTextItemWithString(attributedStringForRichText(.plain(author), styleStack: styleStack), boundingWidth: boundingWidth - horizontalInset * 2.0 - lineInset - avatarInset, offset: CGPoint(x: horizontalInset + lineInset + avatarInset, y: contentSize.height + avatarVerticalInset), media: media, webpage: webpage)
items.append(contentsOf: textItems)
contentSize.height += textContentSize.height + avatarVerticalInset
}
if date != 0 {
if items.count != 0 {
contentSize.height += itemSpacing
}
let dateString = DateFormatter.localizedString(from: Date(timeIntervalSince1970: Double(date)), dateStyle: .long, timeStyle: .none)
let styleStack = InstantPageTextStyleStack()
setupStyleStack(styleStack, theme: theme, category: .caption, link: false)
let (_, textItems, textContentSize) = layoutTextItemWithString(attributedStringForRichText(.plain(dateString), styleStack: styleStack), boundingWidth: boundingWidth - horizontalInset * 2.0 - lineInset - avatarInset, offset: CGPoint(x: horizontalInset + lineInset + avatarInset, y: contentSize.height), media: media, webpage: webpage)
items.append(contentsOf: textItems)
contentSize.height += textContentSize.height
}
if items.count != 0 {
contentSize.height += itemSpacing
}
var previousBlock: InstantPageBlock?
for subBlock in blocks {
let subLayout = layoutInstantPageBlock(webpage: webpage, userLocation: userLocation, rtl: rtl, block: subBlock, boundingWidth: boundingWidth - horizontalInset * 2.0 - lineInset, horizontalInset: 0.0, safeInset: 0.0, isCover: false, previousItems: items, fillToSize: nil, media: media, mediaIndexCounter: &mediaIndexCounter, embedIndexCounter: &embedIndexCounter, detailsIndexCounter: &detailsIndexCounter, theme: theme, strings: strings, dateTimeFormat: dateTimeFormat, webEmbedHeights: webEmbedHeights, excludeCaptions: false)
let spacing = spacingBetweenBlocks(upper: previousBlock, lower: subBlock)
let blockItems = subLayout.flattenedItemsWithOrigin(CGPoint(x: horizontalInset + lineInset, y: contentSize.height + spacing))
items.append(contentsOf: blockItems)
contentSize.height += subLayout.contentSize.height + spacing
previousBlock = subBlock
}
contentSize.height += verticalInset
items.append(InstantPageShapeItem(frame: CGRect(origin: CGPoint(x: horizontalInset, y: 0.0), size: CGSize(width: 3.0, height: contentSize.height)), shapeFrame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: 3.0, height: contentSize.height)), shape: .roundLine, color: theme.textCategories.paragraph.color))
let (captionItems, captionSize) = layoutCaption(caption, contentSize)
items.append(contentsOf: captionItems)
contentSize.height += captionSize.height
return InstantPageLayout(origin: CGPoint(), contentSize: contentSize, items: items)
case let .slideshow(items: subItems, caption: caption):
var contentSize = CGSize(width: boundingWidth, height: 0.0)
var items: [InstantPageItem] = []
var itemMedias: [InstantPageMedia] = []
for subBlock in subItems {
switch subBlock {
case let .image(id, caption, url, webpageId):
if case let .image(image) = media[id], let imageSize = largestImageRepresentation(image.representations)?.dimensions {
let mediaIndex = mediaIndexCounter
mediaIndexCounter += 1
let filledSize = imageSize.cgSize.fitted(CGSize(width: boundingWidth, height: 1200.0))
contentSize.height = max(contentSize.height, filledSize.height)
var mediaUrl: InstantPageUrlItem?
if let url = url {
mediaUrl = InstantPageUrlItem(url: url, webpageId: webpageId)
}
itemMedias.append(InstantPageMedia(index: mediaIndex, media: .image(image), url: mediaUrl, caption: caption.text, credit: caption.credit))
}
break
default:
break
}
}
items.append(InstantPageSlideshowItem(frame: CGRect(origin: CGPoint(), size: CGSize(width: boundingWidth, height: contentSize.height)), webPage: webpage, medias: itemMedias))
let (captionItems, captionSize) = layoutCaption(caption, contentSize)
items.append(contentsOf: captionItems)
contentSize.height += captionSize.height
return InstantPageLayout(origin: CGPoint(), contentSize: contentSize, items: items)
case let .webEmbed(url, html, dimensions, caption, stretchToWidth, allowScrolling, coverId):
var embedBoundingWidth = boundingWidth - horizontalInset * 2.0
if stretchToWidth {
embedBoundingWidth = boundingWidth
}
let embedIndex = embedIndexCounter
embedIndexCounter += 1
let size: CGSize
if let dimensions = dimensions {
if dimensions.width <= 0 {
size = CGSize(width: embedBoundingWidth, height: dimensions.cgSize.height)
} else {
size = dimensions.cgSize.aspectFitted(CGSize(width: embedBoundingWidth, height: embedBoundingWidth))
}
} else {
if let height = webEmbedHeights[embedIndex] {
size = CGSize(width: embedBoundingWidth, height: CGFloat(height))
} else {
size = CGSize(width: embedBoundingWidth, height: 44.0)
}
}
var items: [InstantPageItem] = []
var contentSize: CGSize
let frame = CGRect(origin: CGPoint(x: floor((boundingWidth - size.width) / 2.0), y: 0.0), size: size)
let item: InstantPageItem
if let url = url, let coverId = coverId, case let .image(image) = media[coverId] {
let loadedContent = TelegramMediaWebpageLoadedContent(url: url, displayUrl: url, hash: 0, type: "video", websiteName: nil, title: nil, text: nil, embedUrl: url, embedType: "video", embedSize: PixelDimensions(size), duration: nil, author: nil, isMediaLargeByDefault: nil, imageIsVideoCover: false, image: image, file: nil, story: nil, attributes: [], instantPage: nil)
let content = TelegramMediaWebpageContent.Loaded(loadedContent)
item = InstantPageImageItem(frame: frame, webPage: webpage, media: InstantPageMedia(index: embedIndex, media: .webpage(TelegramMediaWebpage(webpageId: EngineMedia.Id(namespace: Namespaces.Media.LocalWebpage, id: -1), content: content)), url: nil, caption: nil, credit: nil), attributes: [], interactive: true, roundCorners: false, fit: false)
} else {
item = InstantPageWebEmbedItem(frame: frame, url: url, html: html, enableScrolling: allowScrolling)
}
items.append(item)
contentSize = item.frame.size
let (captionItems, captionSize) = layoutCaption(caption, contentSize)
items.append(contentsOf: captionItems)
contentSize.height += captionSize.height
return InstantPageLayout(origin: CGPoint(), contentSize: contentSize, items: items)
case let .channelBanner(peer):
var contentSize = CGSize(width: boundingWidth, height: 0.0)
var items: [InstantPageItem] = []
var offset: CGFloat = 0.0
var previousItemHasRTL = false
if let previousItem = previousItems.last as? InstantPageTextItem {
if previousItem.containsRTL {
previousItemHasRTL = true
}
var minY = previousItem.frame.minY
if let firstItem = previousItems.first {
minY = firstItem.frame.maxY
}
offset = minY - previousItem.frame.maxY
}
if !offset.isZero {
offset -= 40.0 + 14.0
}
if let peer = peer {
let item = InstantPagePeerReferenceItem(frame: CGRect(origin: CGPoint(x: 0.0, y: offset), size: CGSize(width: boundingWidth, height: 40.0)), initialPeer: .channel(peer), safeInset: safeInset, transparent: !offset.isZero, rtl: rtl || previousItemHasRTL)
items.append(item)
if offset.isZero {
contentSize.height += 40.0
}
}
return InstantPageLayout(origin: CGPoint(x: 0.0, y: offset), contentSize: contentSize, items: items)
case let .anchor(name):
let item = InstantPageAnchorItem(frame: CGRect(origin: CGPoint(), size: CGSize(width: boundingWidth, height: 0.0)), anchor: name)
return InstantPageLayout(origin: CGPoint(), contentSize: item.frame.size, items: [item])
case let .audio(audioId, caption):
var contentSize = CGSize(width: boundingWidth, height: 0.0)
var items: [InstantPageItem] = []
if case let .file(file) = media[audioId] {
let mediaIndex = mediaIndexCounter
mediaIndexCounter += 1
let item = InstantPageAudioItem(frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: boundingWidth, height: 48.0)), media: InstantPageMedia(index: mediaIndex, media: .file(file), url: nil, caption: nil, credit: nil), webpage: webpage)
contentSize.height += item.frame.height
items.append(item)
let (captionItems, captionSize) = layoutCaption(caption, contentSize)
items.append(contentsOf: captionItems)
contentSize.height += captionSize.height
}
return InstantPageLayout(origin: CGPoint(), contentSize: contentSize, items: items)
case let .table(title, rows, bordered, striped):
var contentSize = CGSize(width: boundingWidth, height: 0.0)
var items: [InstantPageItem] = []
var styleStack = InstantPageTextStyleStack()
setupStyleStack(styleStack, theme: theme, category: .caption, link: false)
let backgroundInset: CGFloat = 0.0
let (_, textItems, textContentSize) = layoutTextItemWithString(attributedStringForRichText(title, styleStack: styleStack), boundingWidth: boundingWidth - horizontalInset * 2.0 - backgroundInset * 2.0, alignment: .center, offset: CGPoint(), media: media, webpage: webpage)
for var item in textItems {
item.frame = item.frame.offsetBy(dx: horizontalInset, dy: 0.0)
}
items.append(contentsOf: textItems)
contentSize.height += textContentSize.height + 10.0
styleStack = InstantPageTextStyleStack()
setupStyleStack(styleStack, theme: theme, category: .table, link: false)
let tableBoundingWidth = boundingWidth - horizontalInset * 2.0
let tableItem = layoutTableItem(rtl: rtl, rows: rows, styleStack: styleStack, theme: theme, bordered: bordered, striped: striped, boundingWidth: tableBoundingWidth, horizontalInset: horizontalInset, media: media, webpage: webpage)
tableItem.frame = tableItem.frame.offsetBy(dx: 0.0, dy: contentSize.height)
contentSize.height += tableItem.frame.height
items.append(tableItem)
return InstantPageLayout(origin: CGPoint(), contentSize: contentSize, items: items)
case let .details(title, blocks, expanded):
var contentSize = CGSize(width: boundingWidth, height: 0.0)
var subitems: [InstantPageItem] = []
let detailsIndex = detailsIndexCounter
detailsIndexCounter += 1
var subDetailsIndex = 0
var previousBlock: InstantPageBlock?
for subBlock in blocks {
let subLayout = layoutInstantPageBlock(webpage: webpage, userLocation: userLocation, rtl: rtl, block: subBlock, boundingWidth: boundingWidth, horizontalInset: horizontalInset, safeInset: safeInset, isCover: false, previousItems: subitems, fillToSize: nil, media: media, mediaIndexCounter: &mediaIndexCounter, embedIndexCounter: &embedIndexCounter, detailsIndexCounter: &subDetailsIndex, theme: theme, strings: strings, dateTimeFormat: dateTimeFormat, webEmbedHeights: webEmbedHeights, excludeCaptions: false)
let spacing = spacingBetweenBlocks(upper: previousBlock, lower: subBlock)
let blockItems = subLayout.flattenedItemsWithOrigin(CGPoint(x: 0.0, y: contentSize.height + spacing))
subitems.append(contentsOf: blockItems)
contentSize.height += subLayout.contentSize.height + spacing
previousBlock = subBlock
}
if !blocks.isEmpty {
let closingSpacing = spacingBetweenBlocks(upper: previousBlock, lower: nil)
contentSize.height += closingSpacing
}
let styleStack = InstantPageTextStyleStack()
setupStyleStack(styleStack, theme: theme, category: .paragraph, link: false)
styleStack.push(.lineSpacingFactor(0.685))
let detailsItem = layoutDetailsItem(theme: theme, title: attributedStringForRichText(title, styleStack: styleStack), boundingWidth: boundingWidth, items: subitems, contentSize: contentSize, safeInset: safeInset, rtl: rtl, initiallyExpanded: expanded, index: detailsIndex)
return InstantPageLayout(origin: CGPoint(), contentSize: detailsItem.frame.size, items: [detailsItem])
case let .relatedArticles(title, articles):
var contentSize = CGSize(width: boundingWidth, height: 0.0)
var items: [InstantPageItem] = []
let styleStack = InstantPageTextStyleStack()
setupStyleStack(styleStack, theme: theme, category: .paragraph, link: false)
styleStack.push(.bold)
let backgroundInset: CGFloat = 14.0
let (_, textItems, textContentSize) = layoutTextItemWithString(attributedStringForRichText(title, styleStack: styleStack), boundingWidth: boundingWidth - horizontalInset * 2.0 - backgroundInset * 2.0, offset: CGPoint(x: horizontalInset, y: backgroundInset), media: media, webpage: webpage, opaqueBackground: true)
let backgroundItem = InstantPageShapeItem(frame: CGRect(origin: CGPoint(), size: CGSize(width: boundingWidth, height: textContentSize.height + backgroundInset * 2.0)), shapeFrame: CGRect(origin: CGPoint(), size: CGSize(width: boundingWidth, height: textContentSize.height + backgroundInset * 2.0)), shape: .rect, color: theme.panelBackgroundColor)
items.append(backgroundItem)
items.append(contentsOf: textItems)
contentSize.height += backgroundItem.frame.height
for (i, article) in articles.enumerated() {
var cover: TelegramMediaImage?
if let coverId = article.photoId {
if case let .image(image) = media[coverId] {
cover = image
}
}
var styleStack = InstantPageTextStyleStack()
setupStyleStack(styleStack, theme: theme, category: .article, link: false)
let title = attributedStringForRichText(.plain(article.title ?? ""), styleStack: styleStack)
styleStack = InstantPageTextStyleStack()
setupStyleStack(styleStack, theme: theme, category: .caption, link: false)
var subtext: String?
if article.author != nil || article.date != nil {
if let author = article.author {
if let date = article.date {
subtext = strings.InstantPage_RelatedArticleAuthorAndDateTitle(author, stringForDate(date)).string
} else {
subtext = author
}
} else if let date = article.date {
subtext = stringForDate(date)
}
} else {
subtext = article.description
}
let description = attributedStringForRichText(.plain(subtext ?? ""), styleStack: styleStack)
let item = layoutArticleItem(theme: theme, userLocation: userLocation, webPage: webpage, title: title, description: description, cover: cover, url: article.url, webpageId: article.webpageId, boundingWidth: boundingWidth, rtl: rtl)
item.frame = item.frame.offsetBy(dx: 0.0, dy: contentSize.height)
contentSize.height += item.frame.height
items.append(item)
let inset: CGFloat = i == articles.count - 1 ? 0.0 : 17.0
let lineSize = CGSize(width: boundingWidth - inset, height: UIScreenPixel)
let shapeItem = InstantPageShapeItem(frame: CGRect(origin: CGPoint(x: rtl || item.rtl ? 0.0 : inset, y: contentSize.height - lineSize.height), size: lineSize), shapeFrame: CGRect(origin: CGPoint(), size: lineSize), shape: .rect, color: theme.controlColor)
items.append(shapeItem)
}
return InstantPageLayout(origin: CGPoint(), contentSize: contentSize, items: items)
case let .map(latitude, longitude, zoom, dimensions, caption):
let imageSize = dimensions
var filledSize = imageSize.cgSize.aspectFitted(CGSize(width: boundingWidth - safeInset * 2.0, height: 1200.0))
if let size = fillToSize {
filledSize = size
} else if isCover {
filledSize = imageSize.cgSize.aspectFilled(CGSize(width: boundingWidth - safeInset * 2.0, height: 1.0))
if !filledSize.height.isZero {
filledSize = filledSize.cropped(CGSize(width: boundingWidth - safeInset * 2.0, height: floor((boundingWidth - safeInset * 2.0) * 3.0 / 5.0)))
}
}
let map = TelegramMediaMap(latitude: latitude, longitude: longitude, heading: nil, accuracyRadius: nil, venue: nil, liveBroadcastingTimeout: nil, liveProximityNotificationRadius: nil)
let attributes: [InstantPageImageAttribute] = [InstantPageMapAttribute(zoom: zoom, dimensions: dimensions.cgSize)]
var contentSize = CGSize(width: boundingWidth - safeInset * 2.0, height: 0.0)
var items: [InstantPageItem] = []
let mediaItem = InstantPageImageItem(frame: CGRect(origin: CGPoint(x: floor((boundingWidth - filledSize.width) / 2.0), y: 0.0), size: filledSize), webPage: webpage, media: InstantPageMedia(index: -1, media: .geo(map), url: nil, caption: caption.text, credit: caption.credit), attributes: attributes, interactive: true, roundCorners: false, fit: false)
items.append(mediaItem)
contentSize.height += filledSize.height
let (captionItems, captionSize) = layoutCaption(caption, contentSize)
items.append(contentsOf: captionItems)
contentSize.height += captionSize.height
return InstantPageLayout(origin: CGPoint(), contentSize: contentSize, items: items)
default:
return InstantPageLayout(origin: CGPoint(), contentSize: CGSize(), items: [])
}
}
public func instantPageLayoutForWebPage(_ webPage: TelegramMediaWebpage, instantPage: InstantPage?, userLocation: MediaResourceUserLocation, boundingWidth: CGFloat, safeInset: CGFloat, strings: PresentationStrings, theme: InstantPageTheme, dateTimeFormat: PresentationDateTimeFormat, webEmbedHeights: [Int : CGFloat] = [:]) -> InstantPageLayout {
var maybeLoadedContent: TelegramMediaWebpageLoadedContent?
if case let .Loaded(content) = webPage.content {
maybeLoadedContent = content
}
guard let loadedContent = maybeLoadedContent, let instantPage else {
return InstantPageLayout(origin: CGPoint(), contentSize: CGSize(), items: [])
}
let rtl = instantPage.rtl
let pageBlocks = instantPage.blocks
var contentSize = CGSize(width: boundingWidth, height: 0.0)
var items: [InstantPageItem] = []
var media = instantPage.media.mapValues(EngineMedia.init)
if let image = loadedContent.image, let id = image.id {
media[id] = .image(image)
}
if let video = loadedContent.file, let id = video.id {
media[id] = .file(video)
}
var mediaIndexCounter: Int = 0
var embedIndexCounter: Int = 0
var detailsIndexCounter: Int = 0
var previousBlock: InstantPageBlock?
for block in pageBlocks {
let blockLayout = layoutInstantPageBlock(webpage: webPage, userLocation: userLocation, rtl: rtl, block: block, boundingWidth: boundingWidth, horizontalInset: 17.0 + safeInset, safeInset: safeInset, isCover: false, previousItems: items, fillToSize: nil, media: media, mediaIndexCounter: &mediaIndexCounter, embedIndexCounter: &embedIndexCounter, detailsIndexCounter: &detailsIndexCounter, theme: theme, strings: strings, dateTimeFormat: dateTimeFormat, webEmbedHeights: webEmbedHeights, excludeCaptions: false)
let spacing = spacingBetweenBlocks(upper: previousBlock, lower: block)
let blockItems = blockLayout.flattenedItemsWithOrigin(CGPoint(x: 0.0, y: contentSize.height + spacing))
items.append(contentsOf: blockItems)
if CGFloat(0.0).isLess(than: blockLayout.contentSize.height) {
contentSize.height += blockLayout.contentSize.height + spacing
previousBlock = block
}
}
let closingSpacing = spacingBetweenBlocks(upper: previousBlock, lower: nil)
contentSize.height += closingSpacing
if webPage.webpageId.id != 0 {
let feedbackItem = InstantPageFeedbackItem(frame: CGRect(x: 0.0, y: contentSize.height, width: boundingWidth, height: 40.0), webPage: webPage)
contentSize.height += feedbackItem.frame.height
items.append(feedbackItem)
}
return InstantPageLayout(origin: CGPoint(), contentSize: contentSize, items: items)
}
@@ -0,0 +1,65 @@
import Foundation
import UIKit
import TelegramCore
func spacingBetweenBlocks(upper: InstantPageBlock?, lower: InstantPageBlock?) -> CGFloat {
if let upper = upper, let lower = lower {
switch (upper, lower) {
case (_, .cover), (_, .channelBanner), (.details, .details), (.relatedArticles, _), (_, .anchor):
return 0.0
case (.divider, _), (_, .divider):
return 25.0
case (_, .blockQuote), (.blockQuote, _), (_, .pullQuote), (.pullQuote, _):
return 27.0
case (.kicker, .title), (.cover, .title):
return 16.0
case (_, .title):
return 20.0
case (.title, .authorDate), (.subtitle, .authorDate):
return 18.0
case (_, .authorDate):
return 20.0
case (.title, .paragraph), (.authorDate, .paragraph):
return 34.0
case (.header, .paragraph), (.subheader, .paragraph):
return 25.0
case (.list, .paragraph):
return 31.0
case (.preformatted, .paragraph):
return 19.0
case (.paragraph, .paragraph):
return 25.0
case (_, .paragraph):
return 20.0
case (.title, .list), (.authorDate, .list):
return 34.0
case (.header, .list), (.subheader, .list):
return 31.0
case (.preformatted, .list):
return 19.0
case (_, .list):
return 25.0
case (.paragraph, .preformatted):
return 19.0
case (_, .preformatted):
return 20.0
case (_, .header), (_, .subheader):
return 32.0
default:
return 20.0
}
} else if let lower = lower {
switch lower {
case .cover, .channelBanner, .details, .anchor:
return 0.0
default:
return 25.0
}
} else {
if let upper = upper, case .relatedArticles = upper {
return 0.0
} else {
return 25.0
}
}
}
@@ -0,0 +1,6 @@
import Foundation
import UIKit
final class InstantPageLinkSelectionView: UIView {
}
@@ -0,0 +1,22 @@
import Foundation
import TelegramCore
public struct InstantPageMedia: Equatable {
public let index: Int
public let media: EngineMedia
public let url: InstantPageUrlItem?
public let caption: RichText?
public let credit: RichText?
public init(index: Int, media: EngineMedia, url: InstantPageUrlItem?, caption: RichText?, credit: RichText?) {
self.index = index
self.media = media
self.url = url
self.caption = caption
self.credit = credit
}
public static func ==(lhs: InstantPageMedia, rhs: InstantPageMedia) -> Bool {
return lhs.index == rhs.index && lhs.media == rhs.media && lhs.url == rhs.url && lhs.caption == rhs.caption && lhs.credit == rhs.credit
}
}
@@ -0,0 +1,251 @@
import Foundation
import UIKit
import SwiftSignalKit
import TelegramCore
import TelegramUIPreferences
import AccountContext
import MusicAlbumArtResources
struct InstantPageMediaPlaylistItemId: SharedMediaPlaylistItemId {
let index: Int
func isEqual(to: SharedMediaPlaylistItemId) -> Bool {
if let to = to as? InstantPageMediaPlaylistItemId {
if self.index != to.index {
return false
}
return true
}
return false
}
}
private func extractFileMedia(_ item: InstantPageMedia) -> TelegramMediaFile? {
if case let .file(file) = item.media {
return file
} else {
return nil
}
}
final class InstantPageMediaPlaylistItem: SharedMediaPlaylistItem {
let webPage: TelegramMediaWebpage
let id: SharedMediaPlaylistItemId
let item: InstantPageMedia
init(webPage: TelegramMediaWebpage, item: InstantPageMedia) {
self.webPage = webPage
self.id = InstantPageMediaPlaylistItemId(index: item.index)
self.item = item
}
var stableId: AnyHashable {
return self.item.index
}
var playbackData: SharedMediaPlaybackData? {
if let file = extractFileMedia(self.item) {
for attribute in file.attributes {
switch attribute {
case let .Audio(isVoice, _, _, _, _):
if isVoice {
return SharedMediaPlaybackData(type: .voice, source: .telegramFile(reference: .webPage(webPage: WebpageReference(self.webPage), media: file), isCopyProtected: false, isViewOnce: false))
} else {
return SharedMediaPlaybackData(type: .music, source: .telegramFile(reference: .webPage(webPage: WebpageReference(self.webPage), media: file), isCopyProtected: false, isViewOnce: false))
}
case let .Video(_, _, flags, _, _, _):
if flags.contains(.instantRoundVideo) {
return SharedMediaPlaybackData(type: .instantVideo, source: .telegramFile(reference: .webPage(webPage: WebpageReference(self.webPage), media: file), isCopyProtected: false, isViewOnce: false))
} else {
return nil
}
default:
break
}
}
if file.mimeType.hasPrefix("audio/") {
return SharedMediaPlaybackData(type: .music, source: .telegramFile(reference: .webPage(webPage: WebpageReference(self.webPage), media: file), isCopyProtected: false, isViewOnce: false))
}
if let fileName = file.fileName {
let ext = (fileName as NSString).pathExtension.lowercased()
if ext == "wav" || ext == "opus" {
return SharedMediaPlaybackData(type: .music, source: .telegramFile(reference: .webPage(webPage: WebpageReference(self.webPage), media: file), isCopyProtected: false, isViewOnce: false))
}
}
}
return nil
}
var displayData: SharedMediaPlaybackDisplayData? {
if let file = extractFileMedia(self.item) {
for attribute in file.attributes {
switch attribute {
case let .Audio(isVoice, _, title, performer, _):
if isVoice {
return SharedMediaPlaybackDisplayData.voice(author: nil, peer: nil)
} else {
var updatedTitle = title
let updatedPerformer = performer
if (title ?? "").isEmpty && (performer ?? "").isEmpty {
updatedTitle = file.fileName ?? ""
}
let albumArt: SharedMediaPlaybackAlbumArt?
if file.fileName?.lowercased().hasSuffix(".ogg") == true {
albumArt = nil
} else {
albumArt = SharedMediaPlaybackAlbumArt(thumbnailResource: ExternalMusicAlbumArtResource(file: .standalone(media: file), title: updatedTitle ?? "", performer: updatedPerformer ?? "", isThumbnail: true), fullSizeResource: ExternalMusicAlbumArtResource(file: .standalone(media: file), title: updatedTitle ?? "", performer: updatedPerformer ?? "", isThumbnail: false))
}
return SharedMediaPlaybackDisplayData.music(title: updatedTitle, performer: updatedPerformer, albumArt: albumArt, long: false, caption: nil)
}
case let .Video(_, _, flags, _, _, _):
if flags.contains(.instantRoundVideo) {
return SharedMediaPlaybackDisplayData.instantVideo(author: nil, peer: nil, timestamp: 0)
} else {
return nil
}
default:
break
}
}
return SharedMediaPlaybackDisplayData.music(title: file.fileName ?? "", performer: "", albumArt: nil, long: false, caption: nil)
}
return nil
}
}
struct InstantPageMediaPlaylistId: SharedMediaPlaylistId {
let webpageId: EngineMedia.Id
func isEqual(to: SharedMediaPlaylistId) -> Bool {
if let to = to as? InstantPageMediaPlaylistId {
return self.webpageId == to.webpageId
}
return false
}
}
struct InstantPagePlaylistLocation: Equatable, SharedMediaPlaylistLocation {
let webpageId: EngineMedia.Id
func isEqual(to: SharedMediaPlaylistLocation) -> Bool {
guard let to = to as? InstantPagePlaylistLocation else {
return false
}
if self.webpageId == to.webpageId {
return false
}
return true
}
}
public final class InstantPageMediaPlaylist: SharedMediaPlaylist {
private let webPage: TelegramMediaWebpage
private let items: [InstantPageMedia]
private let initialItemIndex: Int
public var location: SharedMediaPlaylistLocation {
return InstantPagePlaylistLocation(webpageId: self.webPage.webpageId)
}
public var currentItemDisappeared: (() -> Void)?
private var currentItem: InstantPageMedia?
private var playedToEnd: Bool = false
private var order: MusicPlaybackSettingsOrder = .regular
public private(set) var looping: MusicPlaybackSettingsLooping = .none
public let id: SharedMediaPlaylistId
private let stateValue = Promise<SharedMediaPlaylistState>()
public var state: Signal<SharedMediaPlaylistState, NoError> {
return self.stateValue.get()
}
public init(webPage: TelegramMediaWebpage, items: [InstantPageMedia], initialItemIndex: Int) {
assert(Queue.mainQueue().isCurrent())
self.id = InstantPageMediaPlaylistId(webpageId: webPage.webpageId)
self.webPage = webPage
self.items = items
self.initialItemIndex = initialItemIndex
self.control(.next)
}
public func control(_ action: SharedMediaPlaylistControlAction) {
assert(Queue.mainQueue().isCurrent())
switch action {
case .next, .previous:
if let currentItem = self.currentItem, let currentIndex = self.items.firstIndex(where: { $0.index == currentItem.index }) {
let selectedIndex: Int?
switch self.order {
case .regular:
if case .next = action {
selectedIndex = max(0, currentIndex - 1)
} else {
if currentIndex == self.items.count - 1 {
selectedIndex = nil
} else {
selectedIndex = currentIndex + 1
}
}
case .reversed:
if case .next = action {
if currentIndex == self.items.count - 1 {
selectedIndex = nil
} else {
selectedIndex = currentIndex + 1
}
} else {
selectedIndex = max(0, currentIndex - 1)
}
case .random:
selectedIndex = Int(arc4random_uniform(UInt32(self.items.count)))
}
if let selectedIndex = selectedIndex {
self.currentItem = self.items[selectedIndex]
self.playedToEnd = false
} else {
self.currentItem = nil
self.playedToEnd = true
}
self.updateState()
} else {
if self.initialItemIndex < self.items.count {
self.currentItem = self.items[self.initialItemIndex]
} else {
self.currentItem = nil
}
self.playedToEnd = false
self.updateState()
}
}
}
public func setOrder(_ order: MusicPlaybackSettingsOrder) {
if self.order != order {
self.order = order
self.updateState()
}
}
public func setLooping(_ looping: MusicPlaybackSettingsLooping) {
if self.looping != looping {
self.looping = looping
self.updateState()
}
}
private func updateState() {
self.stateValue.set(.single(SharedMediaPlaylistState(loading: false, playedToEnd: self.playedToEnd, item: self.currentItem.flatMap({ InstantPageMediaPlaylistItem(webPage: self.webPage, item: $0) }), nextItem: nil, previousItem: nil, order: self.order, looping: self.looping)))
}
public func onItemPlaybackStarted(_ item: SharedMediaPlaylistItem) {
}
}
@@ -0,0 +1,247 @@
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import TelegramPresentationData
import AppBundle
private let backArrowImage = NavigationBarTheme.generateBackArrowImage(color: .white)
private let moreImage = generateTintedImage(image: UIImage(bundleImageName: "Instant View/MoreIcon"), color: .white)
private let actionImage = generateTintedImage(image: UIImage(bundleImageName: "Instant View/ActionIcon"), color: .white)
final private class InstantPageProgressNode: ASDisplayNode {
private let foregroundNode: ASDisplayNode
private var progress: CGFloat = 0.0
override init() {
self.foregroundNode = ASDisplayNode()
self.foregroundNode.backgroundColor = .white
super.init()
self.addSubnode(self.foregroundNode)
}
func setProgress(_ progress: CGFloat, animated: Bool = false) {
if self.progress == progress && animated {
return
}
let size = self.bounds.size
self.progress = progress
let transition: ContainedViewLayoutTransition
if animated && progress > 0.0 {
transition = .animated(duration: 0.7, curve: .spring)
} else {
transition = .immediate
}
let alpaTransition: ContainedViewLayoutTransition
if animated {
alpaTransition = .animated(duration: 0.3, curve: .easeInOut)
} else {
alpaTransition = .immediate
}
transition.updateFrame(node: self.foregroundNode, frame: CGRect(x: 0.0, y: 0.0, width: size.width * progress, height: size.height))
let alpha: CGFloat = progress < 0.001 || progress > 0.999 ? 0.0 : 1.0
alpaTransition.updateAlpha(node: self.foregroundNode, alpha: alpha)
}
}
final class InstantPageNavigationBar: ASDisplayNode {
private var strings: PresentationStrings
private let pageProgressNode: ASDisplayNode
private let backButton: HighlightableButtonNode
private let moreButton: HighlightableButtonNode
private let actionButton: HighlightableButtonNode
private let scrollToTopButton: HighlightableButtonNode
private let arrowNode: ASImageNode
private let titleNode: ASTextNode
private let progressNode: InstantPageProgressNode
private let intrinsicMoreSize: CGSize
private let intrinsicSmallMoreSize: CGSize
private let intrinsicActionSize: CGSize
private let intrinsicSmallActionSize: CGSize
private var dimmed: Bool = false
private var buttonsAlphaFactor: CGFloat = 1.0
private var currentTitle: String?
var back: (() -> Void)?
var share: (() -> Void)?
var settings: (() -> Void)?
var scrollToTop: (() -> Void)?
init(strings: PresentationStrings) {
self.strings = strings
self.pageProgressNode = ASDisplayNode()
self.pageProgressNode.isLayerBacked = true
self.pageProgressNode.backgroundColor = UIColor(rgb: 0x242425)
self.backButton = HighlightableButtonNode()
self.moreButton = HighlightableButtonNode()
self.actionButton = HighlightableButtonNode()
self.scrollToTopButton = HighlightableButtonNode()
self.scrollToTopButton.isAccessibilityElement = false
self.actionButton.setImage(actionImage, for: [])
self.intrinsicActionSize = CGSize(width: 44.0, height: 44.0)
self.intrinsicSmallActionSize = CGSize(width: 20.0, height: 20.0)
self.actionButton.frame = CGRect(origin: CGPoint(), size: self.intrinsicActionSize)
self.moreButton.setImage(moreImage, for: [])
self.intrinsicMoreSize = CGSize(width: 44.0, height: 44.0)
self.intrinsicSmallMoreSize = CGSize(width: 20.0, height: 20.0)
self.moreButton.frame = CGRect(origin: CGPoint(), size: self.intrinsicMoreSize)
self.arrowNode = ASImageNode()
self.arrowNode.image = backArrowImage
self.arrowNode.isLayerBacked = true
self.arrowNode.displayWithoutProcessing = true
self.arrowNode.displaysAsynchronously = false
self.titleNode = ASTextNode()
self.titleNode.maximumNumberOfLines = 1
self.titleNode.truncationMode = .byTruncatingTail
self.progressNode = InstantPageProgressNode()
super.init()
self.backgroundColor = .black
self.backButton.addSubnode(self.arrowNode)
self.addSubnode(self.pageProgressNode)
self.addSubnode(self.backButton)
self.addSubnode(self.scrollToTopButton)
self.addSubnode(self.moreButton)
self.addSubnode(self.actionButton)
self.addSubnode(self.titleNode)
self.addSubnode(self.progressNode)
self.actionButton.accessibilityLabel = strings.KeyCommand_Share
self.backButton.accessibilityLabel = strings.Common_Back
self.backButton.addTarget(self, action: #selector(self.backPressed), forControlEvents: .touchUpInside)
self.actionButton.addTarget(self, action: #selector(self.actionPressed), forControlEvents: .touchUpInside)
self.moreButton.addTarget(self, action: #selector(self.morePressed), forControlEvents: .touchUpInside)
self.scrollToTopButton.addTarget(self, action: #selector(self.scrollToTopPressed), forControlEvents: .touchUpInside)
}
@objc func backPressed() {
self.back?()
}
@objc func actionPressed() {
self.share?()
}
@objc func morePressed() {
self.settings?()
}
@objc func scrollToTopPressed() {
self.scrollToTop?()
}
func updateDimmed(_ dimmed: Bool, transition: ContainedViewLayoutTransition) {
if dimmed != self.dimmed {
self.dimmed = dimmed
transition.updateAlpha(node: self.arrowNode, alpha: dimmed ? 0.5 : 1.0)
var buttonsAlpha = self.buttonsAlphaFactor
if dimmed {
buttonsAlpha *= 0.5
}
transition.updateAlpha(node: self.actionButton, alpha: buttonsAlpha)
}
}
func setLoadProgress(_ progress: CGFloat) {
self.progressNode.setProgress(progress, animated: true)
}
func updateLayout(size: CGSize, minHeight: CGFloat, maxHeight: CGFloat, topInset: CGFloat, leftInset: CGFloat, rightInset: CGFloat, title: String?, pageProgress: CGFloat, transition: ContainedViewLayoutTransition) {
let progressHeight = size.height
transition.updateFrame(node: self.pageProgressNode, frame: CGRect(origin: CGPoint(x: 0.0, y: size.height - progressHeight), size: CGSize(width: floorToScreenPixels(size.width * pageProgress), height: progressHeight)))
let transitionFactor = (size.height - minHeight) / (maxHeight - minHeight)
transition.updateFrame(node: self.backButton, frame: CGRect(origin: CGPoint(x: 1.0, y: 0.0), size: CGSize(width: 100.0, height: size.height)))
if let image = arrowNode.image {
let arrowImageSize = image.size
let arrowHeight: CGFloat
if size.height.isLess(than: maxHeight) {
arrowHeight = floor(9.0 * transitionFactor + 12.0)
} else {
arrowHeight = 21.0
}
let scaledArrowSize = CGSize(width: arrowImageSize.width * arrowHeight / arrowImageSize.height, height: arrowHeight)
let arrowOffset = floor(8.0 * transitionFactor + 4.0)
transition.updateFrame(node: self.arrowNode, frame: CGRect(origin: CGPoint(x: leftInset + 8.0, y: size.height - arrowHeight - arrowOffset), size: scaledArrowSize))
}
let offsetScaleFactor: CGFloat
let buttonScaleFactor: CGFloat
if size.height.isLess(than: maxHeight) {
offsetScaleFactor = transitionFactor
buttonScaleFactor = ((transitionFactor * self.intrinsicMoreSize.height) + ((1.0 - transitionFactor) * self.intrinsicSmallMoreSize.height)) / self.intrinsicMoreSize.height
} else {
offsetScaleFactor = 1.0
buttonScaleFactor = 1.0
}
var alphaFactor = min(1.0, offsetScaleFactor * offsetScaleFactor)
self.buttonsAlphaFactor = alphaFactor
if self.dimmed {
alphaFactor *= 0.5
}
if title != self.currentTitle {
self.currentTitle = title
if let title = title {
self.titleNode.transform = CATransform3DIdentity
self.titleNode.attributedText = NSAttributedString(string: title, font: Font.semibold(17.0), textColor: .white, paragraphAlignment: .center)
let titleSize = self.titleNode.measure(CGSize(width: size.width - leftInset - rightInset - 170.0, height: size.height))
self.titleNode.frame = CGRect(origin: CGPoint(x: (size.width - titleSize.width) / 2.0, y: size.height - 30.0), size: titleSize)
}
}
let maxMoreOffset = self.intrinsicMoreSize.height / 2.0 + floor((44.0 - self.intrinsicMoreSize.height) / 2.0)
let minMoreOffset = self.intrinsicSmallMoreSize.height / 2.0 + floor((20.0 - self.intrinsicSmallMoreSize.height) / 2.0)
let moreOffset = (transitionFactor * maxMoreOffset) + ((1.0 - transitionFactor) * minMoreOffset)
transition.updateTransformScale(node: self.titleNode, scale: 0.75 + transitionFactor * 0.25)
transition.updatePosition(node: self.titleNode, position: CGPoint(x: size.width / 2.0, y: size.height - moreOffset))
transition.updateTransformScale(node: self.moreButton, scale: buttonScaleFactor)
transition.updatePosition(node: self.moreButton, position: CGPoint(x: size.width - rightInset - buttonScaleFactor * self.intrinsicMoreSize.width / 2.0, y: size.height - moreOffset))
transition.updateAlpha(node: self.moreButton, alpha: alphaFactor)
transition.updateTransformScale(node: self.actionButton, scale: buttonScaleFactor)
transition.updatePosition(node: self.actionButton, position: CGPoint(x: size.width - rightInset - buttonScaleFactor * self.intrinsicMoreSize.width - buttonScaleFactor * self.intrinsicActionSize.width / 2.0, y: size.height - moreOffset))
transition.updateAlpha(node: self.actionButton, alpha: alphaFactor)
transition.updateFrame(node: self.scrollToTopButton, frame: CGRect(origin: CGPoint(x: leftInset + 64.0, y: 0.0), size: CGSize(width: size.width - leftInset - rightInset - 64.0, height: size.height)))
let loadProgressHeight: CGFloat = 2.0
transition.updateFrame(node: self.progressNode, frame: CGRect(origin: CGPoint(x: 0.0, y: size.height - loadProgressHeight - UIScreenPixel), size: CGSize(width: size.width, height: loadProgressHeight)))
}
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
if self.dimmed {
return nil
} else {
return super.hitTest(point, with: event)
}
}
}
@@ -0,0 +1,15 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import TelegramPresentationData
public protocol InstantPageNode: ASDisplayNode {
func updateIsVisible(_ isVisible: Bool)
func transitionNode(media: InstantPageMedia) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))?
func updateHiddenMedia(media: InstantPageMedia?)
func update(strings: PresentationStrings, theme: InstantPageTheme)
func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition)
}
@@ -0,0 +1,63 @@
import Foundation
import UIKit
import TelegramCore
import AsyncDisplayKit
import TelegramPresentationData
import TelegramUIPreferences
import AccountContext
import ContextUI
public final class InstantPagePeerReferenceItem: InstantPageItem {
public var frame: CGRect
public let wantsNode: Bool = true
public let separatesTiles: Bool = false
public let medias: [InstantPageMedia] = []
let initialPeer: EnginePeer
let safeInset: CGFloat
let transparent: Bool
let rtl: Bool
init(frame: CGRect, initialPeer: EnginePeer, safeInset: CGFloat, transparent: Bool, rtl: Bool) {
self.frame = frame
self.initialPeer = initialPeer
self.safeInset = safeInset
self.transparent = transparent
self.rtl = rtl
}
public func node(context: AccountContext, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, theme: InstantPageTheme, sourceLocation: InstantPageSourceLocation, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, openPeer: @escaping (EnginePeer) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void, updateWebEmbedHeight: @escaping (CGFloat) -> Void, updateDetailsExpanded: @escaping (Bool) -> Void, currentExpandedDetails: [Int : Bool]?, getPreloadedResource: @escaping (String) -> Data?) -> InstantPageNode? {
return InstantPagePeerReferenceNode(context: context, strings: strings, nameDisplayOrder: nameDisplayOrder, theme: theme, initialPeer: self.initialPeer, safeInset: self.safeInset, transparent: self.transparent, rtl: self.rtl, openPeer: openPeer)
}
public func matchesAnchor(_ anchor: String) -> Bool {
return false
}
public func matchesNode(_ node: InstantPageNode) -> Bool {
if let node = node as? InstantPagePeerReferenceNode {
return self.initialPeer.id == node.peer?.id && self.safeInset == node.safeInset
} else {
return false
}
}
public func distanceThresholdGroup() -> Int? {
return 5
}
public func distanceThresholdWithGroupCount(_ count: Int) -> CGFloat {
if count > 3 {
return 1000.0
} else {
return CGFloat.greatestFiniteMagnitude
}
}
public func linkSelectionRects(at point: CGPoint) -> [CGRect] {
return []
}
public func drawInTile(context: CGContext) {
}
}
@@ -0,0 +1,321 @@
import Foundation
import UIKit
import TelegramCore
import SwiftSignalKit
import AsyncDisplayKit
import Display
import TelegramPresentationData
import TelegramUIPreferences
import ActivityIndicator
import AccountContext
import AppBundle
private enum JoinState: Equatable {
case none
case notJoined
case inProgress
case joined(justNow: Bool)
static func ==(lhs: JoinState, rhs: JoinState) -> Bool {
switch lhs {
case .none:
if case .none = rhs {
return true
} else {
return false
}
case .notJoined:
if case .notJoined = rhs {
return true
} else {
return false
}
case .inProgress:
if case .inProgress = rhs {
return true
} else {
return false
}
case let .joined(justNow):
if case .joined(justNow) = rhs {
return true
} else {
return false
}
}
}
}
public final class InstantPagePeerReferenceNode: ASDisplayNode, InstantPageNode {
private let context: AccountContext
let safeInset: CGFloat
private let transparent: Bool
private let rtl: Bool
private var strings: PresentationStrings
private var nameDisplayOrder: PresentationPersonNameOrder
private var theme: InstantPageTheme
private let openPeer: (EnginePeer) -> Void
private let highlightedBackgroundNode: ASDisplayNode
private let buttonNode: HighlightableButtonNode
private let nameNode: ASTextNode
private let joinNode: HighlightableButtonNode
private let activityIndicator: ActivityIndicator
private let checkNode: ASImageNode
var peer: EnginePeer?
private var peerDisposable: Disposable?
private let joinDisposable = MetaDisposable()
private var joinState: JoinState = .none
init(context: AccountContext, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, theme: InstantPageTheme, initialPeer: EnginePeer, safeInset: CGFloat, transparent: Bool, rtl: Bool, openPeer: @escaping (EnginePeer) -> Void) {
self.context = context
self.strings = strings
self.nameDisplayOrder = nameDisplayOrder
self.theme = theme
self.peer = initialPeer
self.safeInset = safeInset
self.transparent = transparent
self.rtl = rtl
self.openPeer = openPeer
self.highlightedBackgroundNode = ASDisplayNode()
self.highlightedBackgroundNode.isLayerBacked = true
self.highlightedBackgroundNode.alpha = 0.0
self.buttonNode = HighlightableButtonNode()
self.nameNode = ASTextNode()
self.nameNode.isUserInteractionEnabled = false
self.nameNode.maximumNumberOfLines = 1
self.joinNode = HighlightableButtonNode()
self.joinNode.hitTestSlop = UIEdgeInsets(top: -17.0, left: -17.0, bottom: -17.0, right: -17.0)
self.activityIndicator = ActivityIndicator(type: .custom(theme.panelAccentColor, 22.0, 2.0, false))
self.checkNode = ASImageNode()
self.checkNode.isLayerBacked = true
self.checkNode.displayWithoutProcessing = true
self.checkNode.displaysAsynchronously = false
self.checkNode.isHidden = true
super.init()
if self.transparent {
self.backgroundColor = UIColor(white: 0.0, alpha: 0.6)
self.highlightedBackgroundNode.backgroundColor = UIColor(white: 1.0, alpha: 0.1)
} else {
self.backgroundColor = theme.panelBackgroundColor
self.highlightedBackgroundNode.backgroundColor = theme.panelHighlightedBackgroundColor
}
self.addSubnode(self.highlightedBackgroundNode)
self.addSubnode(self.buttonNode)
self.addSubnode(self.joinNode)
self.addSubnode(self.checkNode)
self.addSubnode(self.nameNode)
self.buttonNode.highligthedChanged = { [weak self] highlighted in
if let strongSelf = self {
if highlighted {
strongSelf.highlightedBackgroundNode.layer.removeAnimation(forKey: "opacity")
strongSelf.highlightedBackgroundNode.alpha = 1.0
} else {
strongSelf.highlightedBackgroundNode.alpha = 0.0
strongSelf.highlightedBackgroundNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2)
}
}
}
self.joinNode.highligthedChanged = { [weak self] highlighted in
if let strongSelf = self {
if highlighted {
strongSelf.joinNode.layer.removeAnimation(forKey: "opacity")
strongSelf.joinNode.alpha = 0.4
} else {
strongSelf.joinNode.alpha = 1.0
strongSelf.joinNode.layer.animateAlpha(from: 0.4, to: 1.0, duration: 0.2)
}
}
}
self.buttonNode.addTarget(self, action: #selector(self.buttonPressed), forControlEvents: .touchUpInside)
self.joinNode.addTarget(self, action: #selector(self.joinPressed), forControlEvents: .touchUpInside)
let account = self.context.account
let engine = context.engine
let signal: Signal<EnginePeer, NoError> = actualizedPeer(accountPeerId: account.peerId, postbox: account.postbox, network: account.network, peer: initialPeer._asPeer())
|> mapToSignal({ peer -> Signal<EnginePeer, NoError> in
if let peer = peer as? TelegramChannel, let username = peer.addressName, peer.accessHash == nil {
return .single(.channel(peer)) |> then(engine.peers.resolvePeerByName(name: username, referrer: nil)
|> mapToSignal({ result -> Signal<EnginePeer, NoError> in
guard case let .result(updatedPeer) = result else {
return .complete()
}
if let updatedPeer = updatedPeer {
return .single(updatedPeer)
} else {
return .single(.channel(peer))
}
}))
} else {
return .single(EnginePeer(peer))
}
})
self.peerDisposable = (signal |> deliverOnMainQueue).start(next: { [weak self] peer in
if let strongSelf = self {
strongSelf.peer = peer
if case let .channel(peer) = peer {
var joinState = strongSelf.joinState
if case .member = peer.participationStatus {
switch joinState {
case .none:
joinState = .joined(justNow: false)
case .inProgress, .notJoined:
joinState = .joined(justNow: true)
case .joined:
break
}
} else {
joinState = .notJoined
}
strongSelf.updateJoinState(joinState)
}
strongSelf.applyThemeAndStrings(themeUpdated: false)
strongSelf.setNeedsLayout()
}
})
self.applyThemeAndStrings(themeUpdated: true)
}
deinit {
self.peerDisposable?.dispose()
self.joinDisposable.dispose()
}
public func update(strings: PresentationStrings, theme: InstantPageTheme) {
if self.strings !== strings || self.theme !== theme {
let themeUpdated = self.theme !== theme
self.strings = strings
self.theme = theme
self.applyThemeAndStrings(themeUpdated: themeUpdated)
}
}
public func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition) {
}
private func applyThemeAndStrings(themeUpdated: Bool) {
if let peer = self.peer {
let textColor = self.transparent ? UIColor.white : self.theme.panelPrimaryColor
self.nameNode.attributedText = NSAttributedString(string: peer.displayTitle(strings: self.strings, displayOrder: self.nameDisplayOrder), font: Font.medium(17.0), textColor: textColor)
}
let accentColor = self.transparent ? UIColor.white : self.theme.panelAccentColor
self.joinNode.setAttributedTitle(NSAttributedString(string: self.strings.Channel_JoinChannel, font: Font.medium(17.0), textColor: accentColor), for: [])
if themeUpdated {
let secondaryColor = self.transparent ? UIColor.white : self.theme.panelSecondaryColor
self.checkNode.image = generateTintedImage(image: UIImage(bundleImageName: "Instant View/PanelCheck"), color: secondaryColor)
self.activityIndicator.type = .custom(self.theme.panelAccentColor, 22.0, 2.0, false)
if !self.transparent {
self.backgroundColor = self.theme.panelBackgroundColor
self.highlightedBackgroundNode.backgroundColor = self.theme.panelHighlightedBackgroundColor
}
}
self.setNeedsLayout()
}
private func updateJoinState(_ joinState: JoinState) {
if self.joinState != joinState {
self.joinState = joinState
switch joinState {
case .none:
self.joinNode.isHidden = true
self.checkNode.isHidden = true
if self.activityIndicator.supernode != nil {
self.activityIndicator.removeFromSupernode()
}
case .notJoined:
self.joinNode.isHidden = false
self.checkNode.isHidden = true
if self.activityIndicator.supernode != nil {
self.activityIndicator.removeFromSupernode()
}
case .inProgress:
self.joinNode.isHidden = true
self.checkNode.isHidden = true
if self.activityIndicator.supernode == nil {
self.addSubnode(self.activityIndicator)
}
case let .joined(justNow):
self.joinNode.isHidden = true
self.checkNode.isHidden = !justNow
if self.activityIndicator.supernode != nil {
self.activityIndicator.removeFromSupernode()
}
}
}
}
public override func layout() {
super.layout()
let size = self.bounds.size
let inset: CGFloat = 17.0 + safeInset
self.highlightedBackgroundNode.frame = CGRect(origin: CGPoint(), size: size)
self.buttonNode.frame = CGRect(origin: CGPoint(), size: size)
let joinSize = self.joinNode.measure(size)
let nameSize = self.nameNode.measure(CGSize(width: size.width - inset * 2.0 - joinSize.width, height: size.height))
let checkSize = self.checkNode.measure(size)
let indicatorSize = self.activityIndicator.measure(size)
if self.rtl {
self.nameNode.frame = CGRect(origin: CGPoint(x: size.width - inset - nameSize.width, y: floor((size.height - nameSize.height) / 2.0)), size: nameSize)
self.joinNode.frame = CGRect(origin: CGPoint(x: inset, y: floor((size.height - joinSize.height) / 2.0)), size: joinSize)
self.checkNode.frame = CGRect(origin: CGPoint(x: inset, y: floor((size.height - checkSize.height) / 2.0)), size: checkSize)
self.activityIndicator.frame = CGRect(origin: CGPoint(x: inset, y: floor((size.height - indicatorSize.height) / 2.0)), size: indicatorSize)
} else {
self.nameNode.frame = CGRect(origin: CGPoint(x: inset, y: floor((size.height - nameSize.height) / 2.0)), size: nameSize)
self.joinNode.frame = CGRect(origin: CGPoint(x: size.width - inset - joinSize.width, y: floor((size.height - joinSize.height) / 2.0)), size: joinSize)
self.checkNode.frame = CGRect(origin: CGPoint(x: size.width - inset - checkSize.width, y: floor((size.height - checkSize.height) / 2.0)), size: checkSize)
self.activityIndicator.frame = CGRect(origin: CGPoint(x: size.width - inset - indicatorSize.width, y: floor((size.height - indicatorSize.height) / 2.0)), size: indicatorSize)
}
}
public func transitionNode(media: InstantPageMedia) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? {
return nil
}
public func updateHiddenMedia(media: InstantPageMedia?) {
}
public func updateIsVisible(_ isVisible: Bool) {
}
@objc func buttonPressed() {
if let peer = self.peer {
self.openPeer(peer)
}
}
@objc func joinPressed() {
if let peer = self.peer, case .notJoined = self.joinState {
self.updateJoinState(.inProgress)
self.joinDisposable.set((self.context.engine.peers.joinChannel(peerId: peer.id, hash: nil) |> deliverOnMainQueue).start(error: { [weak self] _ in
if let strongSelf = self {
if case .inProgress = strongSelf.joinState {
strongSelf.updateJoinState(.notJoined)
}
}
}))
}
}
}
@@ -0,0 +1,66 @@
import Foundation
import UIKit
import TelegramCore
import AsyncDisplayKit
import TelegramPresentationData
import TelegramUIPreferences
import AccountContext
import ContextUI
public final class InstantPagePlayableVideoItem: InstantPageItem {
public var frame: CGRect
let webPage: TelegramMediaWebpage
let media: InstantPageMedia
public var medias: [InstantPageMedia] {
return [self.media]
}
public let interactive: Bool
public let wantsNode: Bool = true
public let separatesTiles: Bool = false
init(frame: CGRect, webPage: TelegramMediaWebpage, media: InstantPageMedia, interactive: Bool) {
self.frame = frame
self.webPage = webPage
self.media = media
self.interactive = interactive
}
public func node(context: AccountContext, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, theme: InstantPageTheme, sourceLocation: InstantPageSourceLocation, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, openPeer: @escaping (EnginePeer) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void, updateWebEmbedHeight: @escaping (CGFloat) -> Void, updateDetailsExpanded: @escaping (Bool) -> Void, currentExpandedDetails: [Int : Bool]?, getPreloadedResource: @escaping (String) -> Data?) -> InstantPageNode? {
return InstantPagePlayableVideoNode(context: context, userLocation: sourceLocation.userLocation, webPage: self.webPage, theme: theme, media: self.media, interactive: self.interactive, openMedia: openMedia)
}
public func matchesAnchor(_ anchor: String) -> Bool {
return false
}
public func matchesNode(_ node: InstantPageNode) -> Bool {
if let node = node as? InstantPagePlayableVideoNode {
return node.media == self.media
} else {
return false
}
}
public func distanceThresholdGroup() -> Int? {
return 2
}
public func distanceThresholdWithGroupCount(_ count: Int) -> CGFloat {
if count > 3 {
return 200.0
} else {
return CGFloat.greatestFiniteMagnitude
}
}
public func drawInTile(context: CGContext) {
}
public func linkSelectionRects(at point: CGPoint) -> [CGRect] {
return []
}
}
@@ -0,0 +1,184 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import TelegramCore
import SwiftSignalKit
import TelegramPresentationData
import AccountContext
import RadialStatusNode
import GalleryUI
import TelegramUniversalVideoContent
private struct FetchControls {
let fetch: (Bool) -> Void
let cancel: () -> Void
}
final class InstantPagePlayableVideoNode: ASDisplayNode, InstantPageNode, GalleryItemTransitionNode {
private let context: AccountContext
let media: InstantPageMedia
let userLocation: MediaResourceUserLocation
private let interactive: Bool
private let openMedia: (InstantPageMedia) -> Void
private var fetchControls: FetchControls?
private let videoNode: UniversalVideoNode
private let statusNode: RadialStatusNode
private var currentSize: CGSize?
private var fetchStatus: EngineMediaResource.FetchStatus?
private var fetchedDisposable = MetaDisposable()
private var statusDisposable = MetaDisposable()
private var localIsVisible = false
public var decoration: UniversalVideoDecoration? {
return nil
}
init(context: AccountContext, userLocation: MediaResourceUserLocation, webPage: TelegramMediaWebpage, theme: InstantPageTheme, media: InstantPageMedia, interactive: Bool, openMedia: @escaping (InstantPageMedia) -> Void) {
self.context = context
self.userLocation = userLocation
self.media = media
self.interactive = interactive
self.openMedia = openMedia
var imageReference: ImageMediaReference?
if case let .file(file) = media.media, let presentation = smallestImageRepresentation(file.previewRepresentations) {
let image = TelegramMediaImage(imageId: EngineMedia.Id(namespace: 0, id: 0), representations: [presentation], immediateThumbnailData: file.immediateThumbnailData, reference: nil, partialReference: nil, flags: [])
imageReference = ImageMediaReference.webPage(webPage: WebpageReference(webPage), media: image)
}
var streamVideo = false
var fileValue: TelegramMediaFile?
if case let .file(file) = media.media {
streamVideo = isMediaStreamable(media: file)
fileValue = file
}
self.videoNode = UniversalVideoNode(context: context, postbox: context.account.postbox, audioSession: context.sharedContext.mediaManager.audioSession, manager: context.sharedContext.mediaManager.universalVideoManager, decoration: GalleryVideoDecoration(), content: NativeVideoContent(id: .instantPage(webPage.webpageId, media.media.id!), userLocation: userLocation, fileReference: .webPage(webPage: WebpageReference(webPage), media: fileValue!), imageReference: imageReference, streamVideo: streamVideo ? .conservative : .none, loopVideo: true, enableSound: false, fetchAutomatically: true, placeholderColor: theme.pageBackgroundColor, storeAfterDownload: nil), priority: .embedded, autoplay: true)
self.videoNode.isUserInteractionEnabled = false
self.statusNode = RadialStatusNode(backgroundNodeColor: UIColor(white: 0.0, alpha: 0.6))
super.init()
self.addSubnode(self.videoNode)
if case let .file(file) = media.media {
self.fetchedDisposable.set(fetchedMediaResource(mediaBox: context.account.postbox.mediaBox, userLocation: userLocation, userContentType: .video, reference: AnyMediaReference.webPage(webPage: WebpageReference(webPage), media: file).resourceReference(file.resource)).start())
self.statusDisposable.set((context.account.postbox.mediaBox.resourceStatus(file.resource) |> deliverOnMainQueue).start(next: { [weak self] status in
displayLinkDispatcher.dispatch {
if let strongSelf = self {
strongSelf.fetchStatus = EngineMediaResource.FetchStatus(status)
strongSelf.updateFetchStatus()
}
}
}))
}
}
deinit {
self.fetchedDisposable.dispose()
}
func isAvailableForGalleryTransition() -> Bool {
return true
}
func isAvailableForInstantPageTransition() -> Bool {
return true
}
override func didLoad() {
super.didLoad()
if self.interactive {
self.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:))))
}
}
func updateIsVisible(_ isVisible: Bool) {
if self.localIsVisible != isVisible {
self.localIsVisible = isVisible
self.videoNode.canAttachContent = isVisible
}
}
func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition) {
}
func update(strings: PresentationStrings, theme: InstantPageTheme) {
}
private func updateFetchStatus() {
var state: RadialStatusNodeState = .none
if let fetchStatus = self.fetchStatus {
switch fetchStatus {
case let .Fetching(_, progress):
let adjustedProgress = max(progress, 0.027)
state = .progress(color: .white, lineWidth: nil, value: CGFloat(adjustedProgress), cancelEnabled: true, animateRotation: true)
case .Remote:
state = .download(.white)
default:
break
}
}
self.statusNode.transitionToState(state, completion: { [weak statusNode] in
if state == .none {
statusNode?.removeFromSupernode()
}
})
}
override func layout() {
super.layout()
let size = self.bounds.size
if self.currentSize != size {
self.currentSize = size
self.videoNode.frame = CGRect(origin: CGPoint(), size: size)
self.videoNode.updateLayout(size: size, transition: .immediate)
let radialStatusSize: CGFloat = 50.0
self.statusNode.frame = CGRect(x: floorToScreenPixels((size.width - radialStatusSize) / 2.0), y: floorToScreenPixels((size.height - radialStatusSize) / 2.0), width: radialStatusSize, height: radialStatusSize)
}
}
func transitionNode(media: InstantPageMedia) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? {
if media == self.media {
return (self, self.bounds, { [weak self] in
return (self?.view.snapshotContentTree(unhide: true), nil)
})
} else {
return nil
}
}
func updateHiddenMedia(media: InstantPageMedia?) {
self.videoNode.isHidden = self.media == media
}
@objc func tapGesture(_ recognizer: UITapGestureRecognizer) {
if case .ended = recognizer.state, let fetchStatus = self.fetchStatus {
switch fetchStatus {
case .Local:
self.openMedia(self.media)
case .Remote, .Paused:
self.fetchControls?.fetch(true)
case .Fetching:
self.fetchControls?.cancel()
}
}
}
func scrubberTransition() -> GalleryItemScrubberTransition? {
return nil
}
}
@@ -0,0 +1,77 @@
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import TelegramCore
import SwiftSignalKit
import AccountContext
import TelegramUIPreferences
public final class InstantPageReferenceController: ViewController {
private var controllerNode: InstantPageReferenceControllerNode {
return self.displayNode as! InstantPageReferenceControllerNode
}
private var animatedIn = false
private let context: AccountContext
private let sourceLocation: InstantPageSourceLocation
private let theme: InstantPageTheme
private let webPage: (webPage: TelegramMediaWebpage, instantPage: InstantPage?)
private let anchorText: NSAttributedString
private let openUrl: (InstantPageUrlItem) -> Void
private let openUrlIn: (InstantPageUrlItem) -> Void
private let present: (ViewController, Any?) -> Void
public init(context: AccountContext, sourceLocation: InstantPageSourceLocation, theme: InstantPageTheme, webPage: TelegramMediaWebpage, instantPage: InstantPage?, anchorText: NSAttributedString, openUrl: @escaping (InstantPageUrlItem) -> Void, openUrlIn: @escaping (InstantPageUrlItem) -> Void, present: @escaping (ViewController, Any?) -> Void) {
self.context = context
self.sourceLocation = sourceLocation
self.theme = theme
self.webPage = (webPage, instantPage)
self.anchorText = anchorText
self.openUrl = openUrl
self.openUrlIn = openUrlIn
self.present = present
super.init(navigationBarPresentationData: nil)
self.statusBar.statusBarStyle = .Ignore
}
required init(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override public func loadDisplayNode() {
self.displayNode = InstantPageReferenceControllerNode(context: self.context, sourceLocation: self.sourceLocation, theme: self.theme, webPage: self.webPage.webPage, instantPage: self.webPage.instantPage, anchorText: self.anchorText, openUrl: self.openUrl, openUrlIn: self.openUrlIn, present: self.present)
self.controllerNode.dismiss = { [weak self] in
self?.presentingViewController?.dismiss(animated: false, completion: nil)
}
self.controllerNode.close = { [weak self] in
self?.dismiss()
}
}
override public func loadView() {
super.loadView()
}
override public func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
if !self.animatedIn {
self.animatedIn = true
self.controllerNode.animateIn()
}
}
override public func dismiss(completion: (() -> Void)? = nil) {
self.controllerNode.animateOut(completion: completion)
}
override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) {
super.containerLayoutUpdated(layout, transition: transition)
self.controllerNode.containerLayoutUpdated(layout, navigationBarHeight: self.navigationLayout(layout: layout).navigationFrame.maxY, transition: transition)
}
}
@@ -0,0 +1,438 @@
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import TelegramCore
import SafariServices
import TelegramPresentationData
import AccountContext
import ShareController
import OpenInExternalAppUI
import TelegramUIPreferences
class InstantPageReferenceControllerNode: ViewControllerTracingNode, ASScrollViewDelegate {
private let context: AccountContext
private let sourceLocation: InstantPageSourceLocation
private let theme: InstantPageTheme
private var presentationData: PresentationData
private let webPage: (webPage: TelegramMediaWebpage, instantPage: InstantPage?)
private let anchorText: NSAttributedString
private let dimNode: ASDisplayNode
private let wrappingScrollNode: ASScrollNode
private let contentContainerNode: ASDisplayNode
private let contentBackgroundNode: ASImageNode
private var contentNode: InstantPageContentNode?
private let titleNode: ASTextNode
private let separatorNode: ASDisplayNode
private let closeButton: HighlightableButtonNode
private var linkHighlightingNode: LinkHighlightingNode?
private var textSelectionNode: LinkHighlightingNode?
private var containerLayout: (ContainerViewLayout, CGFloat)?
private var openUrl: (InstantPageUrlItem) -> Void
private var openUrlIn: (InstantPageUrlItem) -> Void
private var present: (ViewController, Any?) -> Void
var dismiss: (() -> Void)?
var close: (() -> Void)?
init(context: AccountContext, sourceLocation: InstantPageSourceLocation, theme: InstantPageTheme, webPage: TelegramMediaWebpage, instantPage: InstantPage?, anchorText: NSAttributedString, openUrl: @escaping (InstantPageUrlItem) -> Void, openUrlIn: @escaping (InstantPageUrlItem) -> Void, present: @escaping (ViewController, Any?) -> Void) {
self.context = context
self.sourceLocation = sourceLocation
self.presentationData = context.sharedContext.currentPresentationData.with { $0 }
self.theme = theme
self.webPage = (webPage, instantPage)
self.anchorText = anchorText
self.openUrl = openUrl
self.openUrlIn = openUrlIn
self.present = present
self.wrappingScrollNode = ASScrollNode()
self.wrappingScrollNode.view.alwaysBounceVertical = true
self.wrappingScrollNode.view.delaysContentTouches = false
self.wrappingScrollNode.view.canCancelContentTouches = true
self.dimNode = ASDisplayNode()
self.dimNode.backgroundColor = UIColor(white: 0.0, alpha: 0.5)
let roundedBackground = generateStretchableFilledCircleImage(radius: 16.0, color: self.theme.overlayPanelColor)
self.contentContainerNode = ASDisplayNode()
self.contentContainerNode.isOpaque = false
self.contentContainerNode.clipsToBounds = true
self.contentBackgroundNode = ASImageNode()
self.contentBackgroundNode.displaysAsynchronously = false
self.contentBackgroundNode.displayWithoutProcessing = true
self.contentBackgroundNode.image = roundedBackground
self.titleNode = ASTextNode()
self.titleNode.attributedText = NSAttributedString(string: self.presentationData.strings.InstantPage_Reference, font: Font.bold(17.0), textColor: self.theme.panelSecondaryColor)
self.separatorNode = ASDisplayNode()
self.separatorNode.backgroundColor = self.theme.controlColor
self.closeButton = HighlightableButtonNode()
super.init()
self.backgroundColor = nil
self.isOpaque = false
self.dimNode.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.dimTapGesture(_:))))
self.addSubnode(self.dimNode)
self.wrappingScrollNode.view.delegate = self.wrappedScrollViewDelegate
self.addSubnode(self.wrappingScrollNode)
self.wrappingScrollNode.addSubnode(self.contentBackgroundNode)
self.wrappingScrollNode.addSubnode(self.contentContainerNode)
self.contentContainerNode.addSubnode(self.titleNode)
self.contentContainerNode.addSubnode(self.separatorNode)
}
override func didLoad() {
super.didLoad()
if #available(iOSApplicationExtension 11.0, iOS 11.0, *) {
self.wrappingScrollNode.view.contentInsetAdjustmentBehavior = .never
}
let recognizer = TapLongTapOrDoubleTapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:)))
recognizer.delaysTouchesBegan = false
recognizer.tapActionAtPoint = { [weak self] point in
if let strongSelf = self, let contentNode = strongSelf.contentNode {
return strongSelf.tapActionAtPoint(point.offsetBy(dx: -contentNode.frame.minX, dy: -contentNode.frame.minY))
}
return .waitForSingleTap
}
recognizer.highlight = { [weak self] point in
if let strongSelf = self, let contentNode = strongSelf.contentNode {
strongSelf.updateTouchesAtPoint(point?.offsetBy(dx: -contentNode.frame.minX, dy: -contentNode.frame.minY))
}
}
self.contentContainerNode.view.addGestureRecognizer(recognizer)
}
@objc func closeButtonPressed() {
self.close?()
}
@objc func dimTapGesture(_ recognizer: UITapGestureRecognizer) {
if case .ended = recognizer.state {
self.closeButtonPressed()
}
}
func animateIn() {
self.dimNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.4)
let offset = self.bounds.size.height - self.contentBackgroundNode.frame.minY
let dimPosition = self.dimNode.layer.position
self.dimNode.layer.animatePosition(from: CGPoint(x: dimPosition.x, y: dimPosition.y - offset), to: dimPosition, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring)
self.layer.animateBoundsOriginYAdditive(from: -offset, to: 0.0, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring)
}
func animateOut(completion: (() -> Void)? = nil) {
var dimCompleted = false
var offsetCompleted = false
let internalCompletion: () -> Void = { [weak self] in
if let strongSelf = self, dimCompleted && offsetCompleted {
strongSelf.dismiss?()
}
completion?()
}
self.dimNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false, completion: { _ in
dimCompleted = true
internalCompletion()
})
let offset = self.bounds.size.height - self.contentBackgroundNode.frame.minY
let dimPosition = self.dimNode.layer.position
self.dimNode.layer.animatePosition(from: dimPosition, to: CGPoint(x: dimPosition.x, y: dimPosition.y - offset), duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false)
self.layer.animateBoundsOriginYAdditive(from: 0.0, to: -offset, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, completion: { _ in
offsetCompleted = true
internalCompletion()
})
}
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
if self.bounds.contains(point) {
if !self.contentBackgroundNode.bounds.contains(self.convert(point, to: self.contentBackgroundNode)) {
return self.dimNode.view
}
}
return super.hitTest(point, with: event)
}
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
let contentOffset = scrollView.contentOffset
let additionalTopHeight = max(0.0, -contentOffset.y)
if additionalTopHeight >= 30.0 {
self.closeButtonPressed()
}
}
func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition) {
self.containerLayout = (layout, navigationBarHeight)
var insets = layout.insets(options: [.statusBar, .input])
let cleanInsets = layout.insets(options: [.statusBar])
insets.top = max(10.0, insets.top)
let bottomInset: CGFloat = 10.0 + cleanInsets.bottom
let titleAreaHeight: CGFloat = 54.0
var contentHeight = titleAreaHeight + bottomInset
let width = horizontalContainerFillingSizeForLayout(layout: layout, sideInset: layout.safeInsets.left)
if self.contentNode == nil || self.contentNode?.frame.width != width {
self.contentNode?.removeFromSupernode()
var media: [EngineMedia.Id: EngineMedia] = [:]
if let instantPage = self.webPage.instantPage {
media = instantPage.media.mapValues(EngineMedia.init)
}
let sideInset: CGFloat = 16.0
let (_, items, contentSize) = layoutTextItemWithString(self.anchorText, boundingWidth: width - sideInset * 2.0, offset: CGPoint(x: sideInset, y: sideInset), media: media, webpage: self.webPage.webPage)
let contentNode = InstantPageContentNode(context: self.context, strings: self.presentationData.strings, nameDisplayOrder: self.presentationData.nameDisplayOrder, sourceLocation: self.sourceLocation, theme: self.theme, items: items, contentSize: CGSize(width: width, height: contentSize.height), inOverlayPanel: true, openMedia: { _ in }, longPressMedia: { _ in }, activatePinchPreview: nil, pinchPreviewFinished: nil, openPeer: { _ in }, openUrl: { _ in }, getPreloadedResource: { url in return nil})
transition.updateFrame(node: contentNode, frame: CGRect(origin: CGPoint(x: 0.0, y: titleAreaHeight), size: CGSize(width: width, height: contentSize.height)))
self.contentContainerNode.insertSubnode(contentNode, at: 0)
self.contentNode = contentNode
contentHeight += contentSize.height + sideInset
contentNode.updateVisibleItems(visibleBounds: contentNode.bounds, animated: false)
}
let sideInset = floor((layout.size.width - width) / 2.0)
let contentContainerFrame = CGRect(origin: CGPoint(x: sideInset, y: layout.size.height - contentHeight), size: CGSize(width: width, height: contentHeight))
let contentFrame = contentContainerFrame
var backgroundFrame = CGRect(origin: CGPoint(x: contentFrame.minX, y: contentFrame.minY), size: CGSize(width: contentFrame.size.width, height: contentFrame.size.height + 2000.0))
if backgroundFrame.minY < contentFrame.minY {
backgroundFrame.origin.y = contentFrame.minY
}
transition.updateFrame(node: self.contentBackgroundNode, frame: backgroundFrame)
transition.updateFrame(node: self.wrappingScrollNode, frame: CGRect(origin: CGPoint(), size: layout.size))
transition.updateFrame(node: self.dimNode, frame: CGRect(origin: CGPoint(), size: layout.size))
let titleSize = self.titleNode.measure(CGSize(width: width, height: titleAreaHeight))
let titleFrame = CGRect(origin: CGPoint(x: 17.0, y: 17.0), size: titleSize)
transition.updateFrame(node: self.titleNode, frame: titleFrame)
//transition.updateFrame(node: self.closeButtonNode, frame: CGRect(origin: CGPoint(x: sideInset, y: layout.size.height - bottomInset - buttonHeight), size: CGSize(width: width, height: buttonHeight)))
transition.updateFrame(node: self.contentContainerNode, frame: contentContainerFrame)
transition.updateFrame(node: self.separatorNode, frame: CGRect(origin: CGPoint(x: 0.0, y: titleAreaHeight), size: CGSize(width: contentContainerFrame.size.width, height: UIScreenPixel)))
}
func tapActionAtPoint(_ point: CGPoint) -> TapLongTapOrDoubleTapGestureRecognizerAction {
if let contentNode = self.contentNode {
for item in contentNode.currentLayout.items {
let frame = contentNode.effectiveFrameForItem(item)
if frame.contains(point) {
if item is InstantPagePeerReferenceItem {
return .fail
} else if item is InstantPageAudioItem {
return .fail
} else if item is InstantPageArticleItem {
return .fail
} else if item is InstantPageFeedbackItem {
return .fail
}
if !(item is InstantPageImageItem || item is InstantPagePlayableVideoItem) {
break
}
}
}
}
return .waitForSingleTap
}
private func updateTouchesAtPoint(_ location: CGPoint?) {
var rects: [CGRect]?
if let contentNode = self.contentNode, let location = location {
for item in contentNode.currentLayout.items {
let itemFrame = contentNode.effectiveFrameForItem(item)
if itemFrame.contains(location) {
var contentOffset = CGPoint()
if let item = item as? InstantPageScrollableItem {
contentOffset = contentNode.scrollableContentOffset(item: item)
}
var itemRects = item.linkSelectionRects(at: location.offsetBy(dx: -itemFrame.minX + contentOffset.x, dy: -itemFrame.minY))
for i in 0 ..< itemRects.count {
itemRects[i] = itemRects[i].offsetBy(dx: itemFrame.minX - contentOffset.x + contentNode.frame.minX, dy: itemFrame.minY + contentNode.frame.minY).insetBy(dx: -2.0, dy: -2.0)
}
if !itemRects.isEmpty {
rects = itemRects
break
}
}
}
}
if let rects = rects {
let linkHighlightingNode: LinkHighlightingNode
if let current = self.linkHighlightingNode {
linkHighlightingNode = current
} else {
linkHighlightingNode = LinkHighlightingNode(color: self.theme.linkHighlightColor)
linkHighlightingNode.isUserInteractionEnabled = false
self.linkHighlightingNode = linkHighlightingNode
self.contentContainerNode.addSubnode(linkHighlightingNode)
}
linkHighlightingNode.frame = CGRect(origin: CGPoint(), size: self.contentContainerNode.bounds.size)
linkHighlightingNode.updateRects(rects)
} else if let linkHighlightingNode = self.linkHighlightingNode {
self.linkHighlightingNode = nil
linkHighlightingNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.18, removeOnCompletion: false, completion: { [weak linkHighlightingNode] _ in
linkHighlightingNode?.removeFromSupernode()
})
}
}
private func textItemAtLocation(_ location: CGPoint) -> (InstantPageTextItem, CGPoint)? {
if let contentNode = self.contentNode {
for item in contentNode.currentLayout.items {
let itemFrame = contentNode.effectiveFrameForItem(item)
if itemFrame.contains(location) {
if let item = item as? InstantPageTextItem, item.selectable {
return (item, CGPoint(x: itemFrame.minX - item.frame.minX + contentNode.frame.minX, y: itemFrame.minY - item.frame.minY + contentNode.frame.minY))
} else if let item = item as? InstantPageScrollableItem {
let contentOffset = contentNode.scrollableContentOffset(item: item)
if let (textItem, parentOffset) = item.textItemAtLocation(location.offsetBy(dx: -itemFrame.minX + contentOffset.x, dy: -itemFrame.minY)) {
return (textItem, itemFrame.origin.offsetBy(dx: parentOffset.x - contentOffset.x + contentNode.frame.minX, dy: parentOffset.y + contentNode.frame.minY))
}
}
}
}
}
return nil
}
private func urlForTapLocation(_ location: CGPoint) -> InstantPageUrlItem? {
if let contentNode = self.contentNode, let (item, parentOffset) = self.textItemAtLocation(location) {
return item.urlAttribute(at: location.offsetBy(dx: -item.frame.minX - parentOffset.x + contentNode.frame.minX, dy: -item.frame.minY - parentOffset.y + contentNode.frame.minY))
}
return nil
}
@objc private func tapGesture(_ recognizer: TapLongTapOrDoubleTapGestureRecognizer) {
guard let contentNode = self.contentNode else {
return
}
switch recognizer.state {
case .ended:
if let (gesture, location) = recognizer.lastRecognizedGestureAndLocation {
let location = location.offsetBy(dx: -contentNode.frame.minX, dy: -contentNode.frame.minY)
switch gesture {
case .tap:
if let url = self.urlForTapLocation(location) {
self.close?()
self.openUrl(url)
}
case .longTap:
if let url = self.urlForTapLocation(location) {
let canOpenIn = availableOpenInOptions(context: self.context, item: .url(url: url.url)).count > 1
let openText = canOpenIn ? self.presentationData.strings.Conversation_FileOpenIn : self.presentationData.strings.Conversation_LinkDialogOpen
let actionSheet = ActionSheetController(instantPageTheme: self.theme)
actionSheet.setItemGroups([ActionSheetItemGroup(items: [
ActionSheetTextItem(title: url.url),
ActionSheetButtonItem(title: openText, color: .accent, action: { [weak self, weak actionSheet] in
actionSheet?.dismissAnimated()
if let strongSelf = self {
strongSelf.close?()
if canOpenIn {
strongSelf.openUrlIn(url)
} else {
strongSelf.openUrl(url)
}
}
}),
ActionSheetButtonItem(title: self.presentationData.strings.ShareMenu_CopyShareLink, color: .accent, action: { [weak actionSheet] in
actionSheet?.dismissAnimated()
UIPasteboard.general.string = url.url
}),
ActionSheetButtonItem(title: self.presentationData.strings.Conversation_AddToReadingList, color: .accent, action: { [weak actionSheet] in
actionSheet?.dismissAnimated()
if let link = URL(string: url.url) {
let _ = try? SSReadingList.default()?.addItem(with: link, title: nil, previewText: nil)
}
})
]), ActionSheetItemGroup(items: [
ActionSheetButtonItem(title: self.presentationData.strings.Common_Cancel, color: .accent, font: .bold, action: { [weak actionSheet] in
actionSheet?.dismissAnimated()
})
])])
self.present(actionSheet, nil)
} else if let (item, parentOffset) = self.textItemAtLocation(location) {
let textFrame = item.frame
var itemRects = item.lineRects()
for i in 0 ..< itemRects.count {
itemRects[i] = itemRects[i].offsetBy(dx: parentOffset.x + textFrame.minX, dy: parentOffset.y + textFrame.minY).insetBy(dx: -2.0, dy: -2.0)
}
self.updateTextSelectionRects(itemRects, text: item.plainText())
}
default:
break
}
}
default:
break
}
}
private func updateTextSelectionRects(_ rects: [CGRect], text: String?) {
if let text = text, !rects.isEmpty {
let textSelectionNode: LinkHighlightingNode
if let current = self.textSelectionNode {
textSelectionNode = current
} else {
textSelectionNode = LinkHighlightingNode(color: UIColor.lightGray.withAlphaComponent(0.4))
textSelectionNode.isUserInteractionEnabled = false
self.textSelectionNode = textSelectionNode
self.contentContainerNode.addSubnode(textSelectionNode)
}
textSelectionNode.frame = CGRect(origin: CGPoint(), size: self.contentContainerNode.bounds.size)
textSelectionNode.updateRects(rects)
var coveringRect = rects[0]
for i in 1 ..< rects.count {
coveringRect = coveringRect.union(rects[i])
}
let controller = makeContextMenuController(actions: [ContextMenuAction(content: .text(title: self.presentationData.strings.Conversation_ContextMenuCopy, accessibilityLabel: self.presentationData.strings.Conversation_ContextMenuCopy), action: {
UIPasteboard.general.string = text
}), ContextMenuAction(content: .text(title: self.presentationData.strings.Conversation_ContextMenuShare, accessibilityLabel: self.presentationData.strings.Conversation_ContextMenuShare), action: { [weak self] in
if let strongSelf = self, case let .Loaded(content) = strongSelf.webPage.webPage.content {
strongSelf.present(ShareController(context: strongSelf.context, subject: .quote(text: text, url: content.url)), nil)
}
})])
controller.dismissed = { [weak self] in
self?.updateTextSelectionRects([], text: nil)
}
self.present(controller, ContextMenuControllerPresentationArguments(sourceNodeAndRect: { [weak self] in
if let strongSelf = self {
return (strongSelf.contentContainerNode, coveringRect.insetBy(dx: -3.0, dy: -3.0), strongSelf, strongSelf.bounds)
} else {
return nil
}
}))
textSelectionNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.18)
} else if let textSelectionNode = self.textSelectionNode {
self.textSelectionNode = nil
textSelectionNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.18, removeOnCompletion: false, completion: { [weak textSelectionNode] _ in
textSelectionNode?.removeFromSupernode()
})
}
}
}
@@ -0,0 +1,108 @@
import Foundation
import UIKit
import AsyncDisplayKit
import TelegramCore
import Display
import TelegramPresentationData
public protocol InstantPageScrollableItem: AnyObject, InstantPageItem {
var contentSize: CGSize { get }
var horizontalInset: CGFloat { get }
var isRTL: Bool { get }
func textItemAtLocation(_ location: CGPoint) -> (InstantPageTextItem, CGPoint)?
}
private final class InstantPageScrollableContentNodeParameters: NSObject {
let item: InstantPageScrollableItem
init(item: InstantPageScrollableItem) {
self.item = item
super.init()
}
}
public final class InstantPageScrollableContentNode: ASDisplayNode {
public let item: InstantPageScrollableItem
init(item: InstantPageScrollableItem, additionalNodes: [InstantPageNode]) {
self.item = item
super.init()
self.isOpaque = false
self.isUserInteractionEnabled = false
for case let node as ASDisplayNode in additionalNodes {
self.addSubnode(node)
}
}
public override func drawParameters(forAsyncLayer layer: _ASDisplayLayer) -> NSObjectProtocol? {
return InstantPageScrollableContentNodeParameters(item: self.item)
}
@objc override public class func draw(_ bounds: CGRect, withParameters parameters: Any?, isCancelled: () -> Bool, isRasterizing: Bool) {
let context = UIGraphicsGetCurrentContext()!
if let parameters = parameters as? InstantPageScrollableContentNodeParameters {
parameters.item.drawInTile(context: context)
}
}
}
public final class InstantPageScrollableNode: ASScrollNode, InstantPageNode {
public let item: InstantPageScrollableItem
let contentNode: InstantPageScrollableContentNode
public var contentOffset: CGPoint {
return self.view.contentOffset
}
init(item: InstantPageScrollableItem, additionalNodes: [InstantPageNode]) {
self.item = item
self.contentNode = InstantPageScrollableContentNode(item: item, additionalNodes: additionalNodes)
super.init()
self.isOpaque = false
self.contentNode.frame = CGRect(origin: CGPoint(x: item.horizontalInset, y: 0.0), size: item.contentSize)
self.view.contentSize = CGSize(width: item.contentSize.width + item.horizontalInset * 2.0, height: item.contentSize.height)
if item.isRTL {
self.view.contentOffset = CGPoint(x: self.view.contentSize.width - item.frame.width, y: 0.0)
}
self.view.alwaysBounceVertical = false
self.view.showsHorizontalScrollIndicator = false
self.view.showsVerticalScrollIndicator = false
if #available(iOSApplicationExtension 11.0, iOS 11.0, *) {
self.view.contentInsetAdjustmentBehavior = .never
}
self.addSubnode(self.contentNode)
self.view.interactiveTransitionGestureRecognizerTest = { [weak self] point -> Bool in
if let strongSelf = self {
if strongSelf.view.contentOffset.x < 1.0 {
return false
} else {
return point.x - strongSelf.view.contentOffset.x > 30.0
}
} else {
return false
}
}
}
public func updateIsVisible(_ isVisible: Bool) {
}
public func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition) {
}
public func transitionNode(media: InstantPageMedia) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? {
return nil
}
public func updateHiddenMedia(media: InstantPageMedia?) {
}
public func update(strings: PresentationStrings, theme: InstantPageTheme) {
}
}
@@ -0,0 +1,78 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import AppBundle
import LegacyComponents
func generateKnobImage() -> UIImage? {
return generateImage(CGSize(width: 40.0, height: 40.0), rotatedContext: { size, context in
context.clear(CGRect(origin: CGPoint(), size: size))
context.setShadow(offset: CGSize(width: 0.0, height: -3.0), blur: 12.0, color: UIColor(white: 0.0, alpha: 0.25).cgColor)
context.setFillColor(UIColor.white.cgColor)
context.fillEllipse(in: CGRect(origin: CGPoint(x: 6.0, y: 6.0), size: CGSize(width: 28.0, height: 28.0)))
})
}
final class InstantPageSettingsBacklightItemNode: InstantPageSettingsItemNode {
private let sliderView: TGPhotoEditorSliderView
private let leftIconNode: ASImageNode
private let rightIconNode: ASImageNode
init(theme: InstantPageSettingsItemTheme) {
self.sliderView = TGPhotoEditorSliderView()
self.sliderView.enablePanHandling = true
self.sliderView.trackCornerRadius = 1.0
self.sliderView.lineSize = 2.0
self.sliderView.minimumValue = 0.0
self.sliderView.startValue = 0.0
self.sliderView.maximumValue = 100.0
self.sliderView.disablesInteractiveTransitionGestureRecognizer = true
self.leftIconNode = ASImageNode()
self.leftIconNode.displaysAsynchronously = false
self.leftIconNode.displayWithoutProcessing = true
self.rightIconNode = ASImageNode()
self.rightIconNode.displaysAsynchronously = false
self.rightIconNode.displayWithoutProcessing = true
super.init(theme: theme, selectable: false)
self.updateTheme(theme)
self.sliderView.value = UIScreen.main.brightness * 100.0
self.sliderView.addTarget(self, action: #selector(self.sliderChanged), for: .valueChanged)
self.view.addSubview(self.sliderView)
self.addSubnode(self.leftIconNode)
self.addSubnode(self.rightIconNode)
}
override func updateTheme(_ theme: InstantPageSettingsItemTheme) {
super.updateTheme(theme)
self.sliderView.backgroundColor = theme.itemBackgroundColor
self.sliderView.backColor = theme.secondaryColor
self.sliderView.trackColor = theme.accentColor
self.sliderView.knobImage = generateKnobImage()
self.leftIconNode.image = generateTintedImage(image: UIImage(bundleImageName: "Instant View/SettingsBrightnessMinIcon"), color: theme.primaryColor)
self.rightIconNode.image = generateTintedImage(image: UIImage(bundleImageName: "Instant View/SettingsBrightnessMaxIcon"), color: theme.primaryColor)
}
override func updateInternalLayout(width: CGFloat, insets: UIEdgeInsets, previousItem: (InstantPageSettingsItemNodeStatus, InstantPageSettingsItemNode?), nextItem: (InstantPageSettingsItemNodeStatus, InstantPageSettingsItemNode?)) -> (height: CGFloat, separatorInset: CGFloat?) {
self.sliderView.frame = CGRect(origin: CGPoint(x: 38.0, y: 8.0), size: CGSize(width: width - 38.0 * 2.0, height: 44.0))
if let image = self.leftIconNode.image {
self.leftIconNode.frame = CGRect(origin: CGPoint(x: 16.0, y: 24.0), size: CGSize(width: image.size.width, height: image.size.height))
}
if let image = self.rightIconNode.image {
self.rightIconNode.frame = CGRect(origin: CGPoint(x: width - 13.0 - image.size.width, y: 21.0), size: CGSize(width: image.size.width, height: image.size.height))
}
return (62.0 + insets.top + insets.bottom, nil)
}
@objc func sliderChanged() {
UIScreen.main.brightness = self.sliderView.value / 100.0
}
}
@@ -0,0 +1,44 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
final class InstantPageSettingsButtonItemNode: InstantPageSettingsItemNode {
private let title: String
private let tapped: () -> Void
private let labelNode: ASTextNode
init(theme: InstantPageSettingsItemTheme, title: String, tapped: @escaping () -> Void) {
self.title = title
self.tapped = tapped
self.labelNode = ASTextNode()
super.init(theme: theme, selectable: true)
self.addSubnode(self.labelNode)
self.updateTheme(theme)
}
override func updateTheme(_ theme: InstantPageSettingsItemTheme) {
super.updateTheme(theme)
self.labelNode.attributedText = NSAttributedString(string: self.title, font: Font.regular(17.0), textColor: theme.accentColor)
}
override func updateInternalLayout(width: CGFloat, insets: UIEdgeInsets, previousItem: (InstantPageSettingsItemNodeStatus, InstantPageSettingsItemNode?), nextItem: (InstantPageSettingsItemNodeStatus, InstantPageSettingsItemNode?)) -> (height: CGFloat, separatorInset: CGFloat?) {
var separatorInset: CGFloat?
if case .sameSection = previousItem.0, let previousNode = previousItem.1, previousNode is InstantPageSettingsFontFamilyNode {
separatorInset = 46.0
}
let labelSize = self.labelNode.measure(CGSize(width: width - 15.0 - 5.0, height: 44.0))
self.labelNode.frame = CGRect(origin: CGPoint(x: 15.0, y: insets.top + floor((44.0 - labelSize.height) / 2.0)), size: labelSize)
return (44.0 + insets.top + insets.bottom, separatorInset)
}
override func pressed() {
self.tapped()
}
}
@@ -0,0 +1,90 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
private func generateCheckIcon(_ color: UIColor) -> UIImage? {
return generateImage(CGSize(width: 14.0, height: 11.0), rotatedContext: { size, context in
context.clear(CGRect(origin: CGPoint(), size: size))
context.setStrokeColor(color.cgColor)
context.setLineWidth(2.0)
context.move(to: CGPoint(x: 12.0, y: 1.0))
context.addLine(to: CGPoint(x: 4.16482734, y: 9.0))
context.addLine(to: CGPoint(x: 1.0, y: 5.81145833))
context.strokePath()
})
}
final class InstantPageSettingsFontFamilyNode: InstantPageSettingsItemNode {
private let title: String
private let family: String?
private let tapped: () -> Void
private let labelNode: ASTextNode
private let checkNode: ASImageNode
var _checked: Bool
var checked: Bool {
get {
return self._checked
} set(value) {
self._checked = value
self.checkNode.isHidden = !value
}
}
init(theme: InstantPageSettingsItemTheme, title: String, family: String?, checked: Bool, tapped: @escaping () -> Void) {
self.title = title
self.family = family
self._checked = checked
self.tapped = tapped
self.labelNode = ASTextNode()
self.checkNode = ASImageNode()
self.checkNode.displayWithoutProcessing = true
self.checkNode.displaysAsynchronously = false
self.checkNode.isHidden = !checked
super.init(theme: theme, selectable: true)
self.addSubnode(self.labelNode)
self.addSubnode(self.checkNode)
self.updateTheme(theme)
}
override func updateTheme(_ theme: InstantPageSettingsItemTheme) {
super.updateTheme(theme)
let font: UIFont
if let family = self.family {
if let familyFont = UIFont(name: family, size: 17.0) {
font = familyFont
} else {
font = UIFont.systemFont(ofSize: 17.0)
}
} else {
font = UIFont.systemFont(ofSize: 17.0)
}
self.labelNode.attributedText = NSAttributedString(string: self.title, font: font, textColor: theme.primaryColor)
self.checkNode.image = generateCheckIcon(theme.accentColor)
}
override func updateInternalLayout(width: CGFloat, insets: UIEdgeInsets, previousItem: (InstantPageSettingsItemNodeStatus, InstantPageSettingsItemNode?), nextItem: (InstantPageSettingsItemNodeStatus, InstantPageSettingsItemNode?)) -> (height: CGFloat, separatorInset: CGFloat?) {
var separatorInset: CGFloat?
if case .sameSection = previousItem.0, let previousNode = previousItem.1, previousNode is InstantPageSettingsFontFamilyNode {
separatorInset = 46.0
}
let labelSize = self.labelNode.measure(CGSize(width: width - 46.0 - 5.0, height: 44.0))
self.labelNode.frame = CGRect(origin: CGPoint(x: 46.0, y: insets.top + floor((44.0 - labelSize.height) / 2.0)), size: labelSize)
if let image = self.checkNode.image {
self.checkNode.frame = CGRect(origin: CGPoint(x: 16.0, y: insets.top + floor((44.0 - image.size.height) / 2.0)), size: image.size)
}
return (44.0 + insets.top + insets.bottom, separatorInset)
}
override func pressed() {
self.tapped()
}
}
@@ -0,0 +1,75 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import AppBundle
import LegacyComponents
final class InstantPageSettingsFontSizeItemNode: InstantPageSettingsItemNode {
private let updated: (Int) -> Void
private let sliderView: TGPhotoEditorSliderView
private let leftIconNode: ASImageNode
private let rightIconNode: ASImageNode
init(theme: InstantPageSettingsItemTheme, fontSizeVariant: Int, updated: @escaping (Int) -> Void) {
self.updated = updated
self.sliderView = TGPhotoEditorSliderView()
self.sliderView.enablePanHandling = true
self.sliderView.trackCornerRadius = 1.0
self.sliderView.lineSize = 2.0
self.sliderView.dotSize = 5.0
self.sliderView.minimumValue = 0.0
self.sliderView.maximumValue = 4.0
self.sliderView.startValue = 0.0
self.sliderView.value = CGFloat(fontSizeVariant)
self.sliderView.positionsCount = 5
self.sliderView.disablesInteractiveTransitionGestureRecognizer = true
self.leftIconNode = ASImageNode()
self.leftIconNode.displaysAsynchronously = false
self.leftIconNode.displayWithoutProcessing = true
self.rightIconNode = ASImageNode()
self.rightIconNode.displaysAsynchronously = false
self.rightIconNode.displayWithoutProcessing = true
super.init(theme: theme, selectable: false)
self.updateTheme(theme)
self.sliderView.addTarget(self, action: #selector(self.sliderChanged), for: .valueChanged)
self.view.addSubview(self.sliderView)
self.addSubnode(self.leftIconNode)
self.addSubnode(self.rightIconNode)
}
override func updateTheme(_ theme: InstantPageSettingsItemTheme) {
super.updateTheme(theme)
self.sliderView.backgroundColor = theme.itemBackgroundColor
self.sliderView.backColor = theme.secondaryColor
self.sliderView.trackColor = theme.accentColor
self.sliderView.knobImage = generateKnobImage()
self.leftIconNode.image = generateTintedImage(image: UIImage(bundleImageName: "Instant View/SettingsFontMinIcon"), color: theme.primaryColor)
self.rightIconNode.image = generateTintedImage(image: UIImage(bundleImageName: "Instant View/SettingsFontMaxIcon"), color: theme.primaryColor)
}
override func updateInternalLayout(width: CGFloat, insets: UIEdgeInsets, previousItem: (InstantPageSettingsItemNodeStatus, InstantPageSettingsItemNode?), nextItem: (InstantPageSettingsItemNodeStatus, InstantPageSettingsItemNode?)) -> (height: CGFloat, separatorInset: CGFloat?) {
self.sliderView.frame = CGRect(origin: CGPoint(x: 38.0, y: 8.0), size: CGSize(width: width - 38.0 * 2.0, height: 44.0))
if let image = self.leftIconNode.image {
self.leftIconNode.frame = CGRect(origin: CGPoint(x: 18.0, y: 25.0), size: CGSize(width: image.size.width, height: image.size.height))
}
if let image = self.rightIconNode.image {
self.rightIconNode.frame = CGRect(origin: CGPoint(x: width - 14.0 - image.size.width, y: 21.0), size: CGSize(width: image.size.width, height: image.size.height))
}
return (62.0 + insets.top + insets.bottom, nil)
}
@objc func sliderChanged() {
self.updated(max(0, min(4, Int(self.sliderView.value))))
}
}
@@ -0,0 +1,126 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
enum InstantPageSettingsItemNodeStatus {
case none
case sameSection
case otherSection
}
class InstantPageSettingsItemNode: ASDisplayNode {
private let topSeparatorNode: ASDisplayNode
private let bottomSeparatorNode: ASDisplayNode
private let highlightedBackgroundNode: ASDisplayNode?
private let highlightButtonNode: HighlightTrackingButtonNode?
init(theme: InstantPageSettingsItemTheme, selectable: Bool) {
self.topSeparatorNode = ASDisplayNode()
self.topSeparatorNode.isLayerBacked = true
self.topSeparatorNode.isHidden = true
self.bottomSeparatorNode = ASDisplayNode()
self.bottomSeparatorNode.isLayerBacked = true
self.bottomSeparatorNode.isHidden = true
if selectable {
let highlightedBackgroundNode = ASDisplayNode()
highlightedBackgroundNode.isLayerBacked = true
highlightedBackgroundNode.alpha = 0.0
self.highlightedBackgroundNode = highlightedBackgroundNode
self.highlightButtonNode = HighlightTrackingButtonNode()
} else {
self.highlightedBackgroundNode = nil
self.highlightButtonNode = nil
}
super.init()
self.backgroundColor = theme.itemBackgroundColor
self.addSubnode(self.topSeparatorNode)
self.addSubnode(self.bottomSeparatorNode)
if let highlightedBackgroundNode = self.highlightedBackgroundNode {
self.addSubnode(highlightedBackgroundNode)
}
if let highlightButtonNode = self.highlightButtonNode {
self.addSubnode(highlightButtonNode)
highlightButtonNode.addTarget(self, action: #selector(self.buttonPressed), forControlEvents: .touchUpInside)
highlightButtonNode.highligthedChanged = { [weak self] highlighted in
if let strongSelf = self, let highlightedBackgroundNode = strongSelf.highlightedBackgroundNode {
if highlighted {
strongSelf.supernode?.view.bringSubviewToFront(strongSelf.view)
highlightedBackgroundNode.layer.removeAnimation(forKey: "opacity")
highlightedBackgroundNode.alpha = 1.0
} else {
highlightedBackgroundNode.alpha = 0.0
highlightedBackgroundNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2)
}
}
}
}
self.updateTheme(theme)
}
func updateTheme(_ theme: InstantPageSettingsItemTheme) {
self.backgroundColor = theme.itemBackgroundColor
self.highlightedBackgroundNode?.backgroundColor = theme.itemHighlightedBackgroundColor
self.topSeparatorNode.backgroundColor = theme.separatorColor
self.bottomSeparatorNode.backgroundColor = theme.separatorColor
}
func updateInternalLayout(width: CGFloat, insets: UIEdgeInsets, previousItem: (InstantPageSettingsItemNodeStatus, InstantPageSettingsItemNode?), nextItem: (InstantPageSettingsItemNodeStatus, InstantPageSettingsItemNode?)) -> (height: CGFloat, separatorInset: CGFloat?) {
return (44.0 + insets.top + insets.bottom, nil)
}
final func updateLayout(width: CGFloat, previousItem: (InstantPageSettingsItemNodeStatus, InstantPageSettingsItemNode?), nextItem: (InstantPageSettingsItemNodeStatus, InstantPageSettingsItemNode?)) -> CGFloat {
let separatorHeight = UIScreenPixel
let separatorInset: CGFloat = 0.0
var highlightExtension: CGFloat = 0.0
switch previousItem.0 {
case .none:
self.topSeparatorNode.isHidden = true
case .sameSection:
self.topSeparatorNode.isHidden = false
case .otherSection:
self.topSeparatorNode.isHidden = false
}
switch nextItem.0 {
case .none:
self.bottomSeparatorNode.isHidden = true
case .sameSection:
self.bottomSeparatorNode.isHidden = true
highlightExtension = separatorHeight
case .otherSection:
self.bottomSeparatorNode.isHidden = false
}
let (internalHeight, internalSeparatorInset) = self.updateInternalLayout(width: width, insets: UIEdgeInsets(top: self.topSeparatorNode.isHidden ? 0.0 : separatorHeight, left: 0.0, bottom: self.bottomSeparatorNode.isHidden ? 0.0 : separatorHeight, right: 0.0), previousItem: previousItem, nextItem: nextItem)
let finalSeparatorInset = internalSeparatorInset ?? separatorInset
self.topSeparatorNode.frame = CGRect(origin: CGPoint(x: finalSeparatorInset, y: 0.0), size: CGSize(width: width - finalSeparatorInset, height: separatorHeight))
self.bottomSeparatorNode.frame = CGRect(origin: CGPoint(x: 0.0, y: internalHeight - separatorHeight), size: CGSize(width: width, height: separatorHeight))
if let highlightButtonNode = self.highlightButtonNode {
highlightButtonNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -highlightExtension), size: CGSize(width: width, height: internalHeight + highlightExtension))
}
if let highlightedBackgroundNode = self.highlightedBackgroundNode {
highlightedBackgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: width, height: internalHeight + highlightExtension))
}
return internalHeight
}
@objc func buttonPressed() {
self.pressed()
}
func pressed() {
}
}
@@ -0,0 +1,103 @@
import Foundation
import UIKit
import Display
import TelegramPresentationData
import TelegramUIPreferences
final class InstantPageSettingsItemTheme: Equatable {
let listBackgroundColor: UIColor
let itemBackgroundColor: UIColor
let itemHighlightedBackgroundColor: UIColor
let separatorColor: UIColor
let primaryColor: UIColor
let secondaryColor: UIColor
let accentColor: UIColor
init(listBackgroundColor: UIColor, itemBackgroundColor: UIColor, itemHighlightedBackgroundColor: UIColor, separatorColor: UIColor, primaryColor: UIColor, secondaryColor: UIColor, accentColor: UIColor) {
self.listBackgroundColor = listBackgroundColor
self.itemBackgroundColor = itemBackgroundColor
self.itemHighlightedBackgroundColor = itemHighlightedBackgroundColor
self.separatorColor = separatorColor
self.primaryColor = primaryColor
self.secondaryColor = secondaryColor
self.accentColor = accentColor
}
static func ==(lhs: InstantPageSettingsItemTheme, rhs: InstantPageSettingsItemTheme) -> Bool {
if !lhs.listBackgroundColor.isEqual(rhs.listBackgroundColor) {
return false
}
if !lhs.itemBackgroundColor.isEqual(rhs.itemBackgroundColor) {
return false
}
if !lhs.itemHighlightedBackgroundColor.isEqual(rhs.itemHighlightedBackgroundColor) {
return false
}
if !lhs.separatorColor.isEqual(rhs.separatorColor) {
return false
}
if !lhs.primaryColor.isEqual(rhs.primaryColor) {
return false
}
if !lhs.secondaryColor.isEqual(rhs.secondaryColor) {
return false
}
if !lhs.accentColor.isEqual(rhs.accentColor) {
return false
}
return true
}
static func themeFor(_ type: InstantPageThemeType) -> InstantPageSettingsItemTheme {
switch type {
case .light:
return lightTheme
case .sepia:
return sepiaTheme
case .gray:
return grayTheme
case .dark:
return darkTheme
}
}
}
private let lightTheme = InstantPageSettingsItemTheme(
listBackgroundColor: UIColor(rgb: 0xefeff4),
itemBackgroundColor: .white,
itemHighlightedBackgroundColor: UIColor(rgb: 0xd9d9d9),
separatorColor: UIColor(rgb: 0xc8c7cc),
primaryColor: .black,
secondaryColor: UIColor(rgb: 0xa8a8a8),
accentColor: UIColor(rgb: 0x0088ff)
)
private let sepiaTheme = InstantPageSettingsItemTheme(
listBackgroundColor: UIColor(rgb: 0xefeff4),
itemBackgroundColor: .white,
itemHighlightedBackgroundColor: UIColor(rgb: 0xd9d9d9),
separatorColor: UIColor(rgb: 0xc8c7cc),
primaryColor: .black,
secondaryColor: UIColor(rgb: 0xb7b7b7),
accentColor: UIColor(rgb: 0xb06900)
)
private let grayTheme = InstantPageSettingsItemTheme(
listBackgroundColor: UIColor(rgb: 0xefeff4),
itemBackgroundColor: .white,
itemHighlightedBackgroundColor: UIColor(rgb: 0xd9d9d9),
separatorColor: UIColor(rgb: 0xc8c7cc),
primaryColor: .black,
secondaryColor: UIColor(rgb: 0xb6b6b6),
accentColor: UIColor(rgb: 0xc7c7c7)
)
private let darkTheme = InstantPageSettingsItemTheme(
listBackgroundColor: UIColor(rgb: 0x232323),
itemBackgroundColor: UIColor(rgb: 0x1a1a1a),
itemHighlightedBackgroundColor: UIColor(rgb: 0x4c4c4c),
separatorColor: UIColor(rgb: 0x151515),
primaryColor: UIColor(rgb: 0x878787),
secondaryColor: UIColor(rgb: 0xa6a6a6),
accentColor: UIColor(rgb: 0xbfc0c2)
)
@@ -0,0 +1,273 @@
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import SwiftSignalKit
import TelegramPresentationData
import TelegramUIPreferences
import AppBundle
private func generateArrowImage(color: UIColor) -> UIImage? {
let smallRadius: CGFloat = 5.0
let largeRadius: CGFloat = 14.0
return generateImage(CGSize(width: smallRadius + largeRadius, height: smallRadius + largeRadius + 16.0), contextGenerator: { size, context in
context.clear(CGRect(origin: CGPoint(), size: size))
if let image = generateTintedImage(image: UIImage(bundleImageName: "Instant View/SettingsArrow"), color: color), let cgImage = image.cgImage {
context.setFillColor(color.cgColor)
context.fill(CGRect(origin: CGPoint(x: 0.0, y: size.height - image.size.height - 16.0), size: CGSize(width: size.width, height: 16.0)))
context.draw(cgImage, in: CGRect(origin: CGPoint(x: size.width - image.size.width, y: size.height - image.size.height), size: image.size))
}
})
}
final class InstantPageSettingsNode: ASDisplayNode {
private var settings: InstantPagePresentationSettings
private var currentThemeType: (InstantPageThemeType, Bool)
private var theme: InstantPageSettingsItemTheme
private let applySettings: (InstantPagePresentationSettings) -> Void
private let openInSafari: () -> Void
private var sections: [[InstantPageSettingsItemNode]] = []
private let sansFamilyNode: InstantPageSettingsFontFamilyNode
private let serifFamilyNode: InstantPageSettingsFontFamilyNode
private let themeItemNode: InstantPageSettingsThemeItemNode
private let autoNightItemNode: InstantPageSettingsSwitchNode
private let openInItemNode: InstantPageSettingsButtonItemNode
private let arrowNode: ASImageNode
private let itemContainerNode: ASDisplayNode
init(strings: PresentationStrings, settings: InstantPagePresentationSettings, currentThemeType: (InstantPageThemeType, Bool), applySettings: @escaping (InstantPagePresentationSettings) -> Void, openInSafari: @escaping () -> Void) {
self.settings = settings
self.currentThemeType = currentThemeType
self.theme = InstantPageSettingsItemTheme.themeFor(currentThemeType.0)
self.applySettings = applySettings
self.openInSafari = openInSafari
self.arrowNode = ASImageNode()
self.arrowNode.displayWithoutProcessing = true
self.arrowNode.displaysAsynchronously = false
self.arrowNode.image = generateArrowImage(color: self.theme.itemBackgroundColor)
self.itemContainerNode = ASDisplayNode()
self.itemContainerNode.layer.masksToBounds = true
self.itemContainerNode.layer.cornerRadius = 16.0
self.itemContainerNode.backgroundColor = self.theme.listBackgroundColor
var updateSerifImpl: ((Bool) -> Void)?
var updateThemeTypeImpl: ((InstantPageThemeType) -> Void)?
var updateAutoNightImpl: ((Bool) -> Void)?
var openInSafariImpl: (() -> Void)?
self.sansFamilyNode = InstantPageSettingsFontFamilyNode(theme: self.theme, title: "San Francisco", family: nil, checked: !settings.forceSerif, tapped: {
updateSerifImpl?(false)
})
self.serifFamilyNode = InstantPageSettingsFontFamilyNode(theme: self.theme, title: "Georgia", family: "Georgia", checked: settings.forceSerif, tapped: {
updateSerifImpl?(true)
})
self.themeItemNode = InstantPageSettingsThemeItemNode(theme: theme, themeType: settings.themeType, update: { value in
updateThemeTypeImpl?(value)
})
self.autoNightItemNode = InstantPageSettingsSwitchNode(theme: theme, title: strings.InstantPage_AutoNightTheme, isOn: settings.autoNightMode, isEnabled: settings.themeType != .dark, toggled: { value in
updateAutoNightImpl?(value)
})
self.openInItemNode = InstantPageSettingsButtonItemNode(theme: theme, title: strings.Web_OpenExternal, tapped: {
openInSafariImpl?()
})
super.init()
self.addSubnode(self.arrowNode)
self.addSubnode(self.itemContainerNode)
self.sections = [
[
InstantPageSettingsBacklightItemNode(theme: self.theme)
],
[
InstantPageSettingsFontSizeItemNode(theme: self.theme, fontSizeVariant: Int(settings.fontSize.rawValue), updated: { [weak self] value in
if let strongSelf = self {
strongSelf.updateSettings {
let size: InstantPagePresentationFontSize = InstantPagePresentationFontSize(rawValue: Int32(value)) ?? .standard
return $0.withUpdatedFontSize(size)
}
}
}),
self.sansFamilyNode,
self.serifFamilyNode
],
[
self.themeItemNode,
self.autoNightItemNode
],
[
self.openInItemNode
]
]
for section in self.sections {
for item in section {
self.itemContainerNode.addSubnode(item)
}
}
updateSerifImpl = { [weak self] value in
if let strongSelf = self {
strongSelf.updateSettings {
return $0.withUpdatedForceSerif(value)
}
}
}
updateThemeTypeImpl = { [weak self] value in
if let strongSelf = self {
let disableAutoNightMode = strongSelf.currentThemeType.1
strongSelf.updateSettings {
if disableAutoNightMode {
let currentTime: Int32 = 0
return $0.withUpdatedThemeType(value).withUpdatedIgnoreAutoNightModeUntil(currentTime)
} else {
return $0.withUpdatedThemeType(value)
}
}
}
}
updateAutoNightImpl = { [weak self] value in
if let strongSelf = self {
strongSelf.updateSettings {
return $0.withUpdatedAutoNightMode(value).withUpdatedIgnoreAutoNightModeUntil(0)
}
}
}
openInSafariImpl = { [weak self] in
if let strongSelf = self {
strongSelf.openInSafari()
}
}
}
func updateLayout(layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) {
let fixedWidth: CGFloat = 295.0
let sectionSpacing: CGFloat = 4.0
let sideInset: CGFloat = 11.0
let topInset: CGFloat = layout.insets(options: [.statusBar]).top + 44.0 + 6.0
var contentHeight: CGFloat = 0.0
var itemSizes: [[CGFloat]] = []
for sectionIndex in 0 ..< self.sections.count {
itemSizes.append([])
if sectionIndex != 0 {
contentHeight += sectionSpacing
}
for itemIndex in 0 ..< self.sections[sectionIndex].count {
let previousItem: InstantPageSettingsItemNodeStatus
var previousItemNode: InstantPageSettingsItemNode?
let nextItem: InstantPageSettingsItemNodeStatus
var nextItemNode: InstantPageSettingsItemNode?
if itemIndex == 0 {
if sectionIndex == 0 {
previousItem = .none
} else {
previousItem = .otherSection
}
} else {
previousItem = .sameSection
previousItemNode = self.sections[sectionIndex][itemIndex - 1]
}
if itemIndex == self.sections[sectionIndex].count - 1 {
if sectionIndex == self.sections.count - 1 {
nextItem = .none
} else {
nextItem = .otherSection
}
} else {
nextItem = .sameSection
nextItemNode = self.sections[sectionIndex][itemIndex + 1]
}
let itemHeight = self.sections[sectionIndex][itemIndex].updateLayout(width: fixedWidth, previousItem: (previousItem, previousItemNode), nextItem: (nextItem, nextItemNode))
itemSizes[sectionIndex].append(itemHeight)
contentHeight += itemHeight
}
}
if let image = self.arrowNode.image {
transition.updateFrame(node: self.arrowNode, frame: CGRect(origin: CGPoint(x: layout.size.width - sideInset - image.size.width, y: topInset - image.size.height + 16.0 + 8.0), size: image.size))
}
transition.updateFrame(node: self.itemContainerNode, frame: CGRect(origin: CGPoint(x: layout.size.width - sideInset - fixedWidth, y: topInset), size: CGSize(width: fixedWidth, height: contentHeight)))
var nextItemOffset: CGFloat = 0.0
for sectionIndex in 0 ..< self.sections.count {
if sectionIndex != 0 {
nextItemOffset += sectionSpacing
}
for itemIndex in 0 ..< self.sections[sectionIndex].count {
let itemHeight = itemSizes[sectionIndex][itemIndex]
transition.updateFrame(node: self.sections[sectionIndex][itemIndex], frame: CGRect(origin: CGPoint(x: 0.0, y: nextItemOffset), size: CGSize(width: fixedWidth, height: itemHeight)))
nextItemOffset += itemHeight
}
}
}
func animateIn() {
self.layer.allowsGroupOpacity = true
self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3, completion: { [weak self] _ in
self?.layer.allowsGroupOpacity = false
})
}
func animateOut(completion: @escaping () -> Void) {
self.layer.allowsGroupOpacity = true
self.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false, completion: { [weak self] _ in
self?.layer.allowsGroupOpacity = false
completion()
})
}
private func updateSettings(_ f: (InstantPagePresentationSettings) -> InstantPagePresentationSettings) {
let updated = f(self.settings)
if updated != self.settings {
self.settings = updated
self.applySettings(settings)
}
}
func updateSettingsAndCurrentThemeType(settings: InstantPagePresentationSettings, type: (InstantPageThemeType, Bool)) {
self.currentThemeType = type
self.sansFamilyNode.checked = !self.settings.forceSerif
self.serifFamilyNode.checked = self.settings.forceSerif
self.themeItemNode.themeType = self.settings.themeType
self.autoNightItemNode.isEnabled = self.settings.themeType != .dark
let theme = InstantPageSettingsItemTheme.themeFor(self.currentThemeType.0)
if theme != self.theme {
self.theme = theme
if let snapshotView = self.view.snapshotView(afterScreenUpdates: false) {
self.view.addSubview(snapshotView)
snapshotView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false, completion: { [weak snapshotView] _ in
snapshotView?.removeFromSuperview()
})
}
self.arrowNode.image = generateArrowImage(color: self.theme.itemBackgroundColor)
self.itemContainerNode.backgroundColor = self.theme.listBackgroundColor
for section in self.sections {
for item in section {
item.updateTheme(self.theme)
}
}
}
}
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
if self.itemContainerNode.frame.contains(point) {
return super.hitTest(point, with: event)
} else {
return nil
}
}
}
@@ -0,0 +1,87 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
final class InstantPageSettingsSwitchNode: InstantPageSettingsItemNode {
private let title: String
private let toggled: (Bool) -> Void
private let labelNode: ASTextNode
private let switchNode: SwitchNode
var isOn: Bool {
didSet {
if self.isEnabled && self.isOn != self.switchNode.isOn {
self.switchNode.setOn(self.isOn, animated: true)
}
}
}
var isEnabled: Bool {
didSet {
if self.isEnabled {
self.switchNode.setOn(self.isOn, animated: true)
self.switchNode.allowsGroupOpacity = false
self.switchNode.alpha = 1.0
} else {
self.switchNode.setOn(false, animated: true)
self.switchNode.allowsGroupOpacity = true
self.switchNode.alpha = 0.6
}
self.switchNode.isUserInteractionEnabled = self.isEnabled
}
}
init(theme: InstantPageSettingsItemTheme, title: String, isOn: Bool, isEnabled: Bool, toggled: @escaping (Bool) -> Void) {
self.title = title
self.toggled = toggled
self.labelNode = ASTextNode()
self.switchNode = SwitchNode()
if isEnabled {
self.switchNode.isOn = isOn
} else {
self.switchNode.isOn = false
self.switchNode.allowsGroupOpacity = true
self.switchNode.alpha = 0.6
}
self.isOn = isOn
self.isEnabled = isEnabled
super.init(theme: theme, selectable: false)
self.addSubnode(self.labelNode)
self.addSubnode(self.switchNode)
self.switchNode.valueUpdated = { [weak self] value in
if let strongSelf = self {
strongSelf.isOn = value
toggled(value)
}
}
}
override func updateTheme(_ theme: InstantPageSettingsItemTheme) {
super.updateTheme(theme)
self.labelNode.attributedText = NSAttributedString(string: self.title, font: Font.regular(17.0), textColor: theme.primaryColor)
}
override func updateInternalLayout(width: CGFloat, insets: UIEdgeInsets, previousItem: (InstantPageSettingsItemNodeStatus, InstantPageSettingsItemNode?), nextItem: (InstantPageSettingsItemNodeStatus, InstantPageSettingsItemNode?)) -> (height: CGFloat, separatorInset: CGFloat?) {
let labelSize = self.labelNode.measure(CGSize(width: width - 46.0 - 5.0, height: 44.0))
self.labelNode.frame = CGRect(origin: CGPoint(x: 15.0, y: insets.top + floor((44.0 - labelSize.height) / 2.0)), size: labelSize)
if let switchView = self.switchNode.view as? UISwitch {
if self.switchNode.bounds.size.width.isZero {
switchView.sizeToFit()
}
let switchSize = switchView.bounds.size
self.switchNode.frame = CGRect(origin: CGPoint(x: width - switchSize.width - 15.0, y: insets.top + 6.0), size: switchSize)
}
return (44.0 + insets.top + insets.bottom, nil)
}
}
@@ -0,0 +1,169 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import TelegramUIPreferences
private final class InstantPageSettingsThemeSelectorNode: ASDisplayNode {
private let selectionNode: ASImageNode
private let colorNode: ASImageNode
private let color: UIColor
var selected: Bool = false {
didSet {
self.selectionNode.isHidden = !self.selected
}
}
var selectionColor: UIColor {
didSet {
if !self.selectionColor.isEqual(oldValue) {
self.selectionNode.image = generateFilledCircleImage(diameter: 46.0, color: nil, strokeColor: self.selectionColor, strokeWidth: 2.0, backgroundColor: nil)
}
}
}
var edgeColor: UIColor {
didSet {
if !self.edgeColor.isEqual(oldValue) {
self.colorNode.image = generateFilledCircleImage(diameter: 46.0, color: self.color, strokeColor: self.edgeColor, strokeWidth: 1.0, backgroundColor: nil)
}
}
}
init(color: UIColor, edgeColor: UIColor, selectionColor: UIColor) {
self.color = color
self.edgeColor = edgeColor
self.selectionColor = selectionColor
self.selectionNode = ASImageNode()
self.selectionNode.isLayerBacked = true
self.selectionNode.displayWithoutProcessing = true
self.selectionNode.displaysAsynchronously = false
self.selectionNode.image = generateFilledCircleImage(diameter: 46.0, color: nil, strokeColor: self.selectionColor, strokeWidth: 2.0, backgroundColor: nil)
self.selectionNode.frame = CGRect(origin: CGPoint(), size: CGSize(width: 46.0, height: 46.0))
self.colorNode = ASImageNode()
self.colorNode.isLayerBacked = true
self.colorNode.displayWithoutProcessing = true
self.colorNode.displaysAsynchronously = false
self.colorNode.image = generateFilledCircleImage(diameter: 46.0, color: self.color, strokeColor: self.edgeColor, strokeWidth: 1.0, backgroundColor: nil)
self.colorNode.frame = CGRect(origin: CGPoint(), size: CGSize(width: 46.0, height: 46.0))
super.init()
self.addSubnode(self.colorNode)
self.addSubnode(self.selectionNode)
}
}
final class InstantPageSettingsThemeItemNode: InstantPageSettingsItemNode {
private let update: (InstantPageThemeType) -> Void
private let themeNodes: [InstantPageSettingsThemeSelectorNode]
var themeType: InstantPageThemeType {
didSet {
let selectedIndex: Int
switch self.themeType {
case .light:
selectedIndex = 0
case .sepia:
selectedIndex = 1
case .gray:
selectedIndex = 2
case .dark:
selectedIndex = 3
}
self.themeNodes[0].edgeColor = (selectedIndex == 1 || selectedIndex == 2) ? UIColor.lightGray : UIColor.white
for i in 0 ..< self.themeNodes.count {
self.themeNodes[i].selected = i == selectedIndex
}
}
}
init(theme: InstantPageSettingsItemTheme, themeType: InstantPageThemeType, update: @escaping (InstantPageThemeType) -> Void) {
self.themeType = themeType
self.update = update
let selectedIndex: Int
switch themeType {
case .light:
selectedIndex = 0
case .sepia:
selectedIndex = 1
case .gray:
selectedIndex = 2
case .dark:
selectedIndex = 3
}
let selectionColor = UIColor(rgb: 0x0088ff)
self.themeNodes = [
InstantPageSettingsThemeSelectorNode(color: .white, edgeColor: (selectedIndex == 1 || selectedIndex == 2) ? UIColor.lightGray : UIColor.white, selectionColor: selectionColor),
InstantPageSettingsThemeSelectorNode(color: UIColor(rgb: 0xcbb98e), edgeColor: UIColor(rgb: 0xcbb98e), selectionColor: selectionColor),
InstantPageSettingsThemeSelectorNode(color: UIColor(rgb: 0x48484a), edgeColor: UIColor(rgb: 0x48484a), selectionColor: selectionColor),
InstantPageSettingsThemeSelectorNode(color: UIColor(rgb: 0x333333), edgeColor: UIColor(rgb: 0x333333), selectionColor: selectionColor)
]
super.init(theme: theme, selectable: false)
for i in 0 ..< self.themeNodes.count {
self.themeNodes[i].selected = i == selectedIndex
self.addSubnode(self.themeNodes[i])
}
}
override func didLoad() {
super.didLoad()
self.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:))))
}
override func updateTheme(_ theme: InstantPageSettingsItemTheme) {
super.updateTheme(theme)
}
override func updateInternalLayout(width: CGFloat, insets: UIEdgeInsets, previousItem: (InstantPageSettingsItemNodeStatus, InstantPageSettingsItemNode?), nextItem: (InstantPageSettingsItemNodeStatus, InstantPageSettingsItemNode?)) -> (height: CGFloat, separatorInset: CGFloat?) {
let sideInset: CGFloat = 26.0
let topInset: CGFloat = 12.0
let itemSize = CGSize(width: 46.0, height: 46.0)
let spacing: CGFloat = floor((width - CGFloat(self.themeNodes.count) * itemSize.width - sideInset * 2.0) / CGFloat(self.themeNodes.count - 1))
for i in 0 ..< self.themeNodes.count {
self.themeNodes[i].frame = CGRect(origin: CGPoint(x: sideInset + CGFloat(i) * (itemSize.width + spacing), y: insets.top + topInset), size: itemSize)
}
return (70.0 + insets.top + insets.bottom, nil)
}
@objc func tapGesture(_ recognizer: UITapGestureRecognizer) {
if case .ended = recognizer.state {
let location = recognizer.location(in: self.view)
for i in 0 ..< self.themeNodes.count {
if self.themeNodes[i].frame.contains(location) {
let themeType: InstantPageThemeType
switch i {
case 0:
themeType = .light
case 1:
themeType = .sepia
case 2:
themeType = .gray
case 3:
themeType = .dark
default:
themeType = .light
}
self.update(themeType)
break
}
}
}
}
}
@@ -0,0 +1,79 @@
import Foundation
import UIKit
import TelegramCore
import AsyncDisplayKit
import TelegramPresentationData
import TelegramUIPreferences
import AccountContext
import ContextUI
enum InstantPageShape {
case rect
case ellipse
case roundLine
}
public final class InstantPageShapeItem: InstantPageItem {
public var frame: CGRect
let shapeFrame: CGRect
let shape: InstantPageShape
let color: UIColor
public let medias: [InstantPageMedia] = []
public let wantsNode: Bool = false
public let separatesTiles: Bool = false
init(frame: CGRect, shapeFrame: CGRect, shape: InstantPageShape, color: UIColor) {
self.frame = frame
self.shapeFrame = shapeFrame
self.shape = shape
self.color = color
}
public func drawInTile(context: CGContext) {
context.setFillColor(self.color.cgColor)
switch self.shape {
case .rect:
context.fill(self.shapeFrame.offsetBy(dx: self.frame.minX, dy: self.frame.minY))
case .ellipse:
context.fillEllipse(in: self.shapeFrame.offsetBy(dx: self.frame.minX, dy: self.frame.minY))
case .roundLine:
if self.shapeFrame.size.width < self.shapeFrame.size.height {
let radius = self.shapeFrame.size.width / 2.0
var shapeFrame = self.shapeFrame.offsetBy(dx: self.frame.minX, dy: self.frame.minY)
shapeFrame.origin.y += radius
shapeFrame.size.height -= radius + radius
context.fill(shapeFrame)
context.fillEllipse(in: CGRect(x: shapeFrame.minX, y: shapeFrame.minY - radius, width: radius + radius, height: radius + radius))
context.fillEllipse(in: CGRect(x: shapeFrame.minX, y: shapeFrame.maxY - radius, width: radius + radius, height: radius + radius))
} else {
context.fill(self.shapeFrame.offsetBy(dx: self.frame.minX, dy: self.frame.minY))
}
}
}
public func matchesAnchor(_ anchor: String) -> Bool {
return false
}
public func matchesNode(_ node: InstantPageNode) -> Bool {
return false
}
public func node(context: AccountContext, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, theme: InstantPageTheme, sourceLocation: InstantPageSourceLocation, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, openPeer: @escaping (EnginePeer) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void, updateWebEmbedHeight: @escaping (CGFloat) -> Void, updateDetailsExpanded: @escaping (Bool) -> Void, currentExpandedDetails: [Int : Bool]?, getPreloadedResource: @escaping (String) -> Data?) -> InstantPageNode? {
return nil
}
public func linkSelectionRects(at point: CGPoint) -> [CGRect] {
return []
}
public func distanceThresholdGroup() -> Int? {
return nil
}
public func distanceThresholdWithGroupCount(_ count: Int) -> CGFloat {
return 0.0
}
}
@@ -0,0 +1,58 @@
import Foundation
import UIKit
import TelegramCore
import AsyncDisplayKit
import TelegramPresentationData
import TelegramUIPreferences
import AccountContext
import ContextUI
final class InstantPageSlideshowItem: InstantPageItem {
var frame: CGRect
let webPage: TelegramMediaWebpage
let wantsNode: Bool = true
let separatesTiles: Bool = false
let medias: [InstantPageMedia]
init(frame: CGRect, webPage: TelegramMediaWebpage, medias: [InstantPageMedia]) {
self.frame = frame
self.webPage = webPage
self.medias = medias
}
func node(context: AccountContext, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, theme: InstantPageTheme, sourceLocation: InstantPageSourceLocation, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, openPeer: @escaping (EnginePeer) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void, updateWebEmbedHeight: @escaping (CGFloat) -> Void, updateDetailsExpanded: @escaping (Bool) -> Void, currentExpandedDetails: [Int : Bool]?, getPreloadedResource: @escaping (String) -> Data?) -> InstantPageNode? {
return InstantPageSlideshowNode(context: context, sourceLocation: sourceLocation, theme: theme, webPage: webPage, medias: self.medias, openMedia: openMedia, longPressMedia: longPressMedia, activatePinchPreview: activatePinchPreview, pinchPreviewFinished: pinchPreviewFinished)
}
func matchesAnchor(_ anchor: String) -> Bool {
return false
}
func matchesNode(_ node: InstantPageNode) -> Bool {
if let node = node as? InstantPageSlideshowNode {
return self.medias == node.medias
} else {
return false
}
}
func distanceThresholdGroup() -> Int? {
return 3
}
func distanceThresholdWithGroupCount(_ count: Int) -> CGFloat {
if count > 3 {
return 1000.0
} else {
return CGFloat.greatestFiniteMagnitude
}
}
func linkSelectionRects(at point: CGPoint) -> [CGRect] {
return []
}
func drawInTile(context: CGContext) {
}
}
@@ -0,0 +1,450 @@
import Foundation
import UIKit
import TelegramCore
import AsyncDisplayKit
import Display
import TelegramPresentationData
import AccountContext
import TelegramUIPreferences
import ContextUI
private final class InstantPageSlideshowItemNode: ASDisplayNode {
private var _index: Int?
var index: Int {
get {
return self._index!
} set(value) {
self._index = value
}
}
private let contentNode: ASDisplayNode
var internalIsVisible: Bool = false {
didSet {
if self.internalParentVisible && oldValue != self.internalIsVisible && self.internalParentVisible {
(self.contentNode as? InstantPageNode)?.updateIsVisible(self.internalIsVisible && self.internalParentVisible)
}
}
}
var internalParentVisible: Bool = false {
didSet {
if self.internalIsVisible && oldValue != self.internalIsVisible && self.internalParentVisible {
(self.contentNode as? InstantPageNode)?.updateIsVisible(self.internalIsVisible && self.internalParentVisible)
}
}
}
init(contentNode: ASDisplayNode) {
self.contentNode = contentNode
super.init()
self.addSubnode(self.contentNode)
}
override func layout() {
super.layout()
self.contentNode.frame = self.bounds
}
func updateHiddenMedia(_ media: InstantPageMedia?) {
if let node = self.contentNode as? InstantPageNode {
node.updateHiddenMedia(media: media)
}
}
func transitionNode(media: InstantPageMedia) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? {
if let node = self.contentNode as? InstantPageNode {
return node.transitionNode(media: media)
}
return nil
}
}
private final class InstantPageSlideshowPagerNode: ASDisplayNode, ASScrollViewDelegate {
private let context: AccountContext
private let sourceLocation: InstantPageSourceLocation
private let theme: InstantPageTheme
private let webPage: TelegramMediaWebpage
private let openMedia: (InstantPageMedia) -> Void
private let longPressMedia: (InstantPageMedia) -> Void
private let activatePinchPreview: ((PinchSourceContainerNode) -> Void)?
private let pinchPreviewFinished: ((InstantPageNode) -> Void)?
private let pageGap: CGFloat
private let scrollView: UIScrollView
private var items: [InstantPageMedia] = []
private var itemNodes: [InstantPageSlideshowItemNode] = []
private var ignoreCentralItemIndexUpdate = false
private var centralItemIndex: Int? {
didSet {
if oldValue != self.centralItemIndex && !self.ignoreCentralItemIndexUpdate {
//self.centralItemIndexUpdated(self.centralItemIndex)
}
}
}
private var containerLayout: ContainerViewLayout?
var centralItemIndexUpdated: (Int?) -> Void = { _ in }
var internalIsVisible: Bool = false {
didSet {
if self.internalIsVisible != oldValue {
for node in self.itemNodes {
node.internalParentVisible = self.internalIsVisible
}
}
}
}
init(context: AccountContext, sourceLocation: InstantPageSourceLocation, theme: InstantPageTheme, webPage: TelegramMediaWebpage, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, pageGap: CGFloat = 0.0) {
self.context = context
self.sourceLocation = sourceLocation
self.theme = theme
self.webPage = webPage
self.openMedia = openMedia
self.longPressMedia = longPressMedia
self.activatePinchPreview = activatePinchPreview
self.pinchPreviewFinished = pinchPreviewFinished
self.pageGap = pageGap
self.scrollView = UIScrollView()
if #available(iOSApplicationExtension 11.0, iOS 11.0, *) {
self.scrollView.contentInsetAdjustmentBehavior = .never
}
super.init()
self.scrollView.showsVerticalScrollIndicator = false
self.scrollView.showsHorizontalScrollIndicator = false
self.scrollView.alwaysBounceHorizontal = !pageGap.isZero
self.scrollView.bounces = !pageGap.isZero
self.scrollView.isPagingEnabled = true
self.scrollView.delegate = self.wrappedScrollViewDelegate
self.scrollView.clipsToBounds = false
self.scrollView.scrollsToTop = false
self.view.addSubview(self.scrollView)
self.view.disablesInteractiveTransitionGestureRecognizer = true
}
func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) {
self.containerLayout = layout
var previousCentralNodeHorizontalOffset: CGFloat?
if let centralItemIndex = self.centralItemIndex, let centralNode = self.visibleItemNode(at: centralItemIndex) {
previousCentralNodeHorizontalOffset = self.scrollView.contentOffset.x - centralNode.frame.minX
}
self.scrollView.frame = CGRect(origin: CGPoint(x: -self.pageGap, y: 0.0), size: CGSize(width: layout.size.width + self.pageGap * 2.0, height: layout.size.height))
for i in 0 ..< self.itemNodes.count {
self.itemNodes[i].frame = CGRect(origin: CGPoint(x: CGFloat(i) * self.scrollView.bounds.size.width + self.pageGap, y: 0.0), size: CGSize(width: self.scrollView.bounds.size.width - self.pageGap * 2.0, height: self.scrollView.bounds.size.height))
//self.itemNodes[i].containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: transition)
}
if let previousCentralNodeHorizontalOffset = previousCentralNodeHorizontalOffset, let centralItemIndex = self.centralItemIndex, let centralNode = self.visibleItemNode(at: centralItemIndex) {
self.scrollView.contentOffset = CGPoint(x: centralNode.frame.minX + previousCentralNodeHorizontalOffset, y: 0.0)
}
self.updateItemNodes()
}
func centralItemNode() -> InstantPageSlideshowItemNode? {
if let centralItemIndex = self.centralItemIndex, let centralItemNode = self.visibleItemNode(at: centralItemIndex) {
return centralItemNode
} else {
return nil
}
}
func replaceItems(_ items: [InstantPageMedia], centralItemIndex: Int?, keepFirst: Bool = false) {
var keptItemNode: InstantPageSlideshowItemNode?
for itemNode in self.itemNodes {
if keepFirst && itemNode.index == 0 {
keptItemNode = itemNode
} else {
itemNode.removeFromSupernode()
}
}
self.itemNodes.removeAll()
if let keptItemNode = keptItemNode {
self.itemNodes.append(keptItemNode)
}
if let centralItemIndex = centralItemIndex, centralItemIndex >= 0 && centralItemIndex < items.count {
self.centralItemIndex = centralItemIndex
} else {
self.centralItemIndex = nil
}
self.items = items
self.updateItemNodes()
}
private func makeNodeForItem(at index: Int) -> InstantPageSlideshowItemNode {
let media = self.items[index]
let contentNode: ASDisplayNode
if case .image = media.media {
contentNode = InstantPageImageNode(context: self.context, sourceLocation: self.sourceLocation, theme: self.theme, webPage: self.webPage, media: media, attributes: [], interactive: true, roundCorners: false, fit: false, openMedia: self.openMedia, longPressMedia: self.longPressMedia, activatePinchPreview: self.activatePinchPreview, pinchPreviewFinished: self.pinchPreviewFinished, getPreloadedResource: { _ in return nil })
} else if case .file = media.media {
contentNode = ASDisplayNode()
} else {
contentNode = ASDisplayNode()
}
let node = InstantPageSlideshowItemNode(contentNode: contentNode)
node.index = index
return node
}
private func visibleItemNode(at index: Int) -> InstantPageSlideshowItemNode? {
for itemNode in self.itemNodes {
if itemNode.index == index {
return itemNode
}
}
return nil
}
private func addVisibleItemNode(_ node: InstantPageSlideshowItemNode) {
var added = false
for i in 0 ..< self.itemNodes.count {
if node.index < self.itemNodes[i].index {
self.itemNodes.insert(node, at: i)
added = true
break
}
}
if !added {
self.itemNodes.append(node)
}
self.scrollView.addSubview(node.view)
}
private func removeVisibleItemNode(internalIndex: Int) {
self.itemNodes[internalIndex].view.removeFromSuperview()
self.itemNodes.remove(at: internalIndex)
}
private func updateItemNodes() {
if self.items.isEmpty || self.containerLayout == nil {
return
}
var resetOffsetToCentralItem = false
if self.itemNodes.isEmpty {
let node = self.makeNodeForItem(at: self.centralItemIndex ?? 0)
node.frame = CGRect(origin: CGPoint(), size: scrollView.bounds.size)
if let _ = self.containerLayout {
//node.containerLayoutUpdated(containerLayout.0, navigationBarHeight: containerLayout.1, transition: .immediate)
}
self.addVisibleItemNode(node)
self.centralItemIndex = node.index
resetOffsetToCentralItem = true
}
var notifyCentralItemUpdated = false
if let centralItemIndex = self.centralItemIndex, let centralItemNode = self.visibleItemNode(at: centralItemIndex) {
if centralItemIndex != 0 {
if self.visibleItemNode(at: centralItemIndex - 1) == nil {
let node = self.makeNodeForItem(at: centralItemIndex - 1)
node.frame = centralItemNode.frame.offsetBy(dx: -centralItemNode.frame.size.width - self.pageGap, dy: 0.0)
if let _ = self.containerLayout {
//node.containerLayoutUpdated(containerLayout.0, navigationBarHeight: containerLayout.1, transition: .immediate)
}
self.addVisibleItemNode(node)
}
}
if centralItemIndex != items.count - 1 {
if self.visibleItemNode(at: centralItemIndex + 1) == nil {
let node = self.makeNodeForItem(at: centralItemIndex + 1)
node.frame = centralItemNode.frame.offsetBy(dx: centralItemNode.frame.size.width + self.pageGap, dy: 0.0)
if let _ = self.containerLayout {
//node.containerLayoutUpdated(containerLayout.0, navigationBarHeight: containerLayout.1, transition: .immediate)
}
self.addVisibleItemNode(node)
}
}
for i in 0 ..< self.itemNodes.count {
self.itemNodes[i].frame = CGRect(origin: CGPoint(x: CGFloat(i) * self.scrollView.bounds.size.width + self.pageGap, y: 0.0), size: CGSize(width: self.scrollView.bounds.size.width - self.pageGap * 2.0, height: self.scrollView.bounds.size.height))
}
if resetOffsetToCentralItem {
self.scrollView.contentOffset = CGPoint(x: centralItemNode.frame.minX - self.pageGap, y: 0.0)
}
if let centralItemCandidateNode = self.centralItemCandidate(), centralItemCandidateNode.index != centralItemIndex {
for i in (0 ..< self.itemNodes.count).reversed() {
let node = self.itemNodes[i]
if node.index < centralItemCandidateNode.index - 1 || node.index > centralItemCandidateNode.index + 1 {
self.removeVisibleItemNode(internalIndex: i)
}
}
self.ignoreCentralItemIndexUpdate = true
self.centralItemIndex = centralItemCandidateNode.index
self.ignoreCentralItemIndexUpdate = false
notifyCentralItemUpdated = true
if centralItemCandidateNode.index != 0 {
if self.visibleItemNode(at: centralItemCandidateNode.index - 1) == nil {
let node = self.makeNodeForItem(at: centralItemCandidateNode.index - 1)
node.frame = centralItemCandidateNode.frame.offsetBy(dx: -centralItemCandidateNode.frame.size.width - self.pageGap, dy: 0.0)
if let _ = self.containerLayout {
//node.containerLayoutUpdated(containerLayout.0, navigationBarHeight: containerLayout.1, transition: .immediate)
}
self.addVisibleItemNode(node)
}
}
if centralItemCandidateNode.index != items.count - 1 {
if self.visibleItemNode(at: centralItemCandidateNode.index + 1) == nil {
let node = self.makeNodeForItem(at: centralItemCandidateNode.index + 1)
node.frame = centralItemCandidateNode.frame.offsetBy(dx: centralItemCandidateNode.frame.size.width + self.pageGap, dy: 0.0)
if let _ = self.containerLayout {
//node.containerLayoutUpdated(containerLayout.0, navigationBarHeight: containerLayout.1, transition: .immediate)
}
self.addVisibleItemNode(node)
}
}
let previousCentralCandidateHorizontalOffset = self.scrollView.contentOffset.x - centralItemCandidateNode.frame.minX
for i in 0 ..< self.itemNodes.count {
self.itemNodes[i].frame = CGRect(origin: CGPoint(x: CGFloat(i) * self.scrollView.bounds.size.width + self.pageGap, y: 0.0), size: CGSize(width: self.scrollView.bounds.size.width - self.pageGap * 2.0, height: self.scrollView.bounds.size.height))
}
self.scrollView.contentOffset = CGPoint(x: centralItemCandidateNode.frame.minX + previousCentralCandidateHorizontalOffset, y: 0.0)
}
self.scrollView.contentSize = CGSize(width: CGFloat(self.itemNodes.count) * self.scrollView.bounds.size.width, height: self.scrollView.bounds.size.height)
} else {
assertionFailure()
}
for itemNode in self.itemNodes {
//itemNode.centralityUpdated(isCentral: itemNode.index == self.centralItemIndex)
//itemNode.visibilityUpdated(isVisible: self.scrollView.bounds.intersects(itemNode.frame))
itemNode.internalIsVisible = self.scrollView.bounds.intersects(itemNode.frame)
}
if notifyCentralItemUpdated {
self.centralItemIndexUpdated(self.centralItemIndex)
}
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
self.updateItemNodes()
}
private func centralItemCandidate() -> InstantPageSlideshowItemNode? {
let hotizontlOffset = self.scrollView.contentOffset.x + self.pageGap
var closestNodeAndDistance: (Int, CGFloat)?
for i in 0 ..< self.itemNodes.count {
let node = self.itemNodes[i]
let distance = abs(node.frame.minX - hotizontlOffset)
if let currentClosestNodeAndDistance = closestNodeAndDistance {
if distance < currentClosestNodeAndDistance.1 {
closestNodeAndDistance = (node.index, distance)
}
} else {
closestNodeAndDistance = (node.index, distance)
}
}
if let closestNodeAndDistance = closestNodeAndDistance {
return self.visibleItemNode(at: closestNodeAndDistance.0)
} else {
return nil
}
}
func updateHiddenMedia(_ media: InstantPageMedia?) {
for node in self.itemNodes {
node.updateHiddenMedia(media)
}
}
func transitionNode(media: InstantPageMedia) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? {
for node in self.itemNodes {
if let transitionNode = node.transitionNode(media: media) {
return transitionNode
}
}
return nil
}
}
final class InstantPageSlideshowNode: ASDisplayNode, InstantPageNode {
var medias: [InstantPageMedia] = []
private let pagerNode: InstantPageSlideshowPagerNode
private let pageControlNode: PageControlNode
init(context: AccountContext, sourceLocation: InstantPageSourceLocation, theme: InstantPageTheme, webPage: TelegramMediaWebpage, medias: [InstantPageMedia], openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?) {
self.medias = medias
self.pagerNode = InstantPageSlideshowPagerNode(context: context, sourceLocation: sourceLocation, theme: theme, webPage: webPage, openMedia: openMedia, longPressMedia: longPressMedia, activatePinchPreview: activatePinchPreview, pinchPreviewFinished: pinchPreviewFinished)
self.pagerNode.replaceItems(medias, centralItemIndex: nil)
self.pageControlNode = PageControlNode(dotColor: .white, inactiveDotColor: UIColor(white: 1.0, alpha: 0.5))
self.pageControlNode.isUserInteractionEnabled = false
super.init()
self.backgroundColor = theme.panelSecondaryColor
self.clipsToBounds = true
self.addSubnode(self.pagerNode)
self.addSubnode(self.pageControlNode)
self.pageControlNode.pagesCount = medias.count
self.pageControlNode.setPage(0)
self.pagerNode.centralItemIndexUpdated = { [weak self] index in
if let strongSelf = self, let index = index {
strongSelf.pageControlNode.setPage(CGFloat(index))
}
}
}
override func layout() {
super.layout()
self.pagerNode.frame = self.bounds
self.pagerNode.containerLayoutUpdated(ContainerViewLayout(size: self.bounds.size, metrics: LayoutMetrics(), deviceMetrics: .unknown(screenSize: CGSize(), statusBarHeight: 0.0, onScreenNavigationHeight: nil, screenCornerRadius: 0.0), intrinsicInsets: UIEdgeInsets(), safeInsets: UIEdgeInsets(), additionalInsets: UIEdgeInsets(), statusBarHeight: nil, inputHeight: nil, inputHeightIsInteractivellyChanging: false, inVoiceOver: false), transition: .immediate)
self.pageControlNode.layer.transform = CATransform3DIdentity
self.pageControlNode.frame = CGRect(origin: CGPoint(x: 0.0, y: self.bounds.size.height - 20.0), size: CGSize(width: self.bounds.size.width, height: 20.0))
let maxWidth = self.bounds.width - 36.0;
let size = self.pageControlNode.calculateSizeThatFits(self.bounds.size)
if size.width > maxWidth
{
let scale = maxWidth / size.width
self.pageControlNode.layer.transform = CATransform3DMakeScale(scale, scale, 1.0)
}
}
func transitionNode(media: InstantPageMedia) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? {
return self.pagerNode.transitionNode(media: media)
}
func updateHiddenMedia(media: InstantPageMedia?) {
self.pagerNode.updateHiddenMedia(media)
}
func updateIsVisible(_ isVisible: Bool) {
self.pagerNode.internalIsVisible = isVisible
}
func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition) {
}
func update(strings: PresentationStrings, theme: InstantPageTheme) {
self.backgroundColor = theme.panelSecondaryColor
}
}
@@ -0,0 +1,78 @@
import Foundation
import UIKit
import SwiftSignalKit
import TelegramCore
import TelegramUIPreferences
public final class InstantPageStoredDetailsState: Codable {
public let index: Int32
public let expanded: Bool
public let details: [InstantPageStoredDetailsState]
public init(index: Int32, expanded: Bool, details: [InstantPageStoredDetailsState]) {
self.index = index
self.expanded = expanded
self.details = details
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: StringCodingKey.self)
self.index = try container.decode(Int32.self, forKey: "index")
self.expanded = try container.decode(Bool.self, forKey: "expanded")
self.details = try container.decode([InstantPageStoredDetailsState].self, forKey: "details")
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: StringCodingKey.self)
try container.encode(self.index, forKey: "index")
try container.encode(self.expanded, forKey: "expanded")
try container.encode(self.details, forKey: "details")
}
}
public final class InstantPageStoredState: Codable {
public let contentOffset: Double
public let details: [InstantPageStoredDetailsState]
public init(contentOffset: Double, details: [InstantPageStoredDetailsState]) {
self.contentOffset = contentOffset
self.details = details
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: StringCodingKey.self)
self.contentOffset = try container.decode(Double.self, forKey: "offset")
self.details = try container.decode([InstantPageStoredDetailsState].self, forKey: "details")
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: StringCodingKey.self)
try container.encode(self.contentOffset, forKey: "offset")
try container.encode(self.details, forKey: "details")
}
}
public func instantPageStoredState(engine: TelegramEngine, webPage: TelegramMediaWebpage) -> Signal<InstantPageStoredState?, NoError> {
let key = EngineDataBuffer(length: 8)
key.setInt64(0, value: webPage.webpageId.id)
return engine.data.get(TelegramEngine.EngineData.Item.ItemCache.Item(collectionId: ApplicationSpecificItemCacheCollectionId.instantPageStoredState, id: key))
|> map { entry -> InstantPageStoredState? in
return entry?.get(InstantPageStoredState.self)
}
}
public func updateInstantPageStoredStateInteractively(engine: TelegramEngine, webPage: TelegramMediaWebpage, state: InstantPageStoredState?) -> Signal<Never, NoError> {
let key = EngineDataBuffer(length: 8)
key.setInt64(0, value: webPage.webpageId.id)
if let state = state {
return engine.itemCache.put(collectionId: ApplicationSpecificItemCacheCollectionId.instantPageStoredState, id: key, item: state)
} else {
return engine.itemCache.remove(collectionId: ApplicationSpecificItemCacheCollectionId.instantPageStoredState, id: key)
}
}
@@ -0,0 +1,445 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import TelegramCore
import SwiftSignalKit
import TelegramPresentationData
import TelegramUIPreferences
import AccountContext
final class InstantPageSubContentNode : ASDisplayNode {
private let context: AccountContext
private let strings: PresentationStrings
private let nameDisplayOrder: PresentationPersonNameOrder
private let sourceLocation: InstantPageSourceLocation
private let theme: InstantPageTheme
private let openMedia: (InstantPageMedia) -> Void
private let longPressMedia: (InstantPageMedia) -> Void
private let openPeer: (EnginePeer) -> Void
private let openUrl: (InstantPageUrlItem) -> Void
var currentLayoutTiles: [InstantPageTile] = []
var currentLayoutItemsWithNodes: [InstantPageItem] = []
var distanceThresholdGroupCount: [Int: Int] = [:]
var visibleTiles: [Int: InstantPageTileNode] = [:]
var visibleItemsWithNodes: [Int: InstantPageNode] = [:]
var currentWebEmbedHeights: [Int : CGFloat] = [:]
var currentExpandedDetails: [Int : Bool]?
var currentDetailsItems: [InstantPageDetailsItem] = []
var requestLayoutUpdate: ((Bool) -> Void)?
var currentLayout: InstantPageLayout
let contentSize: CGSize
let inOverlayPanel: Bool
private var previousVisibleBounds: CGRect?
init(context: AccountContext, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, sourceLocation: InstantPageSourceLocation, theme: InstantPageTheme, items: [InstantPageItem], contentSize: CGSize, inOverlayPanel: Bool = false, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, openPeer: @escaping (EnginePeer) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void) {
self.context = context
self.strings = strings
self.nameDisplayOrder = nameDisplayOrder
self.sourceLocation = sourceLocation
self.theme = theme
self.openMedia = openMedia
self.longPressMedia = longPressMedia
self.openPeer = openPeer
self.openUrl = openUrl
self.currentLayout = InstantPageLayout(origin: CGPoint(), contentSize: contentSize, items: items)
self.contentSize = contentSize
self.inOverlayPanel = inOverlayPanel
super.init()
self.updateLayout()
}
private func updateLayout() {
for (_, tileNode) in self.visibleTiles {
tileNode.removeFromSupernode()
}
self.visibleTiles.removeAll()
let currentLayoutTiles = instantPageTilesFromLayout(currentLayout, boundingWidth: contentSize.width)
var currentDetailsItems: [InstantPageDetailsItem] = []
var currentLayoutItemsWithViews: [InstantPageItem] = []
var distanceThresholdGroupCount: [Int: Int] = [:]
var expandedDetails: [Int: Bool] = [:]
var detailsIndex = -1
for item in self.currentLayout.items {
if item.wantsNode {
currentLayoutItemsWithViews.append(item)
if let group = item.distanceThresholdGroup() {
let count: Int
if let currentCount = distanceThresholdGroupCount[Int(group)] {
count = currentCount
} else {
count = 0
}
distanceThresholdGroupCount[Int(group)] = count + 1
}
if let detailsItem = item as? InstantPageDetailsItem {
detailsIndex += 1
expandedDetails[detailsIndex] = detailsItem.initiallyExpanded
currentDetailsItems.append(detailsItem)
}
}
}
if self.currentExpandedDetails == nil {
self.currentExpandedDetails = expandedDetails
}
self.currentLayoutTiles = currentLayoutTiles
self.currentLayoutItemsWithNodes = currentLayoutItemsWithViews
self.currentDetailsItems = currentDetailsItems
self.distanceThresholdGroupCount = distanceThresholdGroupCount
}
var effectiveContentSize: CGSize {
var contentSize = self.contentSize
for item in self.currentDetailsItems {
let expanded = self.currentExpandedDetails?[item.index] ?? item.initiallyExpanded
contentSize.height += -item.frame.height + (expanded ? self.effectiveSizeForDetails(item).height : item.titleHeight)
}
return contentSize
}
func updateVisibleItems(visibleBounds: CGRect, animated: Bool = false) {
var visibleTileIndices = Set<Int>()
var visibleItemIndices = Set<Int>()
self.previousVisibleBounds = visibleBounds
var topNode: ASDisplayNode?
let topTileNode = topNode
if let scrollSubnodes = self.subnodes {
for node in scrollSubnodes.reversed() {
if let node = node as? InstantPageTileNode {
topNode = node
break
}
}
}
var collapseOffset: CGFloat = 0.0
let transition: ContainedViewLayoutTransition
if animated {
transition = .animated(duration: 0.3, curve: .spring)
} else {
transition = .immediate
}
var itemIndex = -1
var embedIndex = -1
var detailsIndex = -1
for item in self.currentLayoutItemsWithNodes {
itemIndex += 1
if item is InstantPageWebEmbedItem {
embedIndex += 1
}
if item is InstantPageDetailsItem {
detailsIndex += 1
}
var itemThreshold: CGFloat = 0.0
if let group = item.distanceThresholdGroup() {
var count: Int = 0
if let currentCount = self.distanceThresholdGroupCount[group] {
count = currentCount
}
itemThreshold = item.distanceThresholdWithGroupCount(count)
}
var itemFrame = item.frame.offsetBy(dx: 0.0, dy: -collapseOffset)
var thresholdedItemFrame = itemFrame
thresholdedItemFrame.origin.y -= itemThreshold
thresholdedItemFrame.size.height += itemThreshold * 2.0
if let detailsItem = item as? InstantPageDetailsItem, let expanded = self.currentExpandedDetails?[detailsIndex] {
let height = expanded ? self.effectiveSizeForDetails(detailsItem).height : detailsItem.titleHeight
collapseOffset += itemFrame.height - height
itemFrame = CGRect(origin: itemFrame.origin, size: CGSize(width: itemFrame.width, height: height))
}
if visibleBounds.intersects(thresholdedItemFrame) {
visibleItemIndices.insert(itemIndex)
var itemNode = self.visibleItemsWithNodes[itemIndex]
if let currentItemNode = itemNode {
if !item.matchesNode(currentItemNode) {
currentItemNode.removeFromSupernode()
self.visibleItemsWithNodes.removeValue(forKey: itemIndex)
itemNode = nil
}
}
if itemNode == nil {
let itemIndex = itemIndex
let detailsIndex = detailsIndex
if let newNode = item.node(context: self.context, strings: self.strings, nameDisplayOrder: self.nameDisplayOrder, theme: theme, sourceLocation: self.sourceLocation, openMedia: { [weak self] media in
self?.openMedia(media)
}, longPressMedia: { [weak self] media in
self?.longPressMedia(media)
}, activatePinchPreview: nil, pinchPreviewFinished: nil, openPeer: { [weak self] peerId in
self?.openPeer(peerId)
}, openUrl: { [weak self] url in
self?.openUrl(url)
}, updateWebEmbedHeight: { _ in
}, updateDetailsExpanded: { [weak self] expanded in
self?.updateDetailsExpanded(detailsIndex, expanded)
}, currentExpandedDetails: self.currentExpandedDetails, getPreloadedResource: { _ in return nil }) {
newNode.frame = itemFrame
newNode.updateLayout(size: itemFrame.size, transition: transition)
if let topNode = topNode {
self.insertSubnode(newNode, aboveSubnode: topNode)
} else {
self.insertSubnode(newNode, at: 0)
}
topNode = newNode
self.visibleItemsWithNodes[itemIndex] = newNode
itemNode = newNode
if let itemNode = itemNode as? InstantPageDetailsNode {
itemNode.requestLayoutUpdate = { [weak self] animated in
self?.requestLayoutUpdate?(animated)
}
}
}
} else {
if let itemNode = itemNode, itemNode.frame != itemFrame {
transition.updateFrame(node: itemNode, frame: itemFrame)
itemNode.updateLayout(size: itemFrame.size, transition: transition)
}
}
if let itemNode = itemNode as? InstantPageDetailsNode {
itemNode.updateVisibleItems(visibleBounds: visibleBounds.offsetBy(dx: -itemNode.frame.minX, dy: -itemNode.frame.minY), animated: animated)
}
}
}
topNode = topTileNode
var tileIndex = -1
for tile in self.currentLayoutTiles {
tileIndex += 1
let tileFrame = effectiveFrameForTile(tile)
var tileVisibleFrame = tileFrame
tileVisibleFrame.origin.y -= 400.0
tileVisibleFrame.size.height += 400.0 * 2.0
if tileVisibleFrame.intersects(visibleBounds) {
visibleTileIndices.insert(tileIndex)
if self.visibleTiles[tileIndex] == nil {
let tileNode = InstantPageTileNode(tile: tile, backgroundColor: self.inOverlayPanel ? self.theme.overlayPanelColor : self.theme.pageBackgroundColor)
tileNode.frame = tileFrame
if let topNode = topNode {
self.insertSubnode(tileNode, aboveSubnode: topNode)
} else {
self.insertSubnode(tileNode, at: 0)
}
topNode = tileNode
self.visibleTiles[tileIndex] = tileNode
} else {
if visibleTiles[tileIndex]!.frame != tileFrame {
transition.updateFrame(node: self.visibleTiles[tileIndex]!, frame: tileFrame)
}
}
}
}
var removeTileIndices: [Int] = []
for (index, tileNode) in self.visibleTiles {
if !visibleTileIndices.contains(index) {
removeTileIndices.append(index)
tileNode.removeFromSupernode()
}
}
for index in removeTileIndices {
self.visibleTiles.removeValue(forKey: index)
}
var removeItemIndices: [Int] = []
for (index, itemNode) in self.visibleItemsWithNodes {
if !visibleItemIndices.contains(index) {
removeItemIndices.append(index)
itemNode.removeFromSupernode()
} else {
var itemFrame = itemNode.frame
let itemThreshold: CGFloat = 200.0
itemFrame.origin.y -= itemThreshold
itemFrame.size.height += itemThreshold * 2.0
itemNode.updateIsVisible(visibleBounds.intersects(itemFrame))
}
}
for index in removeItemIndices {
self.visibleItemsWithNodes.removeValue(forKey: index)
}
}
private func updateWebEmbedHeight(_ index: Int, _ height: CGFloat) {
// let currentHeight = self.currentWebEmbedHeights[index]
// if height != currentHeight {
// if let currentHeight = currentHeight, currentHeight > height {
// return
// }
// self.currentWebEmbedHeights[index] = height
//
// let signal: Signal<Void, NoError> = (.complete() |> delay(0.08, queue: Queue.mainQueue()))
// self.updateLayoutDisposable.set(signal.start(completed: { [weak self] in
// if let strongSelf = self {
// strongSelf.updateLayout()
// strongSelf.updateVisibleItems()
// }
// }))
// }
}
func updateDetailsExpanded(_ index: Int, _ expanded: Bool, animated: Bool = true, requestLayout: Bool = true) {
if var currentExpandedDetails = self.currentExpandedDetails {
currentExpandedDetails[index] = expanded
self.currentExpandedDetails = currentExpandedDetails
}
self.requestLayoutUpdate?(animated)
}
func transitionNode(media: InstantPageMedia) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? {
for (_, itemNode) in self.visibleItemsWithNodes {
if let transitionNode = itemNode.transitionNode(media: media) {
return transitionNode
}
}
return nil
}
func updateHiddenMedia(media: InstantPageMedia?) {
for (_, itemNode) in self.visibleItemsWithNodes {
itemNode.updateHiddenMedia(media: media)
}
}
func scrollableContentOffset(item: InstantPageScrollableItem) -> CGPoint {
var contentOffset = CGPoint()
for (_, itemNode) in self.visibleItemsWithNodes {
if let itemNode = itemNode as? InstantPageScrollableNode, itemNode.item === item {
contentOffset = itemNode.contentOffset
break
}
}
return contentOffset
}
func nodeForDetailsItem(_ item: InstantPageDetailsItem) -> InstantPageDetailsNode? {
for (_, itemNode) in self.visibleItemsWithNodes {
if let detailsNode = itemNode as? InstantPageDetailsNode, detailsNode.item === item {
return detailsNode
}
}
return nil
}
private func effectiveSizeForDetails(_ item: InstantPageDetailsItem) -> CGSize {
if let node = nodeForDetailsItem(item) {
return CGSize(width: item.frame.width, height: node.effectiveContentSize.height + item.titleHeight)
} else {
return item.frame.size
}
}
private func effectiveFrameForTile(_ tile: InstantPageTile) -> CGRect {
let layoutOrigin = tile.frame.origin
var origin = layoutOrigin
for item in self.currentDetailsItems {
let expanded = self.currentExpandedDetails?[item.index] ?? item.initiallyExpanded
if layoutOrigin.y >= item.frame.maxY {
let height = expanded ? self.effectiveSizeForDetails(item).height : item.titleHeight
origin.y += height - item.frame.height
}
}
return CGRect(origin: origin, size: tile.frame.size)
}
func effectiveFrameForItem(_ item: InstantPageItem) -> CGRect {
let layoutOrigin = item.frame.origin
var origin = layoutOrigin
for item in self.currentDetailsItems {
let expanded = self.currentExpandedDetails?[item.index] ?? item.initiallyExpanded
if layoutOrigin.y >= item.frame.maxY {
let height = expanded ? self.effectiveSizeForDetails(item).height : item.titleHeight
origin.y += height - item.frame.height
}
}
if let item = item as? InstantPageDetailsItem {
let expanded = self.currentExpandedDetails?[item.index] ?? item.initiallyExpanded
let height = expanded ? self.effectiveSizeForDetails(item).height : item.titleHeight
return CGRect(origin: origin, size: CGSize(width: item.frame.width, height: height))
} else {
return CGRect(origin: origin, size: item.frame.size)
}
}
func textItemAtLocation(_ location: CGPoint) -> (InstantPageTextItem, CGPoint)? {
for item in self.currentLayout.items {
let itemFrame = self.effectiveFrameForItem(item)
if itemFrame.contains(location) {
if let item = item as? InstantPageTextItem, item.selectable {
return (item, CGPoint(x: itemFrame.minX - item.frame.minX, y: itemFrame.minY - item.frame.minY))
} else if let item = item as? InstantPageScrollableItem {
let contentOffset = scrollableContentOffset(item: item)
if let (textItem, parentOffset) = item.textItemAtLocation(location.offsetBy(dx: -itemFrame.minX + contentOffset.x, dy: -itemFrame.minY)) {
return (textItem, itemFrame.origin.offsetBy(dx: parentOffset.x - contentOffset.x, dy: parentOffset.y))
}
} else if let item = item as? InstantPageDetailsItem {
for (_, itemNode) in self.visibleItemsWithNodes {
if let itemNode = itemNode as? InstantPageDetailsNode, itemNode.item === item {
if let (textItem, parentOffset) = itemNode.textItemAtLocation(location.offsetBy(dx: -itemFrame.minX, dy: -itemFrame.minY)) {
return (textItem, itemFrame.origin.offsetBy(dx: parentOffset.x, dy: parentOffset.y))
}
}
}
}
}
}
return nil
}
func tapActionAtPoint(_ point: CGPoint) -> TapLongTapOrDoubleTapGestureRecognizerAction {
for item in self.currentLayout.items {
let frame = self.effectiveFrameForItem(item)
if frame.contains(point) {
if item is InstantPagePeerReferenceItem {
return .fail
} else if item is InstantPageAudioItem {
return .fail
} else if item is InstantPageArticleItem {
return .fail
} else if item is InstantPageFeedbackItem {
return .fail
} else if let item = item as? InstantPageDetailsItem {
for (_, itemNode) in self.visibleItemsWithNodes {
if let itemNode = itemNode as? InstantPageDetailsNode, itemNode.item === item {
return itemNode.tapActionAtPoint(point.offsetBy(dx: -itemNode.frame.minX, dy: -itemNode.frame.minY))
}
}
}
break
}
}
return .waitForSingleTap
}
}
@@ -0,0 +1,641 @@
import Foundation
import UIKit
import AsyncDisplayKit
import TelegramCore
import Display
import TelegramPresentationData
import TelegramUIPreferences
import AccountContext
import ContextUI
private struct TableSide: OptionSet {
var rawValue: Int32 = 0
static let top = TableSide(rawValue: 1 << 0)
static let left = TableSide(rawValue: 1 << 1)
static let right = TableSide(rawValue: 1 << 2)
static let bottom = TableSide(rawValue: 1 << 3)
var uiRectCorner: UIRectCorner {
var corners: UIRectCorner = []
if self.contains(.top) && self.contains(.left) {
corners.insert(.topLeft)
}
if self.contains(.top) && self.contains(.right) {
corners.insert(.topRight)
}
if self.contains(.bottom) && self.contains(.left) {
corners.insert(.bottomLeft)
}
if self.contains(.bottom) && self.contains(.right) {
corners.insert(.bottomRight)
}
return corners
}
}
private extension TableHorizontalAlignment {
var textAlignment: NSTextAlignment {
switch self {
case .left:
return .left
case .center:
return .center
case .right:
return .right
}
}
}
private struct TableCellPosition {
let row: Int
let column: Int
}
private struct InstantPageTableCellItem {
let position: TableCellPosition
let cell: InstantPageTableCell
let frame: CGRect
let filled: Bool
let textItem: InstantPageTextItem?
let additionalItems: [InstantPageItem]
let adjacentSides: TableSide
func withRowHeight(_ height: CGFloat) -> InstantPageTableCellItem {
var frame = self.frame
frame = CGRect(x: frame.minX, y: frame.minY, width: frame.width, height: height)
return InstantPageTableCellItem(position: position, cell: self.cell, frame: frame, filled: self.filled, textItem: self.textItem, additionalItems: self.additionalItems, adjacentSides: self.adjacentSides)
}
func withRTL(_ totalWidth: CGFloat) -> InstantPageTableCellItem {
var frame = self.frame
frame = CGRect(x: totalWidth - frame.minX - frame.width, y: frame.minY, width: frame.width, height: frame.height)
var adjacentSides = self.adjacentSides
if adjacentSides.contains(.left) && !adjacentSides.contains(.right) {
adjacentSides.remove(.left)
adjacentSides.insert(.right)
}
else if adjacentSides.contains(.right) && !adjacentSides.contains(.left) {
adjacentSides.remove(.right)
adjacentSides.insert(.left)
}
return InstantPageTableCellItem(position: position, cell: self.cell, frame: frame, filled: self.filled, textItem: self.textItem, additionalItems: self.additionalItems, adjacentSides: adjacentSides)
}
var verticalAlignment: TableVerticalAlignment {
return self.cell.verticalAlignment
}
var colspan: Int {
return self.cell.colspan > 1 ? Int(clamping: self.cell.colspan) : 1
}
var rowspan: Int {
return self.cell.rowspan > 1 ? Int(clamping: self.cell.rowspan) : 1
}
}
private let tableCellInsets = UIEdgeInsets(top: 14.0, left: 12.0, bottom: 14.0, right: 12.0)
private let tableBorderWidth: CGFloat = 1.0
private let tableCornerRadius: CGFloat = 5.0
public final class InstantPageTableItem: InstantPageScrollableItem {
public var frame: CGRect
let totalWidth: CGFloat
public let horizontalInset: CGFloat
public let medias: [InstantPageMedia] = []
public let wantsNode: Bool = true
public let separatesTiles: Bool = false
let theme: InstantPageTheme
public let isRTL: Bool
fileprivate let cells: [InstantPageTableCellItem]
private let borderWidth: CGFloat
public let anchors: [String: (CGFloat, Bool)]
fileprivate init(frame: CGRect, totalWidth: CGFloat, horizontalInset: CGFloat, borderWidth: CGFloat, theme: InstantPageTheme, cells: [InstantPageTableCellItem], rtl: Bool) {
self.frame = frame
self.totalWidth = totalWidth
self.horizontalInset = horizontalInset
self.borderWidth = borderWidth
self.theme = theme
self.cells = cells
self.isRTL = rtl
var anchors: [String: (CGFloat, Bool)] = [:]
for cell in cells {
if let textItem = cell.textItem {
for (anchor, (lineIndex, empty)) in textItem.anchors {
if anchors[anchor] == nil {
let textItemFrame = textItem.frame.offsetBy(dx: cell.frame.minX, dy: cell.frame.minY)
let offset = textItemFrame.minY + textItem.lines[lineIndex].frame.minY
anchors[anchor] = (offset, empty)
}
}
}
}
self.anchors = anchors
}
public var contentSize: CGSize {
return CGSize(width: self.totalWidth, height: self.frame.height)
}
public func drawInTile(context: CGContext) {
for cell in self.cells {
if cell.cell.text == nil {
continue
}
context.saveGState()
context.translateBy(x: cell.frame.minX, y: cell.frame.minY)
let hasBorder = self.borderWidth > 0.0
let bounds = CGRect(origin: CGPoint(), size: cell.frame.size)
var path: UIBezierPath?
if !cell.adjacentSides.isEmpty {
path = UIBezierPath(roundedRect: bounds, byRoundingCorners: cell.adjacentSides.uiRectCorner, cornerRadii: CGSize(width: tableCornerRadius, height: tableCornerRadius))
}
if cell.filled {
context.setFillColor(self.theme.tableHeaderColor.cgColor)
}
if self.borderWidth > 0.0 {
context.setStrokeColor(self.theme.tableBorderColor.cgColor)
context.setLineWidth(borderWidth)
}
if let path = path {
context.addPath(path.cgPath)
var drawMode: CGPathDrawingMode?
switch (cell.filled, hasBorder) {
case (true, false):
drawMode = .fill
case (true, true):
drawMode = .fillStroke
case (false, true):
drawMode = .stroke
default:
break
}
if let drawMode = drawMode {
context.drawPath(using: drawMode)
}
} else {
if cell.filled {
context.fill(bounds)
}
if hasBorder {
context.stroke(bounds)
}
}
if let textItem = cell.textItem {
textItem.drawInTile(context: context)
}
context.restoreGState()
}
}
public func matchesAnchor(_ anchor: String) -> Bool {
return false
}
public func node(context: AccountContext, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, theme: InstantPageTheme, sourceLocation: InstantPageSourceLocation, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, openPeer: @escaping (EnginePeer) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void, updateWebEmbedHeight: @escaping (CGFloat) -> Void, updateDetailsExpanded: @escaping (Bool) -> Void, currentExpandedDetails: [Int : Bool]?, getPreloadedResource: @escaping (String) -> Data?) -> InstantPageNode? {
var additionalNodes: [InstantPageNode] = []
for cell in self.cells {
for item in cell.additionalItems {
if item.wantsNode {
if let node = item.node(context: context, strings: strings, nameDisplayOrder: nameDisplayOrder, theme: theme, sourceLocation: sourceLocation, openMedia: { _ in }, longPressMedia: { _ in }, activatePinchPreview: nil, pinchPreviewFinished: nil, openPeer: { _ in }, openUrl: { _ in}, updateWebEmbedHeight: { _ in }, updateDetailsExpanded: { _ in }, currentExpandedDetails: nil, getPreloadedResource: getPreloadedResource) {
node.frame = item.frame.offsetBy(dx: cell.frame.minX, dy: cell.frame.minY)
additionalNodes.append(node)
}
}
}
}
return InstantPageScrollableNode(item: self, additionalNodes: additionalNodes)
}
public func matchesNode(_ node: InstantPageNode) -> Bool {
if let node = node as? InstantPageScrollableNode {
return node.item === self
}
return false
}
public func distanceThresholdGroup() -> Int? {
return nil
}
public func distanceThresholdWithGroupCount(_ count: Int) -> CGFloat {
return 0.0
}
public func linkSelectionRects(at point: CGPoint) -> [CGRect] {
for cell in self.cells {
if let item = cell.textItem, item.selectable, item.frame.insetBy(dx: -tableCellInsets.left, dy: -tableCellInsets.top).contains(point.offsetBy(dx: -cell.frame.minX - self.horizontalInset, dy: -cell.frame.minY)) {
let rects = item.linkSelectionRects(at: point.offsetBy(dx: -cell.frame.minX - self.horizontalInset - item.frame.minX, dy: -cell.frame.minY - item.frame.minY))
return rects.map { $0.offsetBy(dx: cell.frame.minX + item.frame.minX + self.horizontalInset, dy: cell.frame.minY + item.frame.minY) }
}
}
return []
}
public func textItemAtLocation(_ location: CGPoint) -> (InstantPageTextItem, CGPoint)? {
for cell in self.cells {
if let item = cell.textItem, item.selectable, item.frame.insetBy(dx: -tableCellInsets.left, dy: -tableCellInsets.top).contains(location.offsetBy(dx: -cell.frame.minX - self.horizontalInset, dy: -cell.frame.minY)) {
return (item, cell.frame.origin.offsetBy(dx: self.horizontalInset, dy: 0.0))
}
}
return nil
}
}
private struct TableRow {
var minColumnWidths: [Int : CGFloat]
var maxColumnWidths: [Int : CGFloat]
}
private func offsetForHorizontalAlignment(_ alignment: TableHorizontalAlignment, width: CGFloat, boundingWidth: CGFloat, insets: UIEdgeInsets) -> CGFloat {
switch alignment {
case .left:
return insets.left
case .center:
return (boundingWidth - width) / 2.0
case .right:
return boundingWidth - width - insets.right
}
}
private func offestForVerticalAlignment(_ verticalAlignment: TableVerticalAlignment, height: CGFloat, boundingHeight: CGFloat, insets: UIEdgeInsets) -> CGFloat {
switch verticalAlignment {
case .top:
return insets.top
case .middle:
return (boundingHeight - height) / 2.0
case .bottom:
return boundingHeight - height - insets.bottom
}
}
func layoutTableItem(rtl: Bool, rows: [InstantPageTableRow], styleStack: InstantPageTextStyleStack, theme: InstantPageTheme, bordered: Bool, striped: Bool, boundingWidth: CGFloat, horizontalInset: CGFloat, media: [EngineMedia.Id: EngineMedia], webpage: TelegramMediaWebpage) -> InstantPageTableItem {
if rows.count == 0 {
return InstantPageTableItem(frame: CGRect(), totalWidth: 0.0, horizontalInset: 0.0, borderWidth: 0.0, theme: theme, cells: [], rtl: rtl)
}
let borderWidth = bordered ? tableBorderWidth : 0.0
let totalCellPadding = tableCellInsets.left + tableCellInsets.right
let cellWidthLimit = boundingWidth - totalCellPadding
var tableRows: [TableRow] = []
var columnCount: Int = 0
var columnSpans: [Range<Int> : (CGFloat, CGFloat)] = [:]
var rowSpans: [Int : [(Int, Int)]] = [:]
var r: Int = 0
for row in rows {
var minColumnWidths: [Int : CGFloat] = [:]
var maxColumnWidths: [Int : CGFloat] = [:]
var i: Int = 0
for cell in row.cells {
if let rowSpan = rowSpans[r] {
for columnAndSpan in rowSpan {
if columnAndSpan.0 == i {
i += columnAndSpan.1
} else {
break
}
}
}
var minCellWidth: CGFloat = 1.0
var maxCellWidth: CGFloat = 1.0
if let text = cell.text {
if let shortestTextItem = layoutTextItemWithString(attributedStringForRichText(text, styleStack: styleStack, boundingWidth: cellWidthLimit - totalCellPadding), boundingWidth: cellWidthLimit, offset: CGPoint(), media: media, webpage: webpage, minimizeWidth: true).0 {
minCellWidth = shortestTextItem.effectiveWidth() + totalCellPadding
}
if let longestTextItem = layoutTextItemWithString(attributedStringForRichText(text, styleStack: styleStack, boundingWidth: cellWidthLimit - totalCellPadding), boundingWidth: cellWidthLimit, offset: CGPoint(), media: media, webpage: webpage).0 {
maxCellWidth = max(minCellWidth, longestTextItem.effectiveWidth() + totalCellPadding)
}
}
if cell.colspan > 1 {
minColumnWidths[i] = 1.0
maxColumnWidths[i] = 1.0
let spanRange = i ..< i + Int(cell.colspan)
if let (minSpanWidth, maxSpanWidth) = columnSpans[spanRange] {
columnSpans[spanRange] = (max(minSpanWidth, minCellWidth), max(maxSpanWidth, maxCellWidth))
} else {
columnSpans[spanRange] = (minCellWidth, maxCellWidth)
}
} else {
minColumnWidths[i] = minCellWidth
maxColumnWidths[i] = maxCellWidth
}
let colspan = cell.colspan > 1 ? Int(clamping: cell.colspan) : 1
if cell.rowspan > 1 {
for j in r ..< r + Int(cell.rowspan) {
if rowSpans[j] == nil {
rowSpans[j] = [(i, colspan)]
} else {
rowSpans[j]!.append((i, colspan))
}
}
}
i += colspan
}
tableRows.append(TableRow(minColumnWidths: minColumnWidths, maxColumnWidths: maxColumnWidths))
columnCount = max(columnCount, row.cells.count)
r += 1
}
let maxContentWidth = boundingWidth - borderWidth
var availableWidth = maxContentWidth
var minColumnWidths: [Int : CGFloat] = [:]
var maxColumnWidths: [Int : CGFloat] = [:]
var maxTotalWidth: CGFloat = 0.0
for i in 0 ..< columnCount {
var minWidth: CGFloat = 1.0
var maxWidth: CGFloat = 1.0
for row in tableRows {
if let columnWidth = row.minColumnWidths[i] {
minWidth = max(minWidth, columnWidth)
}
if let columnWidth = row.maxColumnWidths[i] {
maxWidth = max(maxWidth, columnWidth)
}
}
minColumnWidths[i] = minWidth
maxColumnWidths[i] = maxWidth
availableWidth -= minWidth
maxTotalWidth += maxWidth
}
for (range, span) in columnSpans {
let (minSpanWidth, maxSpanWidth) = span
var minWidth: CGFloat = 0.0
var maxWidth: CGFloat = 0.0
for i in range {
if let columnWidth = minColumnWidths[i] {
minWidth += columnWidth
}
if let columnWidth = maxColumnWidths[i] {
maxWidth += columnWidth
}
}
if minWidth < minSpanWidth {
let delta = minSpanWidth - minWidth
for i in range {
if let columnWidth = minColumnWidths[i] {
let growth = floor(delta / CGFloat(range.count))
minColumnWidths[i] = columnWidth + growth
availableWidth -= growth
}
}
}
if maxWidth < maxSpanWidth {
let delta = maxSpanWidth - maxWidth
for i in range {
if let columnWidth = maxColumnWidths[i] {
let growth = round(delta / CGFloat(range.count))
maxColumnWidths[i] = columnWidth + growth
maxTotalWidth += growth
}
}
}
}
var totalWidth = maxTotalWidth
var finalColumnWidths: [Int : CGFloat] = [:]
let widthToDistribute: CGFloat
if availableWidth > 0 {
widthToDistribute = availableWidth
finalColumnWidths = minColumnWidths
} else {
widthToDistribute = maxContentWidth - maxTotalWidth
finalColumnWidths = maxColumnWidths
}
if widthToDistribute > 0.0 {
var distributedWidth = widthToDistribute
for i in 0 ..< finalColumnWidths.count {
var width = finalColumnWidths[i]!
let maxWidth = maxColumnWidths[i]!
let growth = min(round(widthToDistribute * maxWidth / maxTotalWidth), distributedWidth)
width += growth
distributedWidth -= growth
finalColumnWidths[i] = width
}
totalWidth = boundingWidth
} else {
totalWidth += borderWidth
}
var finalizedCells: [InstantPageTableCellItem] = []
var origin: CGPoint = CGPoint(x: borderWidth / 2.0, y: borderWidth / 2.0)
var totalHeight: CGFloat = 0.0
var rowHeights: [Int : CGFloat] = [:]
var awaitingSpanCells: [Int : [(Int, InstantPageTableCellItem)]] = [:]
for i in 0 ..< rows.count {
let row = rows[i]
var maxRowHeight: CGFloat = 0.0
var isEmptyRow = true
origin.x = borderWidth / 2.0
var k: Int = 0
var rowCells: [InstantPageTableCellItem] = []
for cell in row.cells {
if let cells = awaitingSpanCells[i] {
isEmptyRow = false
for colAndCell in cells {
let cell = colAndCell.1
if cell.position.column == k {
for j in 0 ..< cell.colspan {
if let width = finalColumnWidths[k + j] {
origin.x += width
}
}
k += cell.colspan
} else {
break
}
}
}
var cellWidth: CGFloat = 0.0
let colspan: Int = cell.colspan > 1 ? Int(clamping: cell.colspan) : 1
let rowspan: Int = cell.rowspan > 1 ? Int(clamping: cell.rowspan) : 1
for j in 0 ..< colspan {
if let width = finalColumnWidths[k + j] {
cellWidth += width
}
}
var item: InstantPageTextItem?
var additionalItems: [InstantPageItem] = []
var cellHeight: CGFloat?
if let text = cell.text {
let (textItem, items, _) = layoutTextItemWithString(attributedStringForRichText(text, styleStack: styleStack, boundingWidth: cellWidth - totalCellPadding), boundingWidth: cellWidth - totalCellPadding, alignment: cell.alignment.textAlignment, offset: CGPoint(), media: media, webpage: webpage)
if let textItem = textItem {
isEmptyRow = false
textItem.frame = textItem.frame.offsetBy(dx: tableCellInsets.left, dy: 0.0)
cellHeight = ceil(textItem.frame.height) + tableCellInsets.top + tableCellInsets.bottom
item = textItem
}
for var item in items where !(item is InstantPageTextItem) {
isEmptyRow = false
if textItem == nil {
let offset = offsetForHorizontalAlignment(cell.alignment, width: item.frame.width, boundingWidth: cellWidth, insets: tableCellInsets)
item.frame = item.frame.offsetBy(dx: offset, dy: 0.0)
} else {
item.frame = item.frame.offsetBy(dx: tableCellInsets.left, dy: 0.0)
}
let height = ceil(item.frame.height) + tableCellInsets.top + tableCellInsets.bottom - 10.0
if let currentCellHeight = cellHeight {
cellHeight = max(currentCellHeight, height)
} else {
cellHeight = height
}
additionalItems.append(item)
}
}
var filled = cell.header
if !filled && striped {
filled = i % 2 == 0
}
var adjacentSides: TableSide = []
if i == 0 {
adjacentSides.insert(.top)
}
if i == rows.count - 1 {
adjacentSides.insert(.bottom)
}
if k == 0 {
adjacentSides.insert(.left)
}
if k + colspan == columnCount {
adjacentSides.insert(.right)
}
let rowCell = InstantPageTableCellItem(position: TableCellPosition(row: i, column: k), cell: cell, frame: CGRect(x: origin.x, y: origin.y, width: cellWidth, height: cellHeight ?? 20.0), filled: filled, textItem: item, additionalItems: additionalItems, adjacentSides: adjacentSides)
if rowspan == 1 {
rowCells.append(rowCell)
if let cellHeight = cellHeight {
maxRowHeight = max(maxRowHeight, cellHeight)
}
} else {
for j in i ..< i + rowspan {
if awaitingSpanCells[j] == nil {
awaitingSpanCells[j] = [(k, rowCell)]
} else {
awaitingSpanCells[j]!.append((k, rowCell))
}
}
}
k += colspan
origin.x += cellWidth
}
let finalizeCell: (InstantPageTableCellItem, inout [InstantPageTableCellItem], CGFloat) -> Void = { cell, cells, height in
let updatedCell = cell.withRowHeight(height)
if let textItem = updatedCell.textItem {
let offset = offestForVerticalAlignment(cell.verticalAlignment, height: textItem.frame.height, boundingHeight: height, insets: tableCellInsets)
updatedCell.textItem!.frame = textItem.frame.offsetBy(dx: 0.0, dy: offset)
for var item in updatedCell.additionalItems {
item.frame = item.frame.offsetBy(dx: 0.0, dy: offset)
}
} else {
for var item in updatedCell.additionalItems {
let offset = offestForVerticalAlignment(cell.verticalAlignment, height: item.frame.height, boundingHeight: height, insets: tableCellInsets)
item.frame = item.frame.offsetBy(dx: 0.0, dy: offset)
}
}
cells.append(updatedCell)
}
if !isEmptyRow {
rowHeights[i] = maxRowHeight
} else {
rowHeights[i] = 0.0
maxRowHeight = 0.0
}
var completedSpans = [Int : Set<Int>]()
if let cells = awaitingSpanCells[i] {
for colAndCell in cells {
let cell = colAndCell.1
let utmostRow = cell.position.row + cell.rowspan - 1
if rowHeights[utmostRow] == nil {
continue
}
var cellHeight: CGFloat = 0.0
for k in cell.position.row ..< utmostRow + 1 {
if let height = rowHeights[k] {
cellHeight += height
}
if completedSpans[k] == nil {
var set = Set<Int>()
set.insert(colAndCell.0)
completedSpans[k] = set
} else {
completedSpans[k]!.insert(colAndCell.0)
}
}
if cell.frame.height > cellHeight {
let delta = cell.frame.height - cellHeight
cellHeight = cell.frame.height
maxRowHeight += delta
rowHeights[i] = maxRowHeight
}
finalizeCell(cell, &finalizedCells, cellHeight)
}
}
for cell in rowCells {
finalizeCell(cell, &finalizedCells, maxRowHeight)
}
if !completedSpans.isEmpty {
awaitingSpanCells = awaitingSpanCells.reduce([Int : [(Int, InstantPageTableCellItem)]]()) { (current, rowAndValue) in
var result = current
let cells = rowAndValue.value.filter({ column, cell in
if let completedSpansInRow = completedSpans[rowAndValue.key] {
return !completedSpansInRow.contains(column)
} else {
return true
}
})
if !cells.isEmpty {
result[rowAndValue.key] = cells
}
return result
}
}
if !isEmptyRow {
totalHeight += maxRowHeight
origin.y += maxRowHeight
}
}
totalHeight += borderWidth
if rtl {
finalizedCells = finalizedCells.map { $0.withRTL(totalWidth) }
}
return InstantPageTableItem(frame: CGRect(x: 0.0, y: 0.0, width: boundingWidth + horizontalInset * 2.0, height: totalHeight), totalWidth: totalWidth, horizontalInset: horizontalInset, borderWidth: borderWidth, theme: theme, cells: finalizedCells, rtl: rtl)
}
@@ -0,0 +1,905 @@
import Foundation
import UIKit
import TelegramCore
import Display
import AsyncDisplayKit
import TelegramPresentationData
import TelegramUIPreferences
import TextFormat
import AccountContext
import ContextUI
public final class InstantPageUrlItem: Equatable {
public let url: String
public let webpageId: EngineMedia.Id?
public init(url: String, webpageId: EngineMedia.Id?) {
self.url = url
self.webpageId = webpageId
}
public static func ==(lhs: InstantPageUrlItem, rhs: InstantPageUrlItem) -> Bool {
return lhs.url == rhs.url && lhs.webpageId == rhs.webpageId
}
}
struct InstantPageTextMarkedItem {
let frame: CGRect
let color: UIColor
}
struct InstantPageTextStrikethroughItem {
let frame: CGRect
}
struct InstantPageTextImageItem {
let frame: CGRect
let range: NSRange
let id: EngineMedia.Id
}
public struct InstantPageTextAnchorItem {
public let name: String
public let anchorText: NSAttributedString?
public let empty: Bool
}
public struct InstantPageTextRangeRectEdge: Equatable {
public var x: CGFloat
public var y: CGFloat
public var height: CGFloat
public init(x: CGFloat, y: CGFloat, height: CGFloat) {
self.x = x
self.y = y
self.height = height
}
}
public final class InstantPageTextLine {
let line: CTLine
let range: NSRange
public let frame: CGRect
let strikethroughItems: [InstantPageTextStrikethroughItem]
let markedItems: [InstantPageTextMarkedItem]
let imageItems: [InstantPageTextImageItem]
public let anchorItems: [InstantPageTextAnchorItem]
let isRTL: Bool
init(line: CTLine, range: NSRange, frame: CGRect, strikethroughItems: [InstantPageTextStrikethroughItem], markedItems: [InstantPageTextMarkedItem], imageItems: [InstantPageTextImageItem], anchorItems: [InstantPageTextAnchorItem], isRTL: Bool) {
self.line = line
self.range = range
self.frame = frame
self.strikethroughItems = strikethroughItems
self.markedItems = markedItems
self.imageItems = imageItems
self.anchorItems = anchorItems
self.isRTL = isRTL
}
}
private func frameForLine(_ line: InstantPageTextLine, boundingWidth: CGFloat, alignment: NSTextAlignment) -> CGRect {
var lineFrame = line.frame
if alignment == .center {
lineFrame.origin.x = floor((boundingWidth - lineFrame.size.width) / 2.0)
} else if alignment == .right || (alignment == .natural && line.isRTL) {
lineFrame.origin.x = boundingWidth - lineFrame.size.width
}
return lineFrame
}
public final class InstantPageTextItem: InstantPageItem {
let attributedString: NSAttributedString
public let lines: [InstantPageTextLine]
let rtlLineIndices: Set<Int>
public var frame: CGRect
let alignment: NSTextAlignment
let opaqueBackground: Bool
public let medias: [InstantPageMedia] = []
public let anchors: [String: (Int, Bool)]
public let wantsNode: Bool = false
public let separatesTiles: Bool = false
public var selectable: Bool = true
var containsRTL: Bool {
return !self.rtlLineIndices.isEmpty
}
init(frame: CGRect, attributedString: NSAttributedString, alignment: NSTextAlignment, opaqueBackground: Bool, lines: [InstantPageTextLine]) {
self.attributedString = attributedString
self.alignment = alignment
self.frame = frame
self.opaqueBackground = opaqueBackground
self.lines = lines
var index = 0
var rtlLineIndices = Set<Int>()
var anchors: [String: (Int, Bool)] = [:]
for line in lines {
if line.isRTL {
rtlLineIndices.insert(index)
}
for anchor in line.anchorItems {
anchors[anchor.name] = (index, anchor.empty)
}
index += 1
}
self.rtlLineIndices = rtlLineIndices
self.anchors = anchors
}
public func drawInTile(context: CGContext) {
context.saveGState()
context.textMatrix = CGAffineTransform(scaleX: 1.0, y: -1.0)
context.translateBy(x: self.frame.minX, y: self.frame.minY)
let clipRect = context.boundingBoxOfClipPath
let upperOriginBound = clipRect.minY - 10.0
let lowerOriginBound = clipRect.maxY + 10.0
let boundsWidth = self.frame.size.width
for i in 0 ..< self.lines.count {
let line = self.lines[i]
let lineFrame = frameForLine(line, boundingWidth: boundsWidth, alignment: self.alignment)
if lineFrame.maxY < upperOriginBound || lineFrame.minY > lowerOriginBound {
continue
}
let lineOrigin = lineFrame.origin
context.textPosition = CGPoint(x: lineOrigin.x, y: lineOrigin.y + lineFrame.size.height)
if !line.markedItems.isEmpty {
context.saveGState()
for item in line.markedItems {
let itemFrame = item.frame.offsetBy(dx: lineFrame.minX, dy: 0.0)
context.setFillColor(item.color.cgColor)
let height = floor(item.frame.size.height * 2.2)
let rect = CGRect(x: itemFrame.minX - 2.0, y: floor(itemFrame.minY + (itemFrame.height - height) / 2.0), width: itemFrame.width + 4.0, height: height)
let path = UIBezierPath.init(roundedRect: rect, cornerRadius: 3.0)
context.addPath(path.cgPath)
context.fillPath()
}
context.restoreGState()
}
if self.opaqueBackground {
context.setBlendMode(.normal)
}
let glyphRuns = CTLineGetGlyphRuns(line.line) as NSArray
if glyphRuns.count != 0 {
for run in glyphRuns {
let run = run as! CTRun
let glyphCount = CTRunGetGlyphCount(run)
CTRunDraw(run, context, CFRangeMake(0, glyphCount))
}
}
if self.opaqueBackground {
context.setBlendMode(.copy)
}
if !line.strikethroughItems.isEmpty {
for item in line.strikethroughItems {
let itemFrame = item.frame.offsetBy(dx: lineFrame.minX, dy: 0.0)
context.fill(CGRect(x: itemFrame.minX, y: itemFrame.minY + floor((lineFrame.size.height / 2.0) + 1.0), width: itemFrame.size.width, height: 1.0))
}
}
}
context.restoreGState()
}
func attributesAtPoint(_ point: CGPoint) -> (Int, [NSAttributedString.Key: Any])? {
let transformedPoint = CGPoint(x: point.x, y: point.y)
let boundsWidth = self.frame.width
for i in 0 ..< self.lines.count {
let line = self.lines[i]
let lineFrame = frameForLine(line, boundingWidth: boundsWidth, alignment: self.alignment)
if lineFrame.insetBy(dx: -5.0, dy: -5.0).contains(transformedPoint) {
var index = CTLineGetStringIndexForPosition(line.line, CGPoint(x: transformedPoint.x - lineFrame.minX, y: transformedPoint.y - lineFrame.minY))
if index == self.attributedString.length {
index -= 1
} else if index != 0 {
var glyphStart: CGFloat = 0.0
CTLineGetOffsetForStringIndex(line.line, index, &glyphStart)
if transformedPoint.x < glyphStart {
index -= 1
}
}
if index >= 0 && index < self.attributedString.length {
return (index, self.attributedString.attributes(at: index, effectiveRange: nil))
}
break
}
}
return nil
}
private func attributeRects(name: NSAttributedString.Key, at index: Int) -> [CGRect]? {
var range = NSRange()
let _ = self.attributedString.attribute(name, at: index, effectiveRange: &range)
if range.length != 0 {
let boundsWidth = self.frame.width
var rects: [CGRect] = []
for i in 0 ..< self.lines.count {
let line = self.lines[i]
let lineRange = NSIntersectionRange(range, line.range)
if lineRange.length != 0 {
var leftOffset: CGFloat = 0.0
if lineRange.location != line.range.location || line.isRTL {
leftOffset = floor(CTLineGetOffsetForStringIndex(line.line, lineRange.location, nil))
}
var rightOffset: CGFloat = line.frame.width
if lineRange.location + lineRange.length != line.range.length || line.isRTL {
rightOffset = ceil(CTLineGetOffsetForStringIndex(line.line, lineRange.location + lineRange.length, nil))
}
let lineFrame = frameForLine(line, boundingWidth: boundsWidth, alignment: self.alignment)
let width = abs(rightOffset - leftOffset)
if width > 1.0 {
rects.append(CGRect(origin: CGPoint(x: lineFrame.minX + (leftOffset < rightOffset ? leftOffset : rightOffset), y: lineFrame.minY), size: CGSize(width: width, height: lineFrame.size.height)))
}
}
}
if !rects.isEmpty {
return rects
}
}
return nil
}
public func linkSelectionRects(at point: CGPoint) -> [CGRect] {
if let (index, dict) = self.attributesAtPoint(point) {
if let _ = dict[NSAttributedString.Key(rawValue: TelegramTextAttributes.URL)] {
if let rects = self.attributeRects(name: NSAttributedString.Key(rawValue: TelegramTextAttributes.URL), at: index) {
return rects.compactMap { rect in
if rect.width > 5.0 {
return rect.insetBy(dx: 0.0, dy: -3.0)
} else {
return nil
}
}
}
}
}
return []
}
public func urlAttribute(at point: CGPoint) -> InstantPageUrlItem? {
if let (_, dict) = self.attributesAtPoint(point) {
if let url = dict[NSAttributedString.Key(rawValue: TelegramTextAttributes.URL)] as? InstantPageUrlItem {
return url
}
}
return nil
}
func rangeRects(in range: NSRange) -> (rects: [CGRect], start: InstantPageTextRangeRectEdge?, end: InstantPageTextRangeRectEdge?)? {
guard range.length != 0 else {
return nil
}
let boundsWidth = self.frame.width
var rects: [(CGRect, CGRect)] = []
var startEdge: InstantPageTextRangeRectEdge?
var endEdge: InstantPageTextRangeRectEdge?
for i in 0 ..< self.lines.count {
let line = self.lines[i]
let lineRange = NSIntersectionRange(range, line.range)
if lineRange.length != 0 {
var leftOffset: CGFloat = 0.0
if lineRange.location != line.range.location || line.isRTL {
leftOffset = floor(CTLineGetOffsetForStringIndex(line.line, lineRange.location, nil))
}
var rightOffset: CGFloat = line.frame.width
if lineRange.location + lineRange.length != line.range.upperBound || line.isRTL {
var secondaryOffset: CGFloat = 0.0
let rawOffset = CTLineGetOffsetForStringIndex(line.line, lineRange.location + lineRange.length, &secondaryOffset)
rightOffset = ceil(rawOffset)
if !rawOffset.isEqual(to: secondaryOffset) {
rightOffset = ceil(secondaryOffset)
}
}
var lineFrame = line.frame
for imageItem in line.imageItems {
if imageItem.frame.minY < lineFrame.minY {
let delta = lineFrame.minY - imageItem.frame.minY - 2.0
lineFrame = CGRect(x: lineFrame.minX, y: lineFrame.minY - delta, width: lineFrame.width, height: lineFrame.height + delta)
}
if imageItem.frame.maxY > lineFrame.maxY {
let delta = imageItem.frame.maxY - lineFrame.maxY - 2.0
lineFrame = CGRect(x: lineFrame.minX, y: lineFrame.minY, width: lineFrame.width, height: lineFrame.height + delta)
}
}
lineFrame = lineFrame.insetBy(dx: 0.0, dy: -4.0)
if self.alignment == .center {
lineFrame.origin.x = floor((boundsWidth - lineFrame.size.width) / 2.0)
} else if self.alignment == .right {
lineFrame.origin.x = boundsWidth - lineFrame.size.width
} else if self.alignment == .natural && self.rtlLineIndices.contains(i) {
lineFrame.origin.x = boundsWidth - lineFrame.size.width
}
let width = max(0.0, abs(rightOffset - leftOffset))
if line.range.contains(range.lowerBound) {
let offsetX = floor(CTLineGetOffsetForStringIndex(line.line, range.lowerBound, nil))
startEdge = InstantPageTextRangeRectEdge(x: lineFrame.minX + offsetX, y: lineFrame.minY, height: lineFrame.height)
}
if line.range.contains(range.upperBound - 1) {
let offsetX: CGFloat
if line.range.upperBound == range.upperBound {
offsetX = lineFrame.maxX
} else {
var secondaryOffset: CGFloat = 0.0
let primaryOffset = floor(CTLineGetOffsetForStringIndex(line.line, range.upperBound - 1, &secondaryOffset))
secondaryOffset = floor(secondaryOffset)
let nextOffet = floor(CTLineGetOffsetForStringIndex(line.line, range.upperBound, &secondaryOffset))
if primaryOffset != secondaryOffset {
offsetX = secondaryOffset
} else {
offsetX = nextOffet
}
}
endEdge = InstantPageTextRangeRectEdge(x: lineFrame.minX + offsetX, y: lineFrame.minY, height: lineFrame.height)
}
rects.append((lineFrame, CGRect(origin: CGPoint(x: lineFrame.minX + min(leftOffset, rightOffset), y: lineFrame.minY), size: CGSize(width: width, height: lineFrame.size.height))))
}
}
if !rects.isEmpty, let startEdge = startEdge, let endEdge = endEdge {
return (rects.map { $1 }, startEdge, endEdge)
}
return nil
}
public func lineRects() -> [CGRect] {
let boundsWidth = self.frame.width
var rects: [CGRect] = []
var topLeft = CGPoint(x: CGFloat.greatestFiniteMagnitude, y: 0.0)
var bottomRight = CGPoint()
var lastLineFrame: CGRect?
for i in 0 ..< self.lines.count {
let line = self.lines[i]
var lineFrame = line.frame
for imageItem in line.imageItems {
if imageItem.frame.minY < lineFrame.minY {
let delta = lineFrame.minY - imageItem.frame.minY - 2.0
lineFrame = CGRect(x: lineFrame.minX, y: lineFrame.minY - delta, width: lineFrame.width, height: lineFrame.height + delta)
}
if imageItem.frame.maxY > lineFrame.maxY {
let delta = imageItem.frame.maxY - lineFrame.maxY - 2.0
lineFrame = CGRect(x: lineFrame.minX, y: lineFrame.minY, width: lineFrame.width, height: lineFrame.height + delta)
}
}
lineFrame = lineFrame.insetBy(dx: 0.0, dy: -4.0)
if self.alignment == .center {
lineFrame.origin.x = floor((boundsWidth - lineFrame.size.width) / 2.0)
} else if self.alignment == .right {
lineFrame.origin.x = boundsWidth - lineFrame.size.width
} else if self.alignment == .natural && self.rtlLineIndices.contains(i) {
lineFrame.origin.x = boundsWidth - lineFrame.size.width
}
if lineFrame.minX < topLeft.x {
topLeft = CGPoint(x: lineFrame.minX, y: topLeft.y)
}
if lineFrame.maxX > bottomRight.x {
bottomRight = CGPoint(x: lineFrame.maxX, y: bottomRight.y)
}
if self.lines.count > 1 && i == self.lines.count - 1 {
lastLineFrame = lineFrame
} else {
if lineFrame.minY < topLeft.y {
topLeft = CGPoint(x: topLeft.x, y: lineFrame.minY)
}
if lineFrame.maxY > bottomRight.y {
bottomRight = CGPoint(x: bottomRight.x, y: lineFrame.maxY)
}
}
}
rects.append(CGRect(x: topLeft.x, y: topLeft.y, width: bottomRight.x - topLeft.x, height: bottomRight.y - topLeft.y))
if self.lines.count > 1, var lastLineFrame = lastLineFrame {
let delta = lastLineFrame.minY - bottomRight.y
lastLineFrame = CGRect(x: lastLineFrame.minX, y: bottomRight.y, width: lastLineFrame.width, height: lastLineFrame.height + delta)
rects.append(lastLineFrame)
}
return rects
}
func effectiveWidth() -> CGFloat {
var width: CGFloat = 0.0
for line in self.lines {
width = max(width, line.frame.width)
}
return ceil(width)
}
public func plainText() -> String {
if let first = self.lines.first, let last = self.lines.last {
return self.attributedString.attributedSubstring(from: NSMakeRange(first.range.location, last.range.location + last.range.length - first.range.location)).string
}
return ""
}
public func matchesAnchor(_ anchor: String) -> Bool {
return false
}
public func node(context: AccountContext, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, theme: InstantPageTheme, sourceLocation: InstantPageSourceLocation, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, openPeer: @escaping (EnginePeer) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void, updateWebEmbedHeight: @escaping (CGFloat) -> Void, updateDetailsExpanded: @escaping (Bool) -> Void, currentExpandedDetails: [Int : Bool]?, getPreloadedResource: @escaping (String) -> Data?) -> InstantPageNode? {
return nil
}
public func matchesNode(_ node: InstantPageNode) -> Bool {
return false
}
public func distanceThresholdGroup() -> Int? {
return nil
}
public func distanceThresholdWithGroupCount(_ count: Int) -> CGFloat {
return 0.0
}
}
final class InstantPageScrollableTextItem: InstantPageScrollableItem {
var frame: CGRect
let totalWidth: CGFloat
let horizontalInset: CGFloat
let medias: [InstantPageMedia] = []
let wantsNode: Bool = true
let separatesTiles: Bool = false
let item: InstantPageTextItem
let additionalItems: [InstantPageItem]
let isRTL: Bool
fileprivate init(frame: CGRect, item: InstantPageTextItem, additionalItems: [InstantPageItem], totalWidth: CGFloat, horizontalInset: CGFloat, rtl: Bool) {
self.frame = frame
self.item = item
self.additionalItems = additionalItems
self.totalWidth = totalWidth
self.horizontalInset = horizontalInset
self.isRTL = rtl
}
var contentSize: CGSize {
return CGSize(width: self.totalWidth, height: self.frame.height)
}
func drawInTile(context: CGContext) {
context.saveGState()
context.translateBy(x: self.item.frame.minX, y: self.item.frame.minY)
self.item.drawInTile(context: context)
context.restoreGState()
}
func node(context: AccountContext, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, theme: InstantPageTheme, sourceLocation: InstantPageSourceLocation, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, openPeer: @escaping (EnginePeer) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void, updateWebEmbedHeight: @escaping (CGFloat) -> Void, updateDetailsExpanded: @escaping (Bool) -> Void, currentExpandedDetails: [Int : Bool]?, getPreloadedResource: @escaping (String) -> Data?) -> InstantPageNode? {
var additionalNodes: [InstantPageNode] = []
for item in additionalItems {
if item.wantsNode {
if let node = item.node(context: context, strings: strings, nameDisplayOrder: nameDisplayOrder, theme: theme, sourceLocation: sourceLocation, openMedia: { _ in }, longPressMedia: { _ in }, activatePinchPreview: nil, pinchPreviewFinished: nil, openPeer: { _ in }, openUrl: { _ in}, updateWebEmbedHeight: { _ in }, updateDetailsExpanded: { _ in }, currentExpandedDetails: nil, getPreloadedResource: getPreloadedResource) {
node.frame = item.frame
additionalNodes.append(node)
}
}
}
return InstantPageScrollableNode(item: self, additionalNodes: additionalNodes)
}
func matchesAnchor(_ anchor: String) -> Bool {
return self.item.matchesAnchor(anchor)
}
func matchesNode(_ node: InstantPageNode) -> Bool {
if let node = node as? InstantPageScrollableNode {
return node.item === self
}
return false
}
func distanceThresholdGroup() -> Int? {
return nil
}
func distanceThresholdWithGroupCount(_ count: Int) -> CGFloat {
return 0.0
}
func linkSelectionRects(at point: CGPoint) -> [CGRect] {
let rects = self.item.linkSelectionRects(at: point.offsetBy(dx: -self.item.frame.minX - self.horizontalInset, dy: -self.item.frame.minY))
return rects.map { $0.offsetBy(dx: self.item.frame.minX + self.horizontalInset, dy: -self.item.frame.minY) }
}
func textItemAtLocation(_ location: CGPoint) -> (InstantPageTextItem, CGPoint)? {
if self.item.selectable, self.item.frame.contains(location.offsetBy(dx: -self.item.frame.minX - self.horizontalInset, dy: -self.item.frame.minY)) {
return (item, self.item.frame.origin.offsetBy(dx: self.horizontalInset, dy: -self.item.frame.minY))
}
return nil
}
}
func attributedStringForRichText(_ text: RichText, styleStack: InstantPageTextStyleStack, url: InstantPageUrlItem? = nil, boundingWidth: CGFloat? = nil) -> NSAttributedString {
switch text {
case .empty:
return NSAttributedString(string: "", attributes: styleStack.textAttributes())
case let .plain(string):
var attributes = styleStack.textAttributes()
if let url = url {
attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.URL)] = url
}
return NSAttributedString(string: string, attributes: attributes)
case let .bold(text):
styleStack.push(.bold)
let result = attributedStringForRichText(text, styleStack: styleStack, url: url)
styleStack.pop()
return result
case let .italic(text):
styleStack.push(.italic)
let result = attributedStringForRichText(text, styleStack: styleStack, url: url)
styleStack.pop()
return result
case let .underline(text):
styleStack.push(.underline)
let result = attributedStringForRichText(text, styleStack: styleStack, url: url)
styleStack.pop()
return result
case let .strikethrough(text):
styleStack.push(.strikethrough)
let result = attributedStringForRichText(text, styleStack: styleStack, url: url)
styleStack.pop()
return result
case let .fixed(text):
styleStack.push(.fontFixed(true))
let result = attributedStringForRichText(text, styleStack: styleStack, url: url)
styleStack.pop()
return result
case let .url(text, url, webpageId):
styleStack.push(.link(webpageId != nil))
let result = attributedStringForRichText(text, styleStack: styleStack, url: InstantPageUrlItem(url: url, webpageId: webpageId))
styleStack.pop()
return result
case let .email(text, email):
styleStack.push(.bold)
styleStack.push(.underline)
let result = attributedStringForRichText(text, styleStack: styleStack, url: InstantPageUrlItem(url: "mailto:\(email)", webpageId: nil))
styleStack.pop()
styleStack.pop()
return result
case let .concat(texts):
let string = NSMutableAttributedString()
for text in texts {
let substring = attributedStringForRichText(text, styleStack: styleStack, url: url, boundingWidth: boundingWidth)
string.append(substring)
}
return string
case let .subscript(text):
styleStack.push(.subscript)
let result = attributedStringForRichText(text, styleStack: styleStack, url: url)
styleStack.pop()
return result
case let .superscript(text):
styleStack.push(.superscript)
let result = attributedStringForRichText(text, styleStack: styleStack, url: url)
styleStack.pop()
return result
case let .marked(text):
styleStack.push(.marker)
let result = attributedStringForRichText(text, styleStack: styleStack, url: url)
styleStack.pop()
return result
case let .phone(text, phone):
styleStack.push(.bold)
styleStack.push(.underline)
let result = attributedStringForRichText(text, styleStack: styleStack, url: InstantPageUrlItem(url: "tel:\(phone)", webpageId: nil))
styleStack.pop()
styleStack.pop()
return result
case let .image(id, dimensions):
struct RunStruct {
let ascent: CGFloat
let descent: CGFloat
let width: CGFloat
}
var dimensions = dimensions
if let boundingWidth = boundingWidth {
dimensions = PixelDimensions(dimensions.cgSize.fittedToWidthOrSmaller(boundingWidth))
}
let extentBuffer = UnsafeMutablePointer<RunStruct>.allocate(capacity: 1)
extentBuffer.initialize(to: RunStruct(ascent: 0.0, descent: 0.0, width: dimensions.cgSize.width))
var callbacks = CTRunDelegateCallbacks(version: kCTRunDelegateVersion1, dealloc: { (pointer) in
}, getAscent: { (pointer) -> CGFloat in
let d = pointer.assumingMemoryBound(to: RunStruct.self)
return d.pointee.ascent
}, getDescent: { (pointer) -> CGFloat in
let d = pointer.assumingMemoryBound(to: RunStruct.self)
return d.pointee.descent
}, getWidth: { (pointer) -> CGFloat in
let d = pointer.assumingMemoryBound(to: RunStruct.self)
return d.pointee.width
})
let delegate = CTRunDelegateCreate(&callbacks, extentBuffer)
let attrDictionaryDelegate = [(kCTRunDelegateAttributeName as NSAttributedString.Key): (delegate as Any), NSAttributedString.Key(rawValue: InstantPageMediaIdAttribute): id.id, NSAttributedString.Key(rawValue: InstantPageMediaDimensionsAttribute): dimensions]
let mutableAttributedString = attributedStringForRichText(.plain(" "), styleStack: styleStack, url: url).mutableCopy() as! NSMutableAttributedString
mutableAttributedString.addAttributes(attrDictionaryDelegate, range: NSMakeRange(0, mutableAttributedString.length))
return mutableAttributedString
case let .anchor(text, name):
var empty = false
var text = text
if case .empty = text {
empty = true
text = .plain("\u{200b}")
}
let anchorText = !empty ? attributedStringForRichText(text, styleStack: styleStack, url: url) : nil
styleStack.push(.anchor(name, anchorText, empty))
let result = attributedStringForRichText(text, styleStack: styleStack, url: url)
styleStack.pop()
return result
}
}
func layoutTextItemWithString(_ string: NSAttributedString, boundingWidth: CGFloat, horizontalInset: CGFloat = 0.0, alignment: NSTextAlignment = .natural, offset: CGPoint, media: [EngineMedia.Id: EngineMedia] = [:], webpage: TelegramMediaWebpage? = nil, minimizeWidth: Bool = false, maxNumberOfLines: Int = 0, opaqueBackground: Bool = false) -> (InstantPageTextItem?, [InstantPageItem], CGSize) {
if string.length == 0 {
return (nil, [], CGSize())
}
var lines: [InstantPageTextLine] = []
var imageItems: [InstantPageTextImageItem] = []
var font = string.attribute(NSAttributedString.Key.font, at: 0, effectiveRange: nil) as? UIFont
if font == nil {
let range = NSMakeRange(0, string.length)
string.enumerateAttributes(in: range, options: []) { attributes, range, _ in
if font == nil, let furtherFont = attributes[NSAttributedString.Key.font] as? UIFont {
font = furtherFont
}
}
}
let image = string.attribute(NSAttributedString.Key.init(rawValue: InstantPageMediaIdAttribute), at: 0, effectiveRange: nil)
guard font != nil || image != nil else {
return (nil, [], CGSize())
}
var lineSpacingFactor: CGFloat = 1.12
if let lineSpacingFactorAttribute = string.attribute(NSAttributedString.Key(rawValue: InstantPageLineSpacingFactorAttribute), at: 0, effectiveRange: nil) {
lineSpacingFactor = CGFloat((lineSpacingFactorAttribute as! NSNumber).floatValue)
}
let typesetter = CTTypesetterCreateWithAttributedString(string)
let fontAscent = font?.ascender ?? 0.0
let fontDescent = font?.descender ?? 0.0
let fontLineHeight = floor(fontAscent + fontDescent)
let fontLineSpacing = floor(fontLineHeight * lineSpacingFactor)
var lastIndex: CFIndex = 0
var currentLineOrigin = CGPoint()
var hasAnchors = false
var maxLineWidth: CGFloat = 0.0
var maxImageHeight: CGFloat = 0.0
var extraDescent: CGFloat = 0.0
let text = string.string
var indexOffset: CFIndex?
while true {
var workingLineOrigin = currentLineOrigin
let currentMaxWidth = boundingWidth - workingLineOrigin.x
var lineCharacterCount: CFIndex
var hadIndexOffset = false
if minimizeWidth {
var count = 0
for ch in text.suffix(text.count - lastIndex) {
count += 1
if ch == " " || ch == "\n" || ch == "\t" {
break
}
}
lineCharacterCount = count
} else {
let suggestedLineBreak = CTTypesetterSuggestLineBreak(typesetter, lastIndex, Double(currentMaxWidth))
if let offset = indexOffset {
lineCharacterCount = suggestedLineBreak + offset
if lineCharacterCount <= 0 {
lineCharacterCount = suggestedLineBreak
}
indexOffset = nil
hadIndexOffset = true
} else {
lineCharacterCount = suggestedLineBreak
}
}
if lineCharacterCount > 0 {
var line = CTTypesetterCreateLineWithOffset(typesetter, CFRangeMake(lastIndex, lineCharacterCount), 100.0)
var lineWidth = CGFloat(CTLineGetTypographicBounds(line, nil, nil, nil))
let lineRange = NSMakeRange(lastIndex, lineCharacterCount)
let substring = string.attributedSubstring(from: lineRange).string
var stop = false
if maxNumberOfLines > 0 && lines.count == maxNumberOfLines - 1 && lastIndex + lineCharacterCount < string.length {
let attributes = string.attributes(at: lastIndex + lineCharacterCount - 1, effectiveRange: nil)
if let truncateString = CFAttributedStringCreate(nil, "\u{2026}" as CFString, attributes as CFDictionary) {
let truncateToken = CTLineCreateWithAttributedString(truncateString)
let tokenWidth = CGFloat(CTLineGetTypographicBounds(truncateToken, nil, nil, nil) + 3.0)
if let truncatedLine = CTLineCreateTruncatedLine(line, Double(lineWidth - tokenWidth), .end, truncateToken) {
lineWidth += tokenWidth
line = truncatedLine
}
}
stop = true
}
let hadExtraDescent = extraDescent > 0.0
extraDescent = 0.0
var lineImageItems: [InstantPageTextImageItem] = []
var isRTL = false
if let glyphRuns = CTLineGetGlyphRuns(line) as? [CTRun], !glyphRuns.isEmpty {
if let run = glyphRuns.first, CTRunGetStatus(run).contains(CTRunStatus.rightToLeft) {
isRTL = true
}
var appliedLineOffset: CGFloat = 0.0
for run in glyphRuns {
let cfRunRange = CTRunGetStringRange(run)
let runRange = NSMakeRange(cfRunRange.location == kCFNotFound ? NSNotFound : cfRunRange.location, cfRunRange.length)
string.enumerateAttributes(in: runRange, options: []) { attributes, range, _ in
if let id = attributes[NSAttributedString.Key.init(rawValue: InstantPageMediaIdAttribute)] as? Int64, let dimensions = attributes[NSAttributedString.Key.init(rawValue: InstantPageMediaDimensionsAttribute)] as? PixelDimensions {
var imageFrame = CGRect(origin: CGPoint(), size: dimensions.cgSize.fitted(CGSize(width: boundingWidth, height: boundingWidth)))
let xOffset = CTLineGetOffsetForStringIndex(line, CTRunGetStringRange(run).location, nil)
let yOffset = fontLineHeight.isZero ? 0.0 : floorToScreenPixels((fontLineHeight - imageFrame.size.height) / 2.0)
imageFrame.origin = imageFrame.origin.offsetBy(dx: workingLineOrigin.x + xOffset, dy: workingLineOrigin.y + yOffset)
let minSpacing = fontLineSpacing - 4.0
let delta = workingLineOrigin.y - minSpacing - imageFrame.minY - appliedLineOffset
if !fontAscent.isZero && delta > 0.0 {
workingLineOrigin.y += delta
appliedLineOffset += delta
imageFrame.origin = imageFrame.origin.offsetBy(dx: 0.0, dy: delta)
}
if !fontLineHeight.isZero {
extraDescent = max(extraDescent, imageFrame.maxY - (workingLineOrigin.y + fontLineHeight + minSpacing))
}
maxImageHeight = max(maxImageHeight, imageFrame.height)
lineImageItems.append(InstantPageTextImageItem(frame: imageFrame, range: range, id: EngineMedia.Id(namespace: Namespaces.Media.CloudFile, id: id)))
}
}
}
}
if substring.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && lineImageItems.count > 0 {
extraDescent += max(6.0, fontLineSpacing / 2.0)
}
if !minimizeWidth && !hadIndexOffset && lineCharacterCount > 1 && lineWidth > currentMaxWidth + 5.0, let imageItem = lineImageItems.last {
indexOffset = -(lastIndex + lineCharacterCount - imageItem.range.lowerBound)
continue
}
var strikethroughItems: [InstantPageTextStrikethroughItem] = []
var markedItems: [InstantPageTextMarkedItem] = []
var anchorItems: [InstantPageTextAnchorItem] = []
string.enumerateAttributes(in: lineRange, options: []) { attributes, range, _ in
if let _ = attributes[NSAttributedString.Key.strikethroughStyle] {
let lowerX = floor(CTLineGetOffsetForStringIndex(line, range.location, nil))
let upperX = ceil(CTLineGetOffsetForStringIndex(line, range.location + range.length, nil))
let x = lowerX < upperX ? lowerX : upperX
strikethroughItems.append(InstantPageTextStrikethroughItem(frame: CGRect(x: workingLineOrigin.x + x, y: workingLineOrigin.y, width: abs(upperX - lowerX), height: fontLineHeight)))
}
if let color = attributes[NSAttributedString.Key.init(rawValue: InstantPageMarkerColorAttribute)] as? UIColor {
var lineHeight = fontLineHeight
var delta: CGFloat = 0.0
if let offset = attributes[NSAttributedString.Key.baselineOffset] as? CGFloat {
lineHeight = floorToScreenPixels(lineHeight * 0.85)
delta = offset * 0.6
}
let lowerX = floor(CTLineGetOffsetForStringIndex(line, range.location, nil))
let upperX = ceil(CTLineGetOffsetForStringIndex(line, range.location + range.length, nil))
let x = lowerX < upperX ? lowerX : upperX
markedItems.append(InstantPageTextMarkedItem(frame: CGRect(x: workingLineOrigin.x + x, y: workingLineOrigin.y + delta, width: abs(upperX - lowerX), height: lineHeight), color: color))
}
if let item = attributes[NSAttributedString.Key.init(rawValue: InstantPageAnchorAttribute)] as? Dictionary<String, Any>, let name = item["name"] as? String, let empty = item["empty"] as? Bool {
anchorItems.append(InstantPageTextAnchorItem(name: name, anchorText: item["text"] as? NSAttributedString, empty: empty))
}
}
if !anchorItems.isEmpty {
hasAnchors = true
}
if hadExtraDescent && extraDescent > 0 {
workingLineOrigin.y += fontLineSpacing
}
let height = !fontLineHeight.isZero ? fontLineHeight : maxImageHeight
let textLine = InstantPageTextLine(line: line, range: lineRange, frame: CGRect(x: workingLineOrigin.x, y: workingLineOrigin.y, width: lineWidth, height: height), strikethroughItems: strikethroughItems, markedItems: markedItems, imageItems: lineImageItems, anchorItems: anchorItems, isRTL: isRTL)
lines.append(textLine)
imageItems.append(contentsOf: lineImageItems)
if lineWidth > maxLineWidth {
maxLineWidth = lineWidth
}
workingLineOrigin.x = 0.0
workingLineOrigin.y += fontLineHeight + fontLineSpacing + extraDescent
currentLineOrigin = workingLineOrigin
lastIndex += lineCharacterCount
if stop {
break
}
} else {
break
}
}
var height: CGFloat = 0.0
if !lines.isEmpty && !(string.string == "\u{200b}" && hasAnchors) {
height = lines.last!.frame.maxY + extraDescent
}
var textWidth = boundingWidth
var requiresScroll = false
if !imageItems.isEmpty && maxLineWidth > boundingWidth + 10.0 {
textWidth = maxLineWidth
requiresScroll = true
}
let textItem = InstantPageTextItem(frame: CGRect(x: 0.0, y: 0.0, width: textWidth, height: height), attributedString: string, alignment: alignment, opaqueBackground: opaqueBackground, lines: lines)
if !requiresScroll {
textItem.frame = textItem.frame.offsetBy(dx: offset.x, dy: offset.y)
}
var items: [InstantPageItem] = []
if !requiresScroll && (imageItems.isEmpty || string.length > 1) {
items.append(textItem)
}
var topInset: CGFloat = 0.0
var bottomInset: CGFloat = 0.0
var additionalItems: [InstantPageItem] = []
if let webpage = webpage {
let offset = requiresScroll ? CGPoint() : offset
for line in textItem.lines {
let lineFrame = frameForLine(line, boundingWidth: boundingWidth, alignment: alignment)
for imageItem in line.imageItems {
if let media = media[imageItem.id] {
let item = InstantPageImageItem(frame: imageItem.frame.offsetBy(dx: lineFrame.minX + offset.x, dy: offset.y), webPage: webpage, media: InstantPageMedia(index: -1, media: media, url: nil, caption: nil, credit: nil), interactive: false, roundCorners: false, fit: false)
additionalItems.append(item)
if item.frame.minY < topInset {
topInset = item.frame.minY
}
if item.frame.maxY > height {
bottomInset = max(bottomInset, item.frame.maxY - height)
}
}
}
}
}
if requiresScroll {
textItem.frame = textItem.frame.offsetBy(dx: 0.0, dy: abs(topInset))
for var item in additionalItems {
item.frame = item.frame.offsetBy(dx: 0.0, dy: abs(topInset))
}
let scrollableItem = InstantPageScrollableTextItem(frame: CGRect(origin: offset, size: CGSize(width: boundingWidth + horizontalInset * 2.0, height: height + abs(topInset) + bottomInset)), item: textItem, additionalItems: additionalItems, totalWidth: textWidth, horizontalInset: horizontalInset, rtl: textItem.containsRTL)
items.append(scrollableItem)
} else {
items.append(contentsOf: additionalItems)
}
return (requiresScroll ? nil : textItem, items, textItem.frame.size)
}
@@ -0,0 +1,522 @@
import Foundation
import UIKit
import UIKit.UIGestureRecognizerSubclass
import AsyncDisplayKit
import Display
import TelegramPresentationData
private func findScrollView(view: UIView?) -> UIScrollView? {
if let view = view {
if let view = view as? UIScrollView {
return view
}
return findScrollView(view: view.superview)
} else {
return nil
}
}
private func cancelScrollViewGestures(view: UIView?) {
if let view = view {
if let gestureRecognizers = view.gestureRecognizers {
for recognizer in gestureRecognizers {
if let recognizer = recognizer as? UIPanGestureRecognizer {
switch recognizer.state {
case .began, .possible:
recognizer.state = .ended
default:
break
}
}
}
}
cancelScrollViewGestures(view: view.superview)
}
}
private func generateKnobImage(color: UIColor, diameter: CGFloat, inverted: Bool = false) -> UIImage? {
let f: (CGSize, CGContext) -> Void = { size, context in
context.clear(CGRect(origin: CGPoint(), size: size))
context.setFillColor(color.cgColor)
context.fill(CGRect(origin: CGPoint(x: (size.width - 2.0) / 2.0, y: size.width / 2.0), size: CGSize(width: 2.0, height: size.height - size.width / 2.0 - 1.0)))
context.fillEllipse(in: CGRect(origin: CGPoint(x: floor((size.width - diameter) / 2.0), y: floor((size.width - diameter) / 2.0)), size: CGSize(width: diameter, height: diameter)))
context.fillEllipse(in: CGRect(origin: CGPoint(x: (size.width - 2.0) / 2.0, y: size.width + 2.0), size: CGSize(width: 2.0, height: 2.0)))
}
let size = CGSize(width: 12.0, height: 12.0 + 2.0 + 2.0)
if inverted {
return generateImage(size, contextGenerator: f)?.stretchableImage(withLeftCapWidth: Int(size.width / 2.0), topCapHeight: Int(size.height) - (Int(size.width) + 1))
} else {
return generateImage(size, rotatedContext: f)?.stretchableImage(withLeftCapWidth: Int(size.width / 2.0), topCapHeight: Int(size.width) + 1)
}
}
public final class InstantPageTextSelectionTheme {
public let selection: UIColor
public let knob: UIColor
public let knobDiameter: CGFloat
public init(selection: UIColor, knob: UIColor, knobDiameter: CGFloat = 12.0) {
self.selection = selection
self.knob = knob
self.knobDiameter = knobDiameter
}
}
private enum Knob {
case left
case right
}
private final class InstantPageTextSelectionGestureRecognizer: UIGestureRecognizer, UIGestureRecognizerDelegate {
private var longTapTimer: Timer?
private var movingKnob: (Knob, CGPoint, CGPoint)?
private var currentLocation: CGPoint?
var beginSelection: ((CGPoint) -> Void)?
var knobAtPoint: ((CGPoint) -> (Knob, CGPoint)?)?
var moveKnob: ((Knob, CGPoint) -> Void)?
var finishedMovingKnob: (() -> Void)?
var clearSelection: (() -> Void)?
override init(target: Any?, action: Selector?) {
super.init(target: nil, action: nil)
self.delegate = self
}
override public func reset() {
super.reset()
self.longTapTimer?.invalidate()
self.longTapTimer = nil
self.movingKnob = nil
self.currentLocation = nil
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
super.touchesBegan(touches, with: event)
let currentLocation = touches.first?.location(in: self.view)
self.currentLocation = currentLocation
if let currentLocation = currentLocation {
if let (knob, knobPosition) = self.knobAtPoint?(currentLocation) {
self.movingKnob = (knob, knobPosition, currentLocation)
cancelScrollViewGestures(view: self.view?.superview)
self.state = .began
} else if self.longTapTimer == nil {
final class TimerTarget: NSObject {
let f: () -> Void
init(_ f: @escaping () -> Void) {
self.f = f
}
@objc func event() {
self.f()
}
}
let longTapTimer = Timer(timeInterval: 0.3, target: TimerTarget({ [weak self] in
self?.longTapEvent()
}), selector: #selector(TimerTarget.event), userInfo: nil, repeats: false)
self.longTapTimer = longTapTimer
RunLoop.main.add(longTapTimer, forMode: .common)
}
}
}
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent) {
super.touchesMoved(touches, with: event)
let currentLocation = touches.first?.location(in: self.view)
self.currentLocation = currentLocation
if let (knob, initialKnobPosition, initialGesturePosition) = self.movingKnob, let currentLocation = currentLocation {
self.moveKnob?(knob, CGPoint(x: initialKnobPosition.x + currentLocation.x - initialGesturePosition.x, y: initialKnobPosition.y + currentLocation.y - initialGesturePosition.y))
}
}
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent) {
super.touchesEnded(touches, with: event)
if let longTapTimer = self.longTapTimer {
self.longTapTimer = nil
longTapTimer.invalidate()
self.clearSelection?()
} else {
if let _ = self.currentLocation, let _ = self.movingKnob {
self.finishedMovingKnob?()
}
}
self.state = .ended
}
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent) {
super.touchesCancelled(touches, with: event)
self.state = .cancelled
}
private func longTapEvent() {
if let currentLocation = self.currentLocation {
self.beginSelection?(currentLocation)
self.state = .ended
}
}
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
return true
}
@available(iOS 9.0, *)
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive press: UIPress) -> Bool {
return true
}
}
public final class InstantPageTextSelectionNodeView: UIView {
var hitTestImpl: ((CGPoint, UIEvent?) -> UIView?)?
override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
return self.hitTestImpl?(point, event)
}
}
public enum InstantPageTextSelectionAction {
case copy
case share
case lookup
}
public struct InstantPageTextSelectionItem {
let item: InstantPageTextItem
let start: Int
let end: Int
var range: NSRange {
return NSRange(location: self.start, length: self.end - self.start)
}
}
public struct InstantPageTextSelection {
let items: [InstantPageTextSelectionItem]
}
final class InstantPageTextSelectionNode: ASDisplayNode {
private let theme: InstantPageTextSelectionTheme
private let strings: PresentationStrings
private let textItemAtLocation: (CGPoint) -> (InstantPageTextItem, CGPoint)?
private let updateIsActive: (Bool) -> Void
private let present: (ViewController, Any?) -> Void
private weak var rootNode: ASDisplayNode?
private let performAction: (String, InstantPageTextSelectionAction) -> Void
private var highlightOverlay: LinkHighlightingNode?
private let leftKnob: ASImageNode
private let rightKnob: ASImageNode
private var currentSelection: InstantPageTextSelection?
private var currentRects: [CGRect]?
public let highlightAreaNode: ASDisplayNode
private var recognizer: InstantPageTextSelectionGestureRecognizer?
private var displayLinkAnimator: DisplayLinkAnimator?
public init(theme: InstantPageTextSelectionTheme, strings: PresentationStrings, textItemAtLocation: @escaping (CGPoint) -> (InstantPageTextItem, CGPoint)?, updateIsActive: @escaping (Bool) -> Void, present: @escaping (ViewController, Any?) -> Void, rootNode: ASDisplayNode, performAction: @escaping (String, InstantPageTextSelectionAction) -> Void) {
self.theme = theme
self.strings = strings
self.textItemAtLocation = textItemAtLocation
self.updateIsActive = updateIsActive
self.present = present
self.rootNode = rootNode
self.performAction = performAction
self.leftKnob = ASImageNode()
self.leftKnob.isUserInteractionEnabled = false
self.leftKnob.image = generateKnobImage(color: theme.knob, diameter: theme.knobDiameter)
self.leftKnob.displaysAsynchronously = false
self.leftKnob.displayWithoutProcessing = true
self.leftKnob.alpha = 0.0
self.rightKnob = ASImageNode()
self.rightKnob.isUserInteractionEnabled = false
self.rightKnob.image = generateKnobImage(color: theme.knob, diameter: theme.knobDiameter, inverted: true)
self.rightKnob.displaysAsynchronously = false
self.rightKnob.displayWithoutProcessing = true
self.rightKnob.alpha = 0.0
self.highlightAreaNode = ASDisplayNode()
super.init()
self.setViewBlock({
return InstantPageTextSelectionNodeView()
})
self.addSubnode(self.leftKnob)
self.addSubnode(self.rightKnob)
}
override public func didLoad() {
super.didLoad()
(self.view as? InstantPageTextSelectionNodeView)?.hitTestImpl = { [weak self] point, event in
return self?.hitTest(point, with: event)
}
let recognizer = InstantPageTextSelectionGestureRecognizer(target: nil, action: nil)
recognizer.knobAtPoint = { [weak self] point in
return self?.knobAtPoint(point)
}
recognizer.moveKnob = { [weak self] knob, point in
guard let strongSelf = self, let currentSelection = strongSelf.currentSelection, let currentItem = currentSelection.items.first else {
return
}
if let (item, parentOffset) = strongSelf.textItemAtLocation(point) {
let mappedPoint = point.offsetBy(dx: -item.frame.minX - parentOffset.x, dy: -item.frame.minY - parentOffset.y)
if let stringIndex = item.attributesAtPoint(mappedPoint)?.0 {
var updatedLeft = currentItem.start
var updatedRight = currentItem.end
switch knob {
case .left:
updatedLeft = stringIndex
case .right:
updatedRight = stringIndex
}
if currentItem.start != updatedLeft || currentItem.end != updatedRight {
strongSelf.currentSelection = InstantPageTextSelection(items: [InstantPageTextSelectionItem(item: item, start: updatedLeft, end: updatedRight)])
strongSelf.updateSelection(selection: strongSelf.currentSelection, animateIn: false)
}
if let scrollView = findScrollView(view: strongSelf.view) {
let scrollPoint = strongSelf.view.convert(point, to: scrollView)
scrollView.scrollRectToVisible(CGRect(origin: CGPoint(x: scrollPoint.x, y: scrollPoint.y - 30.0), size: CGSize(width: 1.0, height: 60.0)), animated: false)
}
}
}
}
recognizer.finishedMovingKnob = { [weak self] in
guard let strongSelf = self else {
return
}
strongSelf.displayMenu()
}
recognizer.beginSelection = { [weak self] point in
guard let strongSelf = self else {
return
}
strongSelf.dismissSelection()
if let (item, parentOffset) = strongSelf.textItemAtLocation(point) {
let mappedPoint = point.offsetBy(dx: -item.frame.minX - parentOffset.x, dy: -item.frame.minY - parentOffset.y)
var resultRange: NSRange?
if let stringIndex = item.attributesAtPoint(mappedPoint)?.0 {
let string = item.attributedString.string as NSString
let inputRange = CFRangeMake(0, string.length)
let flag = UInt(kCFStringTokenizerUnitWord)
let locale = CFLocaleCopyCurrent()
let tokenizer = CFStringTokenizerCreate(kCFAllocatorDefault, string as CFString, inputRange, flag, locale)
var tokenType = CFStringTokenizerAdvanceToNextToken(tokenizer)
while !tokenType.isEmpty {
let currentTokenRange = CFStringTokenizerGetCurrentTokenRange(tokenizer)
if currentTokenRange.location <= stringIndex && currentTokenRange.location + currentTokenRange.length > stringIndex {
resultRange = NSRange(location: currentTokenRange.location, length: currentTokenRange.length)
break
}
tokenType = CFStringTokenizerAdvanceToNextToken(tokenizer)
}
if resultRange == nil {
resultRange = NSRange(location: stringIndex, length: 1)
}
}
strongSelf.currentSelection = resultRange.flatMap {
InstantPageTextSelection(items: [InstantPageTextSelectionItem(item: item, start: $0.lowerBound, end: $0.upperBound)])
}
}
strongSelf.updateSelection(selection: strongSelf.currentSelection, animateIn: true)
strongSelf.displayMenu()
strongSelf.updateIsActive(true)
}
recognizer.clearSelection = { [weak self] in
self?.dismissSelection()
self?.updateIsActive(false)
}
self.recognizer = recognizer
self.view.addGestureRecognizer(recognizer)
}
public func updateLayout() {
if let currentSelection = self.currentSelection {
self.updateSelection(selection: currentSelection, animateIn: false)
}
}
private func updateSelection(selection: InstantPageTextSelection?, animateIn: Bool) {
var rects: (rects: [CGRect], start: InstantPageTextRangeRectEdge, end: InstantPageTextRangeRectEdge)?
if let selection = selection, selection.items.count > 0 {
var selectionRects: [CGRect] = []
var start: InstantPageTextRangeRectEdge?
var end: InstantPageTextRangeRectEdge?
for i in 0 ..< selection.items.count {
let item = selection.items[i]
if let (itemRects, itemStart, itemEnd) = item.item.rangeRects(in: item.range) {
for rect in itemRects {
var rect = rect
rect = rect.insetBy(dx: 0.0, dy: -1.0)
selectionRects.append(rect.offsetBy(dx: item.item.frame.minX, dy: item.item.frame.minY))
}
if let itemStart = itemStart, i == 0 {
start = InstantPageTextRangeRectEdge(x: itemStart.x + item.item.frame.minX, y: itemStart.y + item.item.frame.minY, height: itemStart.height)
}
if let itemEnd = itemEnd, i == selection.items.count - 1 {
end = InstantPageTextRangeRectEdge(x: itemEnd.x + item.item.frame.minX, y: itemEnd.y + item.item.frame.minY, height: itemEnd.height)
}
}
}
if let start = start, let end = end {
rects = (rects: selectionRects, start: start, end: end)
}
}
self.currentRects = rects?.rects
if let (rects, startEdge, endEdge) = rects, !rects.isEmpty {
let highlightOverlay: LinkHighlightingNode
if let current = self.highlightOverlay {
highlightOverlay = current
} else {
highlightOverlay = LinkHighlightingNode(color: self.theme.selection)
highlightOverlay.isUserInteractionEnabled = false
highlightOverlay.innerRadius = 0.0
highlightOverlay.outerRadius = 0.0
highlightOverlay.inset = 1.0
self.highlightOverlay = highlightOverlay
self.highlightAreaNode.addSubnode(highlightOverlay)
}
highlightOverlay.frame = self.bounds
highlightOverlay.updateRects(rects)
if let image = self.leftKnob.image {
self.leftKnob.frame = CGRect(origin: CGPoint(x: floor(startEdge.x - image.size.width / 2.0), y: startEdge.y + 1.0 - 12.0), size: CGSize(width: image.size.width, height: self.theme.knobDiameter + startEdge.height + 2.0))
self.rightKnob.frame = CGRect(origin: CGPoint(x: floor(endEdge.x + 1.0 - image.size.width / 2.0), y: endEdge.y + endEdge.height + 3.0 - (endEdge.height + 2.0)), size: CGSize(width: image.size.width, height: self.theme.knobDiameter + endEdge.height + 2.0))
}
if self.leftKnob.alpha.isZero {
highlightOverlay.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3, timingFunction: CAMediaTimingFunctionName.easeOut.rawValue)
self.leftKnob.alpha = 1.0
self.leftKnob.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.14, delay: 0.19)
self.rightKnob.alpha = 1.0
self.rightKnob.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.14, delay: 0.19)
self.leftKnob.layer.animateSpring(from: 0.5 as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: 0.2, delay: 0.25, initialVelocity: 0.0, damping: 80.0)
self.rightKnob.layer.animateSpring(from: 0.5 as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: 0.2, delay: 0.25, initialVelocity: 0.0, damping: 80.0)
if animateIn {
var result = CGRect()
for rect in rects {
if result.isEmpty {
result = rect
} else {
result = result.union(rect)
}
}
highlightOverlay.layer.animateScale(from: 2.0, to: 1.0, duration: 0.26)
let fromResult = CGRect(origin: CGPoint(x: result.minX - result.width / 2.0, y: result.minY - result.height / 2.0), size: CGSize(width: result.width * 2.0, height: result.height * 2.0))
highlightOverlay.layer.animatePosition(from: CGPoint(x: (-fromResult.midX + highlightOverlay.bounds.midX) / 1.0, y: (-fromResult.midY + highlightOverlay.bounds.midY) / 1.0), to: CGPoint(), duration: 0.26, additive: true)
}
}
} else if let highlightOverlay = self.highlightOverlay {
self.highlightOverlay = nil
highlightOverlay.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.18, removeOnCompletion: false, completion: { [weak highlightOverlay] _ in
highlightOverlay?.removeFromSupernode()
})
self.leftKnob.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.18)
self.leftKnob.alpha = 0.0
self.leftKnob.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.18)
self.rightKnob.alpha = 0.0
self.rightKnob.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.18)
}
}
private func knobAtPoint(_ point: CGPoint) -> (Knob, CGPoint)? {
if !self.leftKnob.alpha.isZero, self.leftKnob.frame.insetBy(dx: -4.0, dy: -8.0).contains(point) {
return (.left, self.leftKnob.frame.offsetBy(dx: 0.0, dy: self.leftKnob.frame.width / 2.0).center)
}
if !self.rightKnob.alpha.isZero, self.rightKnob.frame.insetBy(dx: -4.0, dy: -8.0).contains(point) {
return (.right, self.rightKnob.frame.offsetBy(dx: 0.0, dy: -self.rightKnob.frame.width / 2.0).center)
}
if !self.leftKnob.alpha.isZero, self.leftKnob.frame.insetBy(dx: -14.0, dy: -14.0).contains(point) {
return (.left, self.leftKnob.frame.offsetBy(dx: 0.0, dy: self.leftKnob.frame.width / 2.0).center)
}
if !self.rightKnob.alpha.isZero, self.rightKnob.frame.insetBy(dx: -14.0, dy: -14.0).contains(point) {
return (.right, self.rightKnob.frame.offsetBy(dx: 0.0, dy: -self.rightKnob.frame.width / 2.0).center)
}
return nil
}
private func dismissSelection() {
self.currentSelection = nil
self.updateSelection(selection: nil, animateIn: false)
}
private func displayMenu() {
// guard let currentRects = self.currentRects, !currentRects.isEmpty, let currentRange = self.currentRange, let cachedLayout = self.textNode.cachedLayout, let attributedString = cachedLayout.attributedString else {
// return
// }
// let range = NSRange(location: min(currentRange.0, currentRange.1), length: max(currentRange.0, currentRange.1) - min(currentRange.0, currentRange.1))
// var completeRect = currentRects[0]
// for i in 0 ..< currentRects.count {
// completeRect = completeRect.union(currentRects[i])
// }
// completeRect = completeRect.insetBy(dx: 0.0, dy: -12.0)
//
// let text = (attributedString.string as NSString).substring(with: range)
guard let currentRects = self.currentRects, !currentRects.isEmpty else {
return
}
var completeRect = currentRects[0]
for i in 0 ..< currentRects.count {
completeRect = completeRect.union(currentRects[i])
}
completeRect = completeRect.insetBy(dx: 0.0, dy: -12.0)
let text = "Text"
var actions: [ContextMenuAction] = []
actions.append(ContextMenuAction(content: .text(title: self.strings.Conversation_ContextMenuCopy, accessibilityLabel: self.strings.Conversation_ContextMenuCopy), action: { [weak self] in
self?.performAction(text, .copy)
self?.dismissSelection()
}))
actions.append(ContextMenuAction(content: .text(title: self.strings.Conversation_ContextMenuLookUp, accessibilityLabel: self.strings.Conversation_ContextMenuLookUp), action: { [weak self] in
self?.performAction(text, .lookup)
self?.dismissSelection()
}))
actions.append(ContextMenuAction(content: .text(title: self.strings.Conversation_ContextMenuShare, accessibilityLabel: self.strings.Conversation_ContextMenuShare), action: { [weak self] in
self?.performAction(text, .share)
self?.dismissSelection()
}))
self.present(makeContextMenuController(actions: actions, catchTapsOutside: false, hasHapticFeedback: false), ContextMenuControllerPresentationArguments(sourceNodeAndRect: { [weak self] in
guard let strongSelf = self, let rootNode = strongSelf.rootNode else {
return nil
}
return (strongSelf, completeRect, rootNode, rootNode.bounds)
}, bounce: false))
}
override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
if self.knobAtPoint(point) != nil {
return self.view
}
if self.bounds.contains(point) {
return self.view
}
return nil
}
}
@@ -0,0 +1,224 @@
import Foundation
import UIKit
import TelegramCore
import Display
enum InstantPageTextStyle {
case fontSize(CGFloat)
case lineSpacingFactor(CGFloat)
case fontSerif(Bool)
case fontFixed(Bool)
case bold
case italic
case underline
case strikethrough
case textColor(UIColor)
case `subscript`
case superscript
case markerColor(UIColor)
case marker
case anchor(String, NSAttributedString?, Bool)
case linkColor(UIColor)
case linkMarkerColor(UIColor)
case link(Bool)
}
let InstantPageLineSpacingFactorAttribute = "LineSpacingFactorAttribute"
let InstantPageMarkerColorAttribute = "MarkerColorAttribute"
let InstantPageMediaIdAttribute = "MediaIdAttribute"
let InstantPageMediaDimensionsAttribute = "MediaDimensionsAttribute"
let InstantPageAnchorAttribute = "AnchorAttribute"
final class InstantPageTextStyleStack {
private var items: [InstantPageTextStyle] = []
func push(_ item: InstantPageTextStyle) {
self.items.append(item)
}
func pop() {
if !self.items.isEmpty {
self.items.removeLast()
}
}
func textAttributes() -> [NSAttributedString.Key: Any] {
var fontSize: CGFloat?
var fontSerif: Bool?
var fontFixed: Bool?
var bold: Bool?
var italic: Bool?
var strikethrough: Bool?
var underline: Bool?
var color: UIColor?
var lineSpacingFactor: CGFloat?
var baselineOffset: CGFloat?
var markerColor: UIColor?
var marker: Bool?
var anchor: Dictionary<String, Any>?
var linkColor: UIColor?
var linkMarkerColor: UIColor?
var link: Bool?
for item in self.items.reversed() {
switch item {
case let .fontSize(value):
if fontSize == nil {
fontSize = value
}
case let .fontSerif(value):
if fontSerif == nil {
fontSerif = value
}
case let .fontFixed(value):
if fontFixed == nil {
fontFixed = value
}
case .bold:
if bold == nil {
bold = true
}
case .italic:
if italic == nil {
italic = true
}
case .strikethrough:
if strikethrough == nil {
strikethrough = true
}
case .underline:
if underline == nil {
underline = true
}
case let .textColor(value):
if color == nil {
color = value
}
case let .lineSpacingFactor(value):
if lineSpacingFactor == nil {
lineSpacingFactor = value
}
case .subscript:
if baselineOffset == nil {
baselineOffset = -0.35
underline = false
}
case .superscript:
if baselineOffset == nil {
baselineOffset = 0.35
}
case let .markerColor(color):
if markerColor == nil {
markerColor = color
}
case .marker:
if marker == nil {
marker = true
}
case let .anchor(name, anchorText, empty):
if anchor == nil {
if let anchorText = anchorText {
anchor = ["name": name, "text": anchorText, "empty": empty]
} else {
anchor = ["name": name, "empty": empty]
}
}
case let .linkColor(color):
if linkColor == nil {
linkColor = color
}
case let .linkMarkerColor(color):
if linkMarkerColor == nil {
linkMarkerColor = color
}
case let .link(instant):
if link == nil {
link = instant
}
}
}
var attributes: [NSAttributedString.Key: Any] = [:]
var parsedFontSize: CGFloat
if let fontSize = fontSize {
parsedFontSize = fontSize
} else {
parsedFontSize = 16.0
}
if let baselineOffset = baselineOffset {
attributes[NSAttributedString.Key.baselineOffset] = round(parsedFontSize * baselineOffset);
parsedFontSize = round(parsedFontSize * 0.85)
}
if (bold != nil && bold!) && (italic != nil && italic!) {
if fontSerif != nil && fontSerif! {
attributes[NSAttributedString.Key.font] = UIFont(name: "Georgia-BoldItalic", size: parsedFontSize)
} else if fontFixed != nil && fontFixed! {
attributes[NSAttributedString.Key.font] = UIFont(name: "Menlo-BoldItalic", size: parsedFontSize)
} else {
attributes[NSAttributedString.Key.font] = Font.semiboldItalic(parsedFontSize)
}
} else if bold != nil && bold! {
if fontSerif != nil && fontSerif! {
attributes[NSAttributedString.Key.font] = UIFont(name: "Georgia-Bold", size: parsedFontSize)
} else if fontFixed != nil && fontFixed! {
attributes[NSAttributedString.Key.font] = UIFont(name: "Menlo-Bold", size: parsedFontSize)
} else {
attributes[NSAttributedString.Key.font] = Font.bold(parsedFontSize)
}
} else if italic != nil && italic! {
if fontSerif != nil && fontSerif! {
attributes[NSAttributedString.Key.font] = UIFont(name: "Georgia-Italic", size: parsedFontSize)
} else if fontFixed != nil && fontFixed! {
attributes[NSAttributedString.Key.font] = UIFont(name: "Menlo-Italic", size: parsedFontSize)
} else {
attributes[NSAttributedString.Key.font] = Font.italic(parsedFontSize)
}
} else {
if fontSerif != nil && fontSerif! {
attributes[NSAttributedString.Key.font] = UIFont(name: "Georgia", size: parsedFontSize)
} else if fontFixed != nil && fontFixed! {
attributes[NSAttributedString.Key.font] = UIFont(name: "Menlo", size: parsedFontSize)
} else {
attributes[NSAttributedString.Key.font] = Font.regular(parsedFontSize)
}
}
if strikethrough != nil && strikethrough! {
attributes[NSAttributedString.Key.strikethroughStyle] = NSUnderlineStyle.single.rawValue as NSNumber
}
if underline != nil && underline! {
attributes[NSAttributedString.Key.underlineStyle] = NSUnderlineStyle.single.rawValue as NSNumber
}
if let link = link, let linkColor = linkColor {
attributes[NSAttributedString.Key.foregroundColor] = linkColor
if link, let linkMarkerColor = linkMarkerColor {
attributes[NSAttributedString.Key(rawValue: InstantPageMarkerColorAttribute)] = linkMarkerColor
}
} else {
if let color = color {
attributes[NSAttributedString.Key.foregroundColor] = color
} else {
attributes[NSAttributedString.Key.foregroundColor] = UIColor.black
}
}
if let lineSpacingFactor = lineSpacingFactor {
attributes[NSAttributedString.Key(rawValue: InstantPageLineSpacingFactorAttribute)] = lineSpacingFactor as NSNumber
}
if marker != nil && marker!, let markerColor = markerColor {
attributes[NSAttributedString.Key(rawValue: InstantPageMarkerColorAttribute)] = markerColor
}
if let anchor = anchor {
attributes[NSAttributedString.Key(rawValue: InstantPageAnchorAttribute)] = anchor
}
return attributes
}
}
@@ -0,0 +1,338 @@
import Foundation
import UIKit
import Postbox
import Display
import TelegramPresentationData
import TelegramUIPreferences
enum InstantPageFontStyle {
case sans
case serif
}
struct InstantPageFont {
let style: InstantPageFontStyle
let size: CGFloat
let lineSpacingFactor: CGFloat
}
struct InstantPageTextAttributes {
let font: InstantPageFont
let color: UIColor
let underline: Bool
init(font: InstantPageFont, color: UIColor, underline: Bool = false) {
self.font = font
self.color = color
self.underline = underline
}
func withUnderline(_ underline: Bool) -> InstantPageTextAttributes {
return InstantPageTextAttributes(font: self.font, color: self.color, underline: underline)
}
func withUpdatedFontStyles(sizeMultiplier: CGFloat, forceSerif: Bool) -> InstantPageTextAttributes {
return InstantPageTextAttributes(font: InstantPageFont(style: forceSerif ? .serif : self.font.style, size: floor(self.font.size * sizeMultiplier), lineSpacingFactor: self.font.lineSpacingFactor), color: self.color, underline: self.underline)
}
}
enum InstantPageTextCategoryType {
case kicker
case header
case subheader
case paragraph
case caption
case credit
case table
case article
}
public struct InstantPageTextCategories {
let kicker: InstantPageTextAttributes
let header: InstantPageTextAttributes
let subheader: InstantPageTextAttributes
let paragraph: InstantPageTextAttributes
let caption: InstantPageTextAttributes
let credit: InstantPageTextAttributes
let table: InstantPageTextAttributes
let article: InstantPageTextAttributes
func attributes(type: InstantPageTextCategoryType, link: Bool) -> InstantPageTextAttributes {
switch type {
case .kicker:
return self.kicker.withUnderline(link)
case .header:
return self.header.withUnderline(link)
case .subheader:
return self.subheader.withUnderline(link)
case .paragraph:
return self.paragraph.withUnderline(link)
case .caption:
return self.caption.withUnderline(link)
case .credit:
return self.credit.withUnderline(link)
case .table:
return self.table.withUnderline(link)
case .article:
return self.article.withUnderline(link)
}
}
func withUpdatedFontStyles(sizeMultiplier: CGFloat, forceSerif: Bool) -> InstantPageTextCategories {
return InstantPageTextCategories(kicker: self.kicker.withUpdatedFontStyles(sizeMultiplier: sizeMultiplier, forceSerif: forceSerif), header: self.header.withUpdatedFontStyles(sizeMultiplier: sizeMultiplier, forceSerif: forceSerif), subheader: self.subheader.withUpdatedFontStyles(sizeMultiplier: sizeMultiplier, forceSerif: forceSerif), paragraph: self.paragraph.withUpdatedFontStyles(sizeMultiplier: sizeMultiplier, forceSerif: forceSerif), caption: self.caption.withUpdatedFontStyles(sizeMultiplier: sizeMultiplier, forceSerif: forceSerif), credit: self.credit.withUpdatedFontStyles(sizeMultiplier: sizeMultiplier, forceSerif: forceSerif), table: self.table.withUpdatedFontStyles(sizeMultiplier: sizeMultiplier, forceSerif: forceSerif), article: self.article.withUpdatedFontStyles(sizeMultiplier: sizeMultiplier, forceSerif: forceSerif))
}
}
public final class InstantPageTheme {
public let type: InstantPageThemeType
public let pageBackgroundColor: UIColor
public let textCategories: InstantPageTextCategories
public let serif: Bool
public let codeBlockBackgroundColor: UIColor
public let linkColor: UIColor
public let textHighlightColor: UIColor
public let linkHighlightColor: UIColor
public let markerColor: UIColor
public let panelBackgroundColor: UIColor
public let panelHighlightedBackgroundColor: UIColor
public let panelPrimaryColor: UIColor
public let panelSecondaryColor: UIColor
public let panelAccentColor: UIColor
public let tableBorderColor: UIColor
public let tableHeaderColor: UIColor
public let controlColor: UIColor
public let imageTintColor: UIColor?
public let overlayPanelColor: UIColor
public init(type: InstantPageThemeType, pageBackgroundColor: UIColor, textCategories: InstantPageTextCategories, serif: Bool, codeBlockBackgroundColor: UIColor, linkColor: UIColor, textHighlightColor: UIColor, linkHighlightColor: UIColor, markerColor: UIColor, panelBackgroundColor: UIColor, panelHighlightedBackgroundColor: UIColor, panelPrimaryColor: UIColor, panelSecondaryColor: UIColor, panelAccentColor: UIColor, tableBorderColor: UIColor, tableHeaderColor: UIColor, controlColor: UIColor, imageTintColor: UIColor?, overlayPanelColor: UIColor) {
self.type = type
self.pageBackgroundColor = pageBackgroundColor
self.textCategories = textCategories
self.serif = serif
self.codeBlockBackgroundColor = codeBlockBackgroundColor
self.linkColor = linkColor
self.textHighlightColor = textHighlightColor
self.linkHighlightColor = linkHighlightColor
self.markerColor = markerColor
self.panelBackgroundColor = panelBackgroundColor
self.panelHighlightedBackgroundColor = panelHighlightedBackgroundColor
self.panelPrimaryColor = panelPrimaryColor
self.panelSecondaryColor = panelSecondaryColor
self.panelAccentColor = panelAccentColor
self.tableBorderColor = tableBorderColor
self.tableHeaderColor = tableHeaderColor
self.controlColor = controlColor
self.imageTintColor = imageTintColor
self.overlayPanelColor = overlayPanelColor
}
public func withUpdatedFontStyles(sizeMultiplier: CGFloat, forceSerif: Bool) -> InstantPageTheme {
return InstantPageTheme(type: type, pageBackgroundColor: pageBackgroundColor, textCategories: self.textCategories.withUpdatedFontStyles(sizeMultiplier: sizeMultiplier, forceSerif: forceSerif), serif: forceSerif, codeBlockBackgroundColor: codeBlockBackgroundColor, linkColor: linkColor, textHighlightColor: textHighlightColor, linkHighlightColor: linkHighlightColor, markerColor: markerColor, panelBackgroundColor: panelBackgroundColor, panelHighlightedBackgroundColor: panelHighlightedBackgroundColor, panelPrimaryColor: panelPrimaryColor, panelSecondaryColor: panelSecondaryColor, panelAccentColor: panelAccentColor, tableBorderColor: tableBorderColor, tableHeaderColor: tableHeaderColor, controlColor: controlColor, imageTintColor: imageTintColor, overlayPanelColor: overlayPanelColor)
}
}
private let lightTheme = InstantPageTheme(
type: .light,
pageBackgroundColor: .white,
textCategories: InstantPageTextCategories(
kicker: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 15.0, lineSpacingFactor: 0.685), color: .black),
header: InstantPageTextAttributes(font: InstantPageFont(style: .serif, size: 24.0, lineSpacingFactor: 0.685), color: .black),
subheader: InstantPageTextAttributes(font: InstantPageFont(style: .serif, size: 19.0, lineSpacingFactor: 0.685), color: .black),
paragraph: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 17.0, lineSpacingFactor: 1.0), color: .black),
caption: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 15.0, lineSpacingFactor: 1.0), color: UIColor(rgb: 0x79828b)),
credit: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 13.0, lineSpacingFactor: 1.0), color: UIColor(rgb: 0x79828b)),
table: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 15.0, lineSpacingFactor: 1.0), color: .black),
article: InstantPageTextAttributes(font: InstantPageFont(style: .serif, size: 18.0, lineSpacingFactor: 1.0), color: .black)
),
serif: false,
codeBlockBackgroundColor: UIColor(rgb: 0xf5f8fc),
linkColor: UIColor(rgb: 0x0088ff),
textHighlightColor: UIColor(rgb: 0, alpha: 0.12),
linkHighlightColor: UIColor(rgb: 0x0088ff, alpha: 0.07),
markerColor: UIColor(rgb: 0xfef3bc),
panelBackgroundColor: UIColor(rgb: 0xf3f4f5),
panelHighlightedBackgroundColor: UIColor(rgb: 0xe7e7e7),
panelPrimaryColor: .black,
panelSecondaryColor: UIColor(rgb: 0x79828b),
panelAccentColor: UIColor(rgb: 0x0088ff),
tableBorderColor: UIColor(rgb: 0xe2e2e2),
tableHeaderColor: UIColor(rgb: 0xf4f4f4),
controlColor: UIColor(rgb: 0xc7c7cd),
imageTintColor: nil,
overlayPanelColor: .white
)
private let sepiaTheme = InstantPageTheme(
type: .sepia,
pageBackgroundColor: UIColor(rgb: 0xf8f1e2),
textCategories: InstantPageTextCategories(
kicker: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 15.0, lineSpacingFactor: 0.685), color: UIColor(rgb: 0x4f321d)),
header: InstantPageTextAttributes(font: InstantPageFont(style: .serif, size: 24.0, lineSpacingFactor: 0.685), color: UIColor(rgb: 0x4f321d)),
subheader: InstantPageTextAttributes(font: InstantPageFont(style: .serif, size: 19.0, lineSpacingFactor: 0.685), color: UIColor(rgb: 0x4f321d)),
paragraph: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 17.0, lineSpacingFactor: 1.0), color: UIColor(rgb: 0x4f321d)),
caption: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 15.0, lineSpacingFactor: 1.0), color: UIColor(rgb: 0x927e6b)),
credit: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 13.0, lineSpacingFactor: 1.0), color: UIColor(rgb: 0x927e6b)),
table: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 15.0, lineSpacingFactor: 1.0), color: UIColor(rgb: 0x4f321d)),
article: InstantPageTextAttributes(font: InstantPageFont(style: .serif, size: 18.0, lineSpacingFactor: 1.0), color: UIColor(rgb: 0x4f321d))
),
serif: false,
codeBlockBackgroundColor: UIColor(rgb: 0xefe7d6),
linkColor: UIColor(rgb: 0xd19600),
textHighlightColor: UIColor(rgb: 0, alpha: 0.1),
linkHighlightColor: UIColor(rgb: 0xd19600, alpha: 0.1),
markerColor: UIColor(rgb: 0xe5ddcd),
panelBackgroundColor: UIColor(rgb: 0xefe7d6),
panelHighlightedBackgroundColor: UIColor(rgb: 0xe3dccb),
panelPrimaryColor: .black,
panelSecondaryColor: UIColor(rgb: 0x927e6b),
panelAccentColor: UIColor(rgb: 0xd19601),
tableBorderColor: UIColor(rgb: 0xddd1b8),
tableHeaderColor: UIColor(rgb: 0xf0e7d4),
controlColor: UIColor(rgb: 0xddd1b8),
imageTintColor: nil,
overlayPanelColor: UIColor(rgb: 0xf8f1e2)
)
private let grayTheme = InstantPageTheme(
type: .gray,
pageBackgroundColor: UIColor(rgb: 0x5a5a5c),
textCategories: InstantPageTextCategories(
kicker: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 15.0, lineSpacingFactor: 0.685), color: UIColor(rgb: 0xcecece)),
header: InstantPageTextAttributes(font: InstantPageFont(style: .serif, size: 24.0, lineSpacingFactor: 0.685), color: UIColor(rgb: 0xcecece)),
subheader: InstantPageTextAttributes(font: InstantPageFont(style: .serif, size: 19.0, lineSpacingFactor: 0.685), color: UIColor(rgb: 0xcecece)),
paragraph: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 17.0, lineSpacingFactor: 1.0), color: UIColor(rgb: 0xcecece)),
caption: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 15.0, lineSpacingFactor: 1.0), color: UIColor(rgb: 0xa0a0a0)),
credit: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 13.0, lineSpacingFactor: 1.0), color: UIColor(rgb: 0xa0a0a0)),
table: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 15.0, lineSpacingFactor: 1.0), color: UIColor(rgb: 0xcecece)),
article: InstantPageTextAttributes(font: InstantPageFont(style: .serif, size: 18.0, lineSpacingFactor: 1.0), color: UIColor(rgb: 0xcecece))
),
serif: false,
codeBlockBackgroundColor: UIColor(rgb: 0x555556),
linkColor: UIColor(rgb: 0x5ac8fa),
textHighlightColor: UIColor(rgb: 0, alpha: 0.16),
linkHighlightColor: UIColor(rgb: 0x5ac8fa, alpha: 0.13),
markerColor: UIColor(rgb: 0x4b4b4b),
panelBackgroundColor: UIColor(rgb: 0x555556),
panelHighlightedBackgroundColor: UIColor(rgb: 0x505051),
panelPrimaryColor: UIColor(rgb: 0xcecece),
panelSecondaryColor: UIColor(rgb: 0xa0a0a0),
panelAccentColor: UIColor(rgb: 0x54b9f8),
tableBorderColor: UIColor(rgb: 0x484848),
tableHeaderColor: UIColor(rgb: 0x555556),
controlColor: UIColor(rgb: 0x484848),
imageTintColor: UIColor(rgb: 0xcecece),
overlayPanelColor: UIColor(rgb: 0x5a5a5c)
)
private let darkTheme = InstantPageTheme(
type: .dark,
pageBackgroundColor: UIColor(rgb: 0x000000),
textCategories: InstantPageTextCategories(
kicker: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 15.0, lineSpacingFactor: 0.685), color: UIColor(rgb: 0xb0b0b0)),
header: InstantPageTextAttributes(font: InstantPageFont(style: .serif, size: 24.0, lineSpacingFactor: 0.685), color: UIColor(rgb: 0xb0b0b0)),
subheader: InstantPageTextAttributes(font: InstantPageFont(style: .serif, size: 19.0, lineSpacingFactor: 0.685), color: UIColor(rgb: 0xb0b0b0)),
paragraph: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 17.0, lineSpacingFactor: 1.0), color: UIColor(rgb: 0xb0b0b0)),
caption: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 15.0, lineSpacingFactor: 1.0), color: UIColor(rgb: 0x6a6a6a)),
credit: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 13.0, lineSpacingFactor: 1.0), color: UIColor(rgb: 0x6a6a6a)),
table: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 15.0, lineSpacingFactor: 1.0), color: UIColor(rgb: 0xb0b0b0)),
article: InstantPageTextAttributes(font: InstantPageFont(style: .serif, size: 18.0, lineSpacingFactor: 1.0), color: UIColor(rgb: 0xb0b0b0))
),
serif: false,
codeBlockBackgroundColor: UIColor(rgb: 0x131313),
linkColor: UIColor(rgb: 0x5ac8fa),
textHighlightColor: UIColor(rgb: 0xffffff, alpha: 0.1),
linkHighlightColor: UIColor(rgb: 0x5ac8fa, alpha: 0.2),
markerColor: UIColor(rgb: 0x313131),
panelBackgroundColor: UIColor(rgb: 0x131313),
panelHighlightedBackgroundColor: UIColor(rgb: 0x1f1f1f),
panelPrimaryColor: UIColor(rgb: 0xb0b0b0),
panelSecondaryColor: UIColor(rgb: 0x6a6a6a),
panelAccentColor: UIColor(rgb: 0x50b6f3),
tableBorderColor: UIColor(rgb: 0x303030),
tableHeaderColor: UIColor(rgb: 0x131313),
controlColor: UIColor(rgb: 0x303030),
imageTintColor: UIColor(rgb: 0xb0b0b0),
overlayPanelColor: UIColor(rgb: 0x232323)
)
private func fontSizeMultiplierForVariant(_ variant: InstantPagePresentationFontSize) -> CGFloat {
switch variant {
case .xxsmall:
return 0.5
case .xsmall:
return 0.75
case .small:
return 0.85
case .standard:
return 1.0
case .large:
return 1.15
case .xlarge:
return 1.25
case .xxlarge:
return 1.5
}
}
func instantPageThemeTypeForSettingsAndTime(themeSettings: PresentationThemeSettings?, settings: InstantPagePresentationSettings, time: Date?, forceDarkTheme: Bool) -> (InstantPageThemeType, Bool) {
if settings.autoNightMode {
switch settings.themeType {
case .light, .sepia, .gray:
var useDarkTheme = false
var fallback = true
if let themeSettings = themeSettings {
if case .explicitNone = themeSettings.automaticThemeSwitchSetting.trigger {
} else {
fallback = false
useDarkTheme = forceDarkTheme
}
}
if fallback, let time = time {
let hour = Calendar.current.component(.hour, from: time)
if hour <= 8 || hour >= 22 {
useDarkTheme = true
}
}
if useDarkTheme {
return (.dark, true)
}
case .dark:
break
}
}
return (settings.themeType, false)
}
public func instantPageThemeForType(_ type: InstantPageThemeType, settings: InstantPagePresentationSettings) -> InstantPageTheme {
switch type {
case .light:
return lightTheme.withUpdatedFontStyles(sizeMultiplier: fontSizeMultiplierForVariant(settings.fontSize), forceSerif: settings.forceSerif)
case .sepia:
return sepiaTheme.withUpdatedFontStyles(sizeMultiplier: fontSizeMultiplierForVariant(settings.fontSize), forceSerif: settings.forceSerif)
case .gray:
return grayTheme.withUpdatedFontStyles(sizeMultiplier: fontSizeMultiplierForVariant(settings.fontSize), forceSerif: settings.forceSerif)
case .dark:
return darkTheme.withUpdatedFontStyles(sizeMultiplier: fontSizeMultiplierForVariant(settings.fontSize), forceSerif: settings.forceSerif)
}
}
extension ActionSheetControllerTheme {
convenience init(instantPageTheme: InstantPageTheme) {
self.init(dimColor: UIColor(white: 0.0, alpha: 0.4), backgroundType: instantPageTheme.type != .dark ? .light : .dark, itemBackgroundColor: instantPageTheme.overlayPanelColor, itemHighlightedBackgroundColor: instantPageTheme.panelHighlightedBackgroundColor, standardActionTextColor: instantPageTheme.panelAccentColor, destructiveActionTextColor: instantPageTheme.panelAccentColor, disabledActionTextColor: instantPageTheme.panelAccentColor, primaryTextColor: instantPageTheme.textCategories.paragraph.color, secondaryTextColor: instantPageTheme.textCategories.caption.color, controlAccentColor: instantPageTheme.panelAccentColor, controlColor: instantPageTheme.tableBorderColor, switchFrameColor: .white, switchContentColor: .white, switchHandleColor: .white, baseFontSize: 17.0)
}
}
public extension ActionSheetController {
convenience init(instantPageTheme: InstantPageTheme) {
self.init(theme: ActionSheetControllerTheme(instantPageTheme: instantPageTheme), allowInputInset: false)
}
}
@@ -0,0 +1,96 @@
import Foundation
import UIKit
import Display
public final class InstantPageTile {
public let frame: CGRect
public var items: [InstantPageItem] = []
init(frame: CGRect) {
self.frame = frame
}
func draw(context: CGContext) {
context.translateBy(x: -self.frame.minX, y: -self.frame.minY)
for item in self.items {
item.drawInTile(context: context)
}
context.translateBy(x: self.frame.minX, y: self.frame.minY)
}
}
public func instantPageTilesFromLayout(_ layout: InstantPageLayout, boundingWidth: CGFloat) -> [InstantPageTile] {
var tileByOrigin: [Int : InstantPageTile] = [:]
let tileHeight: CGFloat = 256.0
var tileHoles: [CGRect] = []
for item in layout.items {
if !item.wantsNode {
let topTileIndex = max(0, Int(floor(item.frame.minY - 10.0) / tileHeight))
let bottomTileIndex = max(topTileIndex, Int(floor(item.frame.maxY + 10.0) / tileHeight))
for i in topTileIndex ... bottomTileIndex {
let tile: InstantPageTile
if let current = tileByOrigin[i] {
tile = current
} else {
tile = InstantPageTile(frame: CGRect(x: 0.0, y: CGFloat(i) * tileHeight, width: boundingWidth, height: tileHeight))
tileByOrigin[i] = tile
}
tile.items.append(item)
}
} else if item.separatesTiles {
tileHoles.append(item.frame)
}
}
var finalTiles: [InstantPageTile] = []
var usedTiles = Set<Int>()
for hole in tileHoles {
let topTileIndex = max(0, Int(floor(hole.minY - 10.0) / tileHeight))
let bottomTileIndex = max(topTileIndex, Int(floor(hole.maxY + 10.0) / tileHeight))
for i in topTileIndex ... bottomTileIndex {
if let tile = tileByOrigin[i] {
if tile.frame.minY > hole.minY && tile.frame.minY < hole.maxY {
let delta = hole.maxY - tile.frame.minY
let updatedTile = InstantPageTile(frame: CGRect(origin: tile.frame.origin.offsetBy(dx: 0.0, dy: delta), size: CGSize(width: tile.frame.width, height: tile.frame.height - delta)))
updatedTile.items.append(contentsOf: tile.items)
finalTiles.append(updatedTile)
usedTiles.insert(i)
} else if tile.frame.maxY > hole.minY && tile.frame.minY < hole.minY {
let delta = tile.frame.maxY - hole.minY
let updatedTile = InstantPageTile(frame: CGRect(origin: tile.frame.origin, size: CGSize(width: tile.frame.width, height: tile.frame.height - delta)))
updatedTile.items.append(contentsOf: tile.items)
finalTiles.append(updatedTile)
usedTiles.insert(i)
}
}
}
//let holeTile = InstantPageTile(frame: hole)
//finalTiles.append(holeTile)
}
for (index, tile) in tileByOrigin {
if !usedTiles.contains(index) {
finalTiles.append(tile)
}
}
return finalTiles.sorted(by: { lhs, rhs in
return lhs.frame.minY < rhs.frame.minY
})
}
public func instantPageAccessibilityAreasFromLayout(_ layout: InstantPageLayout, boundingWidth: CGFloat) -> [AccessibilityAreaNode] {
var result: [AccessibilityAreaNode] = []
for item in layout.items {
if let item = item as? InstantPageTextItem {
let itemNode = AccessibilityAreaNode()
itemNode.frame = item.frame
itemNode.accessibilityTraits = .staticText
itemNode.accessibilityLabel = item.attributedString.string
result.append(itemNode)
}
}
return result
}
@@ -0,0 +1,47 @@
import Foundation
import UIKit
import AsyncDisplayKit
private final class InstantPageTileNodeParameters: NSObject {
let tile: InstantPageTile
let backgroundColor: UIColor
init(tile: InstantPageTile, backgroundColor: UIColor) {
self.tile = tile
self.backgroundColor = backgroundColor
super.init()
}
}
public final class InstantPageTileNode: ASDisplayNode {
private let tile: InstantPageTile
public init(tile: InstantPageTile, backgroundColor: UIColor) {
self.tile = tile
super.init()
self.isLayerBacked = true
self.isOpaque = false
self.backgroundColor = backgroundColor
}
public override func drawParameters(forAsyncLayer layer: _ASDisplayLayer) -> NSObjectProtocol? {
return InstantPageTileNodeParameters(tile: self.tile, backgroundColor: self.backgroundColor ?? UIColor.white)
}
@objc override public class func draw(_ bounds: CGRect, withParameters parameters: Any?, isCancelled: () -> Bool, isRasterizing: Bool) {
let context = UIGraphicsGetCurrentContext()!
if let parameters = parameters as? InstantPageTileNodeParameters {
if !isRasterizing {
context.setBlendMode(.copy)
context.setFillColor(parameters.backgroundColor.cgColor)
context.fill(bounds)
}
parameters.tile.draw(context: context)
}
}
}
@@ -0,0 +1,61 @@
import Foundation
import UIKit
import TelegramCore
import AsyncDisplayKit
import TelegramPresentationData
import TelegramUIPreferences
import AccountContext
import ContextUI
public final class InstantPageWebEmbedItem: InstantPageItem {
public var frame: CGRect
public let wantsNode: Bool = true
public let separatesTiles: Bool = false
public let medias: [InstantPageMedia] = []
let url: String?
let html: String?
let enableScrolling: Bool
init(frame: CGRect, url: String?, html: String?, enableScrolling: Bool) {
self.frame = frame
self.url = url
self.html = html
self.enableScrolling = enableScrolling
}
public func node(context: AccountContext, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, theme: InstantPageTheme, sourceLocation: InstantPageSourceLocation, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, openPeer: @escaping (EnginePeer) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void, updateWebEmbedHeight: @escaping (CGFloat) -> Void, updateDetailsExpanded: @escaping (Bool) -> Void, currentExpandedDetails: [Int : Bool]?, getPreloadedResource: @escaping (String) -> Data?) -> InstantPageNode? {
return InstantPageWebEmbedNode(frame: self.frame, url: self.url, html: self.html, enableScrolling: self.enableScrolling, updateWebEmbedHeight: updateWebEmbedHeight)
}
public func matchesAnchor(_ anchor: String) -> Bool {
return false
}
public func matchesNode(_ node: InstantPageNode) -> Bool {
if let node = node as? InstantPageWebEmbedNode {
return self.url == node.url && self.html == node.html
} else {
return false
}
}
public func distanceThresholdGroup() -> Int? {
return 6
}
public func distanceThresholdWithGroupCount(_ count: Int) -> CGFloat {
if count > 3 {
return 1000.0
} else {
return CGFloat.greatestFiniteMagnitude
}
}
public func linkSelectionRects(at point: CGPoint) -> [CGRect] {
return []
}
public func drawInTile(context: CGContext) {
}
}
@@ -0,0 +1,131 @@
import Foundation
import UIKit
import TelegramCore
import WebKit
import AsyncDisplayKit
import Display
import TelegramPresentationData
private class WeakInstantPageWebEmbedNodeMessageHandler: NSObject, WKScriptMessageHandler {
private let f: (WKScriptMessage) -> ()
init(_ f: @escaping (WKScriptMessage) -> ()) {
self.f = f
super.init()
}
func userContentController(_ controller: WKUserContentController, didReceive scriptMessage: WKScriptMessage) {
self.f(scriptMessage)
}
}
final class InstantPageWebEmbedNode: ASDisplayNode, InstantPageNode {
let url: String?
let html: String?
let updateWebEmbedHeight: (CGFloat) -> Void
private var webView: WKWebView?
init(frame: CGRect, url: String?, html: String?, enableScrolling: Bool, updateWebEmbedHeight: @escaping (CGFloat) -> Void) {
self.url = url
self.html = html
self.updateWebEmbedHeight = updateWebEmbedHeight
super.init()
let js = "var TelegramWebviewProxyProto = function() {}; " +
"TelegramWebviewProxyProto.prototype.postEvent = function(eventName, eventData) { " +
"window.webkit.messageHandlers.performAction.postMessage({'eventName': eventName, 'eventData': eventData}); " +
"}; " +
"var TelegramWebviewProxy = new TelegramWebviewProxyProto();"
let configuration = WKWebViewConfiguration()
let userController = WKUserContentController()
let userScript = WKUserScript(source: js, injectionTime: .atDocumentStart, forMainFrameOnly: false)
userController.addUserScript(userScript)
userController.add(WeakInstantPageWebEmbedNodeMessageHandler { [weak self] message in
if let strongSelf = self {
strongSelf.handleScriptMessage(message)
}
}, name: "performAction")
configuration.userContentController = userController
let webView = WKWebView(frame: CGRect(origin: CGPoint(), size: frame.size), configuration: configuration)
webView.allowsBackForwardNavigationGestures = false
if #available(iOSApplicationExtension 9.0, iOS 9.0, *) {
webView.allowsLinkPreview = false
}
if #available(iOSApplicationExtension 11.0, iOS 11.0, *) {
webView.scrollView.contentInsetAdjustmentBehavior = .never
}
webView.scrollView.isScrollEnabled = enableScrolling
if let html = html {
webView.loadHTMLString(html, baseURL: nil)
} else if let url = url, let parsedUrl = URL(string: url) {
var request = URLRequest(url: parsedUrl)
if let scheme = parsedUrl.scheme, let host = parsedUrl.host {
let referrer = "\(scheme)://\(host)"
request.setValue(referrer, forHTTPHeaderField: "Referer")
}
webView.load(request)
}
self.webView = webView
}
private func handleScriptMessage(_ message: WKScriptMessage) {
guard let body = message.body as? [String: Any] else {
return
}
guard let eventName = body["eventName"] as? String, let eventString = body["eventData"] as? String else {
return
}
guard let eventData = eventString.data(using: .utf8) else {
return
}
guard let dict = (try? JSONSerialization.jsonObject(with: eventData, options: [])) as? [String: Any] else {
return
}
if eventName == "resize_frame", let height = dict["height"] as? Int {
self.updateWebEmbedHeight(CGFloat(height))
}
}
override func didLoad() {
super.didLoad()
if let webView = self.webView {
self.view.addSubview(webView)
}
}
override func layout() {
super.layout()
self.webView?.frame = self.bounds
}
func transitionNode(media: InstantPageMedia) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? {
return nil
}
func updateHiddenMedia(media: InstantPageMedia?) {
}
func updateIsVisible(_ isVisible: Bool) {
}
func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition) {
}
func update(strings: PresentationStrings, theme: InstantPageTheme) {
}
}