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
@@ -0,0 +1,91 @@
import Foundation
import SwiftSignalKit
final class FileDownload: NSObject, URLSessionDownloadDelegate {
let fileName: String
let fileSize: Int64?
let isMedia: Bool
private var urlSession: URLSession!
private var completion: ((URL?, Error?) -> Void)?
private var progressHandler: ((Double) -> Void)?
private var task: URLSessionDownloadTask!
private let progressPromise = ValuePromise<Double>(0.0)
var progressSignal: Signal<Double, NoError> {
return self.progressPromise.get()
}
init(from url: URL, fileName: String, fileSize: Int64?, isMedia: Bool, progressHandler: @escaping (Double) -> Void, completion: @escaping (URL?, Error?) -> Void) {
self.fileName = fileName
self.fileSize = fileSize
self.isMedia = isMedia
self.completion = completion
self.progressHandler = progressHandler
super.init()
let configuration = URLSessionConfiguration.default
self.urlSession = URLSession(configuration: configuration, delegate: self, delegateQueue: OperationQueue.main)
let downloadTask = self.urlSession.downloadTask(with: url)
downloadTask.resume()
self.task = downloadTask
}
func cancel() {
self.task.cancel()
}
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
var totalBytesExpectedToWrite = totalBytesExpectedToWrite
if totalBytesExpectedToWrite == -1, let fileSize = self.fileSize {
totalBytesExpectedToWrite = fileSize
}
let progress = max(0.0, min(1.0, Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)))
self.progressHandler?(progress)
self.progressPromise.set(progress)
}
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
self.completion?(location, nil)
}
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
if let error = error {
self.completion?(nil, error)
}
}
static func getFileSize(url: String) -> Signal<Int64?, NoError> {
if #available(iOS 13.0, *) {
guard let url = URL(string: url) else {
return .single(nil)
}
var request = URLRequest(url: url)
request.httpMethod = "HEAD"
return Signal { subscriber in
let task = URLSession.shared.dataTask(with: request) { _, response, error in
if let _ = error {
subscriber.putNext(nil)
subscriber.putCompletion()
return
}
var fileSize: Int64?
if let httpResponse = response as? HTTPURLResponse, let contentLength = httpResponse.value(forHTTPHeaderField: "Content-Length"), let size = Int64(contentLength) {
fileSize = size
}
subscriber.putNext(fileSize)
subscriber.putCompletion()
}
task.resume()
return ActionDisposable {
task.cancel()
}
}
} else {
return .single(nil)
}
}
}
@@ -0,0 +1,373 @@
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import ComponentFlow
import BundleIconComponent
import MultilineTextComponent
import MoreButtonNode
import AccountContext
import TelegramPresentationData
import LottieAnimationComponent
final class FullscreenControlsComponent: Component {
let context: AccountContext
let strings: PresentationStrings
let title: String
let isVerified: Bool
let insets: UIEdgeInsets
let statusBarStyle: StatusBarStyle
var hasBack: Bool
let backPressed: () -> Void
let minimizePressed: () -> Void
let morePressed: (ASDisplayNode, ContextGesture?) -> Void
init(
context: AccountContext,
strings: PresentationStrings,
title: String,
isVerified: Bool,
insets: UIEdgeInsets,
statusBarStyle: StatusBarStyle,
hasBack: Bool,
backPressed: @escaping () -> Void,
minimizePressed: @escaping () -> Void,
morePressed: @escaping (ASDisplayNode, ContextGesture?) -> Void
) {
self.context = context
self.strings = strings
self.title = title
self.isVerified = isVerified
self.insets = insets
self.statusBarStyle = statusBarStyle
self.hasBack = hasBack
self.backPressed = backPressed
self.minimizePressed = minimizePressed
self.morePressed = morePressed
}
static func ==(lhs: FullscreenControlsComponent, rhs: FullscreenControlsComponent) -> Bool {
if lhs.context !== rhs.context {
return false
}
if lhs.title != rhs.title {
return false
}
if lhs.isVerified != rhs.isVerified {
return false
}
if lhs.insets != rhs.insets {
return false
}
if lhs.statusBarStyle != rhs.statusBarStyle {
return false
}
if lhs.hasBack != rhs.hasBack {
return false
}
return true
}
final class View: UIView {
private let leftBackgroundView: BlurredBackgroundView
private let rightBackgroundView: BlurredBackgroundView
private let closeIcon = ComponentView<Empty>()
private let leftButton = HighlightTrackingButton()
private let titleClippingView = UIView()
private let title = ComponentView<Empty>()
private let credibility = ComponentView<Empty>()
private let buttonTitle = ComponentView<Empty>()
private let minimizeButton = ComponentView<Empty>()
private let moreNode = MoreButtonNode(theme: defaultPresentationTheme, size: CGSize(width: 36.0, height: 36.0), encircled: false)
private var displayTitle = true
private var timer: Timer?
private var component: FullscreenControlsComponent?
private weak var state: EmptyComponentState?
override init(frame: CGRect) {
self.leftBackgroundView = BlurredBackgroundView(color: nil)
self.rightBackgroundView = BlurredBackgroundView(color: nil)
super.init(frame: frame)
self.titleClippingView.clipsToBounds = true
self.titleClippingView.isUserInteractionEnabled = false
self.leftBackgroundView.clipsToBounds = true
self.addSubview(self.leftBackgroundView)
self.addSubview(self.leftButton)
self.addSubview(self.titleClippingView)
self.rightBackgroundView.clipsToBounds = true
self.addSubview(self.rightBackgroundView)
self.addSubview(self.moreNode.view)
self.moreNode.updateColor(.white, transition: .immediate)
self.leftButton.highligthedChanged = { [weak self] highlighted in
guard let self else {
return
}
if highlighted {
if let view = self.closeIcon.view {
view.layer.removeAnimation(forKey: "opacity")
view.alpha = 0.6
}
if let view = self.buttonTitle.view {
view.layer.removeAnimation(forKey: "opacity")
view.alpha = 0.6
}
} else {
if let view = self.closeIcon.view {
view.alpha = 1.0
view.layer.animateAlpha(from: 0.6, to: 1.0, duration: 0.2)
}
if let view = self.buttonTitle.view {
view.alpha = 1.0
view.layer.animateAlpha(from: 0.6, to: 1.0, duration: 0.2)
}
}
}
self.leftButton.addTarget(self, action: #selector(self.closePressed), for: .touchUpInside)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
self.timer?.invalidate()
}
@objc private func closePressed() {
guard let component = self.component else {
return
}
component.backPressed()
}
@objc private func timerEvent() {
self.timer?.invalidate()
self.timer = nil
self.displayTitle = false
self.state?.updated(transition: .spring(duration: 0.3))
}
func update(component: FullscreenControlsComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
let isFirstTime = self.component == nil
let previousComponent = self.component
self.component = component
self.state = state
let sideInset: CGFloat = 16.0
let leftBackgroundSize = CGSize(width: 30.0, height: 30.0)
let rightBackgroundSize = CGSize(width: 72.0, height: 30.0)
let backgroundColor: UIColor = component.statusBarStyle == .Black ? UIColor(white: 0.7, alpha: 0.35) : UIColor(white: 0.45, alpha: 0.25)
let textColor: UIColor = component.statusBarStyle == .Black ? UIColor(rgb: 0x808080) : .white
self.leftBackgroundView.updateColor(color: backgroundColor, transition: transition.containedViewLayoutTransition)
self.rightBackgroundView.updateColor(color: backgroundColor, transition: transition.containedViewLayoutTransition)
let rightBackgroundFrame = CGRect(origin: CGPoint(x: availableSize.width - component.insets.right - sideInset - rightBackgroundSize.width, y: 0.0), size: rightBackgroundSize)
self.rightBackgroundView.update(size: rightBackgroundSize, cornerRadius: rightBackgroundFrame.height / 2.0, transition: transition.containedViewLayoutTransition)
transition.setFrame(view: self.rightBackgroundView, frame: rightBackgroundFrame)
var isAnimatingTextTransition = false
self.moreNode.updateColor(textColor, transition: .immediate)
var additionalLeftWidth: CGFloat = 0.0
let titleSize = self.title.update(
transition: .immediate,
component: AnyComponent(MultilineTextComponent(text: .plain(NSAttributedString(string: component.title, font: Font.with(size: 13.0, design: .round, weight: .semibold), textColor: textColor)))),
environment: {},
containerSize: availableSize
)
let titleFrame = CGRect(origin: CGPoint(x: self.displayTitle ? 3.0 : -titleSize.width - 15.0, y: floorToScreenPixels((leftBackgroundSize.height - titleSize.height) / 2.0)), size: titleSize)
if let view = self.title.view {
if view.superview == nil {
self.titleClippingView.addSubview(view)
}
if !view.alpha.isZero && !self.displayTitle {
isAnimatingTextTransition = true
}
transition.setFrame(view: view, frame: titleFrame)
transition.setAlpha(view: view, alpha: self.displayTitle ? 1.0 : 0.0)
}
let buttonTitleUpdated = (previousComponent?.hasBack ?? false) != component.hasBack
let animationMultiplier = !component.hasBack ? -1.0 : 1.0
if buttonTitleUpdated && !self.displayTitle {
isAnimatingTextTransition = true
if let view = self.buttonTitle.view, let snapshotView = view.snapshotView(afterScreenUpdates: false) {
snapshotView.frame = view.frame
self.titleClippingView.addSubview(snapshotView)
snapshotView.layer.animatePosition(from: .zero, to: CGPoint(x: -(snapshotView.frame.width * 1.5) * animationMultiplier, y: 0.0), duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, additive: true, completion: { _ in
snapshotView.removeFromSuperview()
})
}
}
let buttonTitleSize = self.buttonTitle.update(
transition: .immediate,
component: AnyComponent(MultilineTextComponent(text: .plain(NSAttributedString(string: component.hasBack ? component.strings.Common_Back : component.strings.Common_Close, font: Font.with(size: 13.0, design: .round, weight: .semibold), textColor: textColor)))),
environment: {},
containerSize: availableSize
)
if self.displayTitle {
additionalLeftWidth += titleSize.width + 10.0
} else {
additionalLeftWidth += buttonTitleSize.width + 10.0
}
let buttonTitleFrame = CGRect(origin: CGPoint(x: self.displayTitle ? leftBackgroundSize.width + additionalLeftWidth + 3.0 : 3.0, y: floorToScreenPixels((leftBackgroundSize.height - buttonTitleSize.height) / 2.0)), size: buttonTitleSize)
if let view = self.buttonTitle.view {
if view.superview == nil {
self.titleClippingView.addSubview(view)
}
transition.setFrame(view: view, frame: buttonTitleFrame)
if buttonTitleUpdated {
view.layer.animatePosition(from: CGPoint(x: (view.frame.width * 1.5) * animationMultiplier, y: 0.0), to: .zero, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, additive: true)
}
}
if component.isVerified {
let credibilitySize = self.credibility.update(
transition: .immediate,
component: AnyComponent(BundleIconComponent(name: "Instant View/Verified", tintColor: textColor)),
environment: {},
containerSize: availableSize
)
if let view = self.credibility.view {
if view.superview == nil {
view.alpha = 0.6
self.titleClippingView.addSubview(view)
}
let credibilityFrame = CGRect(origin: CGPoint(x: titleFrame.maxX + 2.0, y: floorToScreenPixels((leftBackgroundSize.height - credibilitySize.height) / 2.0)), size: credibilitySize)
transition.setFrame(view: view, frame: credibilityFrame)
}
if self.displayTitle {
additionalLeftWidth += credibilitySize.width + 2.0
}
}
var leftBackgroundTransition = transition
if buttonTitleUpdated {
leftBackgroundTransition = .spring(duration: 0.3)
}
let leftBackgroundFrame = CGRect(origin: CGPoint(x: sideInset + component.insets.left, y: 0.0), size: CGSize(width: leftBackgroundSize.width + additionalLeftWidth, height: leftBackgroundSize.height))
self.leftBackgroundView.update(size: leftBackgroundFrame.size, cornerRadius: leftBackgroundSize.height / 2.0, transition: leftBackgroundTransition.containedViewLayoutTransition)
leftBackgroundTransition.setFrame(view: self.leftBackgroundView, frame: leftBackgroundFrame)
self.leftButton.frame = leftBackgroundFrame
if isAnimatingTextTransition, self.titleClippingView.mask == nil {
if let maskImage = generateGradientImage(size: CGSize(width: 42.0, height: 10.0), colors: [UIColor.clear, UIColor.black, UIColor.black, UIColor.clear], locations: [0.0, 0.1, 0.9, 1.0], direction: .horizontal) {
let maskView = UIImageView(image: maskImage.stretchableImage(withLeftCapWidth: 4, topCapHeight: 0))
self.titleClippingView.mask = maskView
maskView.frame = CGRect(origin: .zero, size: CGSize(width: self.titleClippingView.bounds.width, height: self.titleClippingView.bounds.height))
}
}
transition.setFrame(view: self.titleClippingView, frame: CGRect(origin: CGPoint(x: sideInset + component.insets.left + leftBackgroundSize.height - 3.0, y: 0.0), size: CGSize(width: leftBackgroundFrame.width - leftBackgroundSize.height, height: leftBackgroundSize.height)))
if let maskView = self.titleClippingView.mask {
leftBackgroundTransition.setFrame(view: maskView, frame: CGRect(origin: .zero, size: CGSize(width: self.titleClippingView.bounds.width, height: self.titleClippingView.bounds.height)), completion: { _ in
self.titleClippingView.mask = nil
})
}
let backButtonSize = self.closeIcon.update(
transition: .immediate,
component: AnyComponent(
LottieAnimationComponent(
animation: LottieAnimationComponent.AnimationItem(
name: "web_backToCancel",
mode: .animating(loop: false),
range: component.hasBack ? (0.5, 1.0) : (0.0, 0.5)
),
colors: ["__allcolors__": textColor],
size: CGSize(width: 30.0, height: 30.0)
)
),
environment: {},
containerSize: CGSize(width: 30.0, height: 30.0)
)
if let view = self.closeIcon.view {
if view.superview == nil {
view.isUserInteractionEnabled = false
self.addSubview(view)
}
let buttonFrame = CGRect(origin: CGPoint(x: leftBackgroundFrame.minX, y: 0.0), size: backButtonSize)
transition.setFrame(view: view, frame: buttonFrame)
}
let minimizeButtonSize = self.minimizeButton.update(
transition: .immediate,
component: AnyComponent(Button(
content: AnyComponent(
BundleIconComponent(name: "Instant View/MinimizeArrow", tintColor: textColor)
),
action: { [weak self] in
guard let self, let component = self.component else {
return
}
component.minimizePressed()
}
).minSize(CGSize(width: 30.0, height: 30.0))),
environment: {},
containerSize: CGSize(width: 30.0, height: 30.0)
)
if let view = self.minimizeButton.view {
if view.superview == nil {
self.addSubview(view)
}
let buttonFrame = CGRect(origin: CGPoint(x: rightBackgroundFrame.minX + 2.0, y: 0.0), size: minimizeButtonSize)
transition.setFrame(view: view, frame: buttonFrame)
}
transition.setFrame(view: self.moreNode.view, frame: CGRect(origin: CGPoint(x: rightBackgroundFrame.maxX - 42.0, y: -4.0), size: CGSize(width: 36.0, height: 36.0)))
self.moreNode.action = { [weak self] node, gesture in
guard let self, let component = self.component else {
return
}
component.morePressed(node, gesture)
}
if isFirstTime {
let timer = Timer(timeInterval: 2.5, target: self, selector: #selector(self.timerEvent), userInfo: nil, repeats: false)
self.timer = timer
RunLoop.main.add(timer, forMode: .common)
}
return CGSize(width: availableSize.width, height: leftBackgroundSize.height)
}
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
let result = super.hitTest(point, with: event)
if result === self {
return nil
}
return result
}
}
func makeView() -> View {
return View(frame: CGRect())
}
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}
@@ -0,0 +1,340 @@
import Foundation
import UIKit
import SwiftSignalKit
import AsyncDisplayKit
import Display
import Postbox
import TelegramCore
import TelegramPresentationData
import TelegramUIPreferences
import AccountContext
import AppBundle
import PhotoResources
import CheckNode
import Markdown
private let textFont = Font.regular(13.0)
private let boldTextFont = Font.semibold(13.0)
private func formattedText(_ text: String, color: UIColor, textAlignment: NSTextAlignment = .natural) -> NSAttributedString {
return parseMarkdownIntoAttributedString(text, attributes: MarkdownAttributes(body: MarkdownAttributeSet(font: textFont, textColor: color), bold: MarkdownAttributeSet(font: boldTextFont, textColor: color), link: MarkdownAttributeSet(font: textFont, textColor: color), linkAttribute: { _ in return nil}), textAlignment: textAlignment)
}
private final class WebAppAlertContentNode: AlertContentNode {
private let strings: PresentationStrings
private let peerName: String
private let peerIcon: TelegramMediaFile?
private let textNode: ASTextNode
private let appIconNode: ASImageNode
private let iconNode: ASImageNode
private let allowWriteCheckNode: InteractiveCheckNode
private let allowWriteLabelNode: ASTextNode
private let actionNodesSeparator: ASDisplayNode
private let actionNodes: [TextAlertContentActionNode]
private let actionVerticalSeparators: [ASDisplayNode]
private var validLayout: CGSize?
private var iconDisposable: Disposable?
override var dismissOnOutsideTap: Bool {
return self.isUserInteractionEnabled
}
var allowWriteAccess: Bool = true {
didSet {
self.allowWriteCheckNode.setSelected(self.allowWriteAccess, animated: true)
}
}
init(account: Account, theme: AlertControllerTheme, ptheme: PresentationTheme, strings: PresentationStrings, peerName: String, icons: [AttachMenuBots.Bot.IconName: TelegramMediaFile], requestWriteAccess: Bool, actions: [TextAlertAction]) {
self.strings = strings
self.peerName = peerName
if let icon = icons[.iOSStatic] {
self.peerIcon = icon
} else if let icon = icons[.default] {
self.peerIcon = icon
} else {
self.peerIcon = nil
}
self.textNode = ASTextNode()
self.textNode.maximumNumberOfLines = 0
self.appIconNode = ASImageNode()
self.appIconNode.displaysAsynchronously = false
self.appIconNode.displayWithoutProcessing = true
self.iconNode = ASImageNode()
self.iconNode.displaysAsynchronously = false
self.iconNode.displayWithoutProcessing = true
self.allowWriteCheckNode = InteractiveCheckNode(theme: CheckNodeTheme(backgroundColor: theme.accentColor, strokeColor: theme.contrastColor, borderColor: theme.controlBorderColor, overlayBorder: false, hasInset: false, hasShadow: false))
self.allowWriteCheckNode.setSelected(true, animated: false)
self.allowWriteLabelNode = ASTextNode()
self.allowWriteLabelNode.maximumNumberOfLines = 4
self.allowWriteLabelNode.isUserInteractionEnabled = true
self.actionNodesSeparator = ASDisplayNode()
self.actionNodesSeparator.isLayerBacked = true
self.actionNodes = actions.map { action -> TextAlertContentActionNode in
return TextAlertContentActionNode(theme: theme, action: action)
}
var actionVerticalSeparators: [ASDisplayNode] = []
if actions.count > 1 {
for _ in 0 ..< actions.count - 1 {
let separatorNode = ASDisplayNode()
separatorNode.isLayerBacked = true
actionVerticalSeparators.append(separatorNode)
}
}
self.actionVerticalSeparators = actionVerticalSeparators
super.init()
self.addSubnode(self.textNode)
self.addSubnode(self.appIconNode)
self.addSubnode(self.iconNode)
if requestWriteAccess {
self.addSubnode(self.allowWriteCheckNode)
self.addSubnode(self.allowWriteLabelNode)
}
self.addSubnode(self.actionNodesSeparator)
for actionNode in self.actionNodes {
self.addSubnode(actionNode)
}
for separatorNode in self.actionVerticalSeparators {
self.addSubnode(separatorNode)
}
self.allowWriteCheckNode.valueChanged = { [weak self] value in
if let strongSelf = self {
strongSelf.allowWriteAccess = !strongSelf.allowWriteAccess
}
}
self.updateTheme(theme)
if let peerIcon = self.peerIcon {
let _ = freeMediaFileInteractiveFetched(account: account, userLocation: .other, fileReference: .standalone(media: peerIcon)).start()
self.iconDisposable = (svgIconImageFile(account: account, fileReference: .standalone(media: peerIcon))
|> deliverOnMainQueue).start(next: { [weak self] transform in
if let strongSelf = self {
let availableSize = CGSize(width: 48.0, height: 48.0)
let arguments = TransformImageArguments(corners: ImageCorners(), imageSize: availableSize, boundingSize: availableSize, intrinsicInsets: UIEdgeInsets())
let drawingContext = transform(arguments)
let image = drawingContext?.generateImage()?.withRenderingMode(.alwaysTemplate)
strongSelf.appIconNode.image = generateTintedImage(image: image, color: theme.accentColor, backgroundColor: nil)
}
})
}
}
deinit {
self.iconDisposable?.dispose()
}
override func didLoad() {
super.didLoad()
self.allowWriteLabelNode.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.allowWriteTap(_:))))
}
@objc private func allowWriteTap(_ gestureRecognizer: UITapGestureRecognizer) {
if self.allowWriteCheckNode.isUserInteractionEnabled {
self.allowWriteAccess = !self.allowWriteAccess
}
}
override func updateTheme(_ theme: AlertControllerTheme) {
self.textNode.attributedText = NSAttributedString(string: strings.WebApp_AddToAttachmentText(self.peerName).string, font: Font.bold(17.0), textColor: theme.primaryColor, paragraphAlignment: .center)
self.appIconNode.image = generateTintedImage(image: self.appIconNode.image, color: theme.accentColor)
self.iconNode.image = generateTintedImage(image: UIImage(bundleImageName: "Chat/Attach Menu/BotPlus"), color: theme.accentColor)
self.allowWriteLabelNode.attributedText = formattedText(strings.WebApp_AddToAttachmentAllowMessages(self.peerName).string, color: theme.primaryColor)
self.actionNodesSeparator.backgroundColor = theme.separatorColor
for actionNode in self.actionNodes {
actionNode.updateTheme(theme)
}
for separatorNode in self.actionVerticalSeparators {
separatorNode.backgroundColor = theme.separatorColor
}
if let size = self.validLayout {
_ = self.updateLayout(size: size, transition: .immediate)
}
}
override func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition) -> CGSize {
var size = size
size.width = min(size.width , 270.0)
self.validLayout = size
var origin: CGPoint = CGPoint(x: 0.0, y: 20.0)
var iconSize = CGSize()
var iconFrame = CGRect()
if let icon = self.iconNode.image {
iconSize = icon.size
iconFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - iconSize.width) / 2.0), y: origin.y), size: iconSize)
origin.y += iconSize.height + 16.0
}
let textSize = self.textNode.measure(size)
var textFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - textSize.width) / 2.0), y: origin.y), size: textSize)
origin.y += textSize.height
var entriesHeight: CGFloat = 0.0
if self.allowWriteLabelNode.supernode != nil {
origin.y += 16.0
entriesHeight += 16.0
let checkSize = CGSize(width: 22.0, height: 22.0)
let condensedSize = CGSize(width: size.width - 76.0, height: size.height)
let allowWriteSize = self.allowWriteLabelNode.measure(condensedSize)
transition.updateFrame(node: self.allowWriteLabelNode, frame: CGRect(origin: CGPoint(x: 46.0, y: origin.y), size: allowWriteSize))
transition.updateFrame(node: self.allowWriteCheckNode, frame: CGRect(origin: CGPoint(x: 12.0, y: origin.y - 2.0), size: checkSize))
origin.y += allowWriteSize.height
entriesHeight += allowWriteSize.height
}
let actionButtonHeight: CGFloat = 44.0
var minActionsWidth: CGFloat = 0.0
let maxActionWidth: CGFloat = floor(size.width / CGFloat(self.actionNodes.count))
let actionTitleInsets: CGFloat = 8.0
var effectiveActionLayout = TextAlertContentActionLayout.horizontal
for actionNode in self.actionNodes {
let actionTitleSize = actionNode.titleNode.updateLayout(CGSize(width: maxActionWidth, height: actionButtonHeight))
if case .horizontal = effectiveActionLayout, actionTitleSize.height > actionButtonHeight * 0.6667 {
effectiveActionLayout = .vertical
}
switch effectiveActionLayout {
case .horizontal:
minActionsWidth += actionTitleSize.width + actionTitleInsets
case .vertical:
minActionsWidth = max(minActionsWidth, actionTitleSize.width + actionTitleInsets)
}
}
let insets = UIEdgeInsets(top: 18.0, left: 18.0, bottom: 18.0, right: 18.0)
var contentWidth = max(textSize.width, minActionsWidth)
contentWidth = max(contentWidth, 234.0)
var actionsHeight: CGFloat = 0.0
switch effectiveActionLayout {
case .horizontal:
actionsHeight = actionButtonHeight
case .vertical:
actionsHeight = actionButtonHeight * CGFloat(self.actionNodes.count)
}
let resultWidth = contentWidth + insets.left + insets.right
let resultSize = CGSize(width: resultWidth, height: iconSize.height + textSize.height + entriesHeight + actionsHeight + 17.0 + insets.top + insets.bottom)
transition.updateFrame(node: self.actionNodesSeparator, frame: CGRect(origin: CGPoint(x: 0.0, y: resultSize.height - actionsHeight - UIScreenPixel), size: CGSize(width: resultSize.width, height: UIScreenPixel)))
var actionOffset: CGFloat = 0.0
let actionWidth: CGFloat = floor(resultSize.width / CGFloat(self.actionNodes.count))
var separatorIndex = -1
var nodeIndex = 0
for actionNode in self.actionNodes {
if separatorIndex >= 0 {
let separatorNode = self.actionVerticalSeparators[separatorIndex]
switch effectiveActionLayout {
case .horizontal:
transition.updateFrame(node: separatorNode, frame: CGRect(origin: CGPoint(x: actionOffset - UIScreenPixel, y: resultSize.height - actionsHeight), size: CGSize(width: UIScreenPixel, height: actionsHeight - UIScreenPixel)))
case .vertical:
transition.updateFrame(node: separatorNode, frame: CGRect(origin: CGPoint(x: 0.0, y: resultSize.height - actionsHeight + actionOffset - UIScreenPixel), size: CGSize(width: resultSize.width, height: UIScreenPixel)))
}
}
separatorIndex += 1
let currentActionWidth: CGFloat
switch effectiveActionLayout {
case .horizontal:
if nodeIndex == self.actionNodes.count - 1 {
currentActionWidth = resultSize.width - actionOffset
} else {
currentActionWidth = actionWidth
}
case .vertical:
currentActionWidth = resultSize.width
}
let actionNodeFrame: CGRect
switch effectiveActionLayout {
case .horizontal:
actionNodeFrame = CGRect(origin: CGPoint(x: actionOffset, y: resultSize.height - actionsHeight), size: CGSize(width: currentActionWidth, height: actionButtonHeight))
actionOffset += currentActionWidth
case .vertical:
actionNodeFrame = CGRect(origin: CGPoint(x: 0.0, y: resultSize.height - actionsHeight + actionOffset), size: CGSize(width: currentActionWidth, height: actionButtonHeight))
actionOffset += actionButtonHeight
}
transition.updateFrame(node: actionNode, frame: actionNodeFrame)
nodeIndex += 1
}
iconFrame.origin.x = floorToScreenPixels((resultSize.width - iconFrame.width) / 2.0) + 21.0
transition.updateFrame(node: self.appIconNode, frame: CGRect(x: iconFrame.minX - 50.0, y: iconFrame.minY + 3.0, width: 42.0, height: 42.0))
transition.updateFrame(node: self.iconNode, frame: iconFrame)
textFrame.origin.x = floorToScreenPixels((resultSize.width - textFrame.width) / 2.0)
transition.updateFrame(node: self.textNode, frame: textFrame)
return resultSize
}
}
public func addWebAppToAttachmentController(context: AccountContext, peerName: String, icons: [AttachMenuBots.Bot.IconName: TelegramMediaFile], requestWriteAccess: Bool, completion: @escaping (Bool) -> Void) -> AlertController {
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
let theme = presentationData.theme
let strings = presentationData.strings
var dismissImpl: ((Bool) -> Void)?
var getContentNodeImpl: (() -> WebAppAlertContentNode?)?
let actions: [TextAlertAction] = [TextAlertAction(type: .genericAction, title: presentationData.strings.Common_Cancel, action: {
dismissImpl?(true)
}), TextAlertAction(type: .defaultAction, title: presentationData.strings.WebApp_AddToAttachmentAdd, action: {
if requestWriteAccess, let allowWriteAccess = getContentNodeImpl?()?.allowWriteAccess {
completion(allowWriteAccess)
} else {
completion(false)
}
dismissImpl?(true)
})]
let contentNode = WebAppAlertContentNode(account: context.account, theme: AlertControllerTheme(presentationData: presentationData), ptheme: theme, strings: strings, peerName: peerName, icons: icons, requestWriteAccess: requestWriteAccess, actions: actions)
getContentNodeImpl = { [weak contentNode] in
return contentNode
}
let controller = AlertController(theme: AlertControllerTheme(presentationData: presentationData), contentNode: contentNode)
dismissImpl = { [weak controller] animated in
if animated {
controller?.dismissAnimated()
} else {
controller?.dismiss()
}
}
return controller
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,364 @@
import Foundation
import UIKit
import SwiftSignalKit
import AsyncDisplayKit
import Display
import Postbox
import TelegramCore
import TelegramPresentationData
import TelegramUIPreferences
import AccountContext
import AppBundle
import AvatarNode
import EmojiTextAttachmentView
import TextFormat
import Markdown
private final class IconsNode: ASDisplayNode {
private let context: AccountContext
private var animationLayer: InlineStickerItemLayer?
private var files: [TelegramMediaFile.Accessor]
private var currentIndex = 0
private var switchingToNext = false
private var timer: SwiftSignalKit.Timer?
private var currentParams: (size: CGSize, theme: PresentationTheme)?
init(context: AccountContext, files: [TelegramMediaFile.Accessor]) {
self.context = context
self.files = files
super.init()
}
deinit {
self.timer?.invalidate()
}
func updateLayout(size: CGSize, theme: PresentationTheme) {
self.currentParams = (size, theme)
if self.timer == nil {
self.timer = SwiftSignalKit.Timer(timeout: 2.5, repeat: true, completion: { [weak self] in
guard let self else {
return
}
self.switchingToNext = true
if let (size, theme) = self.currentParams {
self.updateLayout(size: size, theme: theme)
}
}, queue: Queue.mainQueue())
self.timer?.start()
}
let animationLayer: InlineStickerItemLayer
var disappearingAnimationLayer: InlineStickerItemLayer?
if let current = self.animationLayer, !self.switchingToNext {
animationLayer = current
} else {
if self.switchingToNext {
self.currentIndex = (self.currentIndex + 1) % self.files.count
disappearingAnimationLayer = self.animationLayer
self.switchingToNext = false
}
let file = self.files[self.currentIndex]._parse()
let emoji = ChatTextInputTextCustomEmojiAttribute(
interactivelySelectedFromPackId: nil,
fileId: file.fileId.id,
file: file
)
animationLayer = InlineStickerItemLayer(
context: .account(self.context),
userLocation: .other,
attemptSynchronousLoad: false,
emoji: emoji,
file: file,
cache: self.context.animationCache,
renderer: self.context.animationRenderer,
unique: true,
placeholderColor: theme.list.mediaPlaceholderColor,
pointSize: CGSize(width: 20.0, height: 20.0),
loopCount: 1
)
animationLayer.isVisibleForAnimations = true
animationLayer.dynamicColor = theme.actionSheet.controlAccentColor
self.view.layer.addSublayer(animationLayer)
self.animationLayer = animationLayer
animationLayer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
animationLayer.animatePosition(from: CGPoint(x: 0.0, y: 10.0), to: .zero, duration: 0.2, additive: true)
animationLayer.animateScale(from: 0.01, to: 1.0, duration: 0.2)
}
animationLayer.frame = CGRect(origin: .zero, size: CGSize(width: 20.0, height: 20.0))
if let disappearingAnimationLayer {
disappearingAnimationLayer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { _ in
disappearingAnimationLayer.removeFromSuperlayer()
})
disappearingAnimationLayer.animatePosition(from: .zero, to: CGPoint(x: 0.0, y: -10.0), duration: 0.2, removeOnCompletion: false, additive: true)
disappearingAnimationLayer.animateScale(from: 1.0, to: 0.01, duration: 0.2, removeOnCompletion: false)
}
}
}
private final class WebAppEmojiStatusAlertContentNode: AlertContentNode {
private let strings: PresentationStrings
private let presentationTheme: PresentationTheme
private let botName: String
private let textNode: ASTextNode
private let iconBackgroundNode: ASImageNode
private let iconAvatarNode: AvatarNode
private let iconNameNode: ASTextNode
private let iconAnimationNode: IconsNode
private let actionNodesSeparator: ASDisplayNode
private let actionNodes: [TextAlertContentActionNode]
private let actionVerticalSeparators: [ASDisplayNode]
private var validLayout: CGSize?
override var dismissOnOutsideTap: Bool {
return self.isUserInteractionEnabled
}
init(
context: AccountContext,
theme: AlertControllerTheme,
ptheme: PresentationTheme,
strings: PresentationStrings,
accountPeer: EnginePeer,
botName: String,
icons: [TelegramMediaFile.Accessor],
actions: [TextAlertAction]
) {
self.strings = strings
self.presentationTheme = ptheme
self.botName = botName
self.textNode = ASTextNode()
self.textNode.maximumNumberOfLines = 0
self.iconBackgroundNode = ASImageNode()
self.iconBackgroundNode.displaysAsynchronously = false
self.iconBackgroundNode.image = generateStretchableFilledCircleImage(radius: 16.0, color: theme.separatorColor)
self.iconAvatarNode = AvatarNode(font: avatarPlaceholderFont(size: 14.0))
self.iconAvatarNode.setPeer(context: context, theme: ptheme, peer: accountPeer)
self.iconNameNode = ASTextNode()
self.iconNameNode.attributedText = NSAttributedString(string: accountPeer.compactDisplayTitle, font: Font.medium(15.0), textColor: theme.primaryColor)
self.iconAnimationNode = IconsNode(context: context, files: icons)
self.actionNodesSeparator = ASDisplayNode()
self.actionNodesSeparator.isLayerBacked = true
self.actionNodes = actions.map { action -> TextAlertContentActionNode in
return TextAlertContentActionNode(theme: theme, action: action)
}
var actionVerticalSeparators: [ASDisplayNode] = []
if actions.count > 1 {
for _ in 0 ..< actions.count - 1 {
let separatorNode = ASDisplayNode()
separatorNode.isLayerBacked = true
actionVerticalSeparators.append(separatorNode)
}
}
self.actionVerticalSeparators = actionVerticalSeparators
super.init()
self.addSubnode(self.textNode)
self.addSubnode(self.iconBackgroundNode)
self.addSubnode(self.iconAvatarNode)
self.addSubnode(self.iconNameNode)
self.addSubnode(self.iconAnimationNode)
self.addSubnode(self.actionNodesSeparator)
for actionNode in self.actionNodes {
self.addSubnode(actionNode)
}
for separatorNode in self.actionVerticalSeparators {
self.addSubnode(separatorNode)
}
self.updateTheme(theme)
}
override func updateTheme(_ theme: AlertControllerTheme) {
let string = self.strings.WebApp_EmojiPermission_Text(self.botName, self.botName).string
let attributedText = parseMarkdownIntoAttributedString(string, attributes: MarkdownAttributes(
body: MarkdownAttributeSet(font: Font.regular(13.0), textColor: theme.primaryColor),
bold: MarkdownAttributeSet(font: Font.semibold(13.0), textColor: theme.primaryColor),
link: MarkdownAttributeSet(font: Font.regular(13.0), textColor: theme.primaryColor),
linkAttribute: { url in
return ("URL", url)
}
), textAlignment: .center)
self.textNode.attributedText = attributedText
self.actionNodesSeparator.backgroundColor = theme.separatorColor
for actionNode in self.actionNodes {
actionNode.updateTheme(theme)
}
for separatorNode in self.actionVerticalSeparators {
separatorNode.backgroundColor = theme.separatorColor
}
if let size = self.validLayout {
_ = self.updateLayout(size: size, transition: .immediate)
}
}
override func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition) -> CGSize {
var size = size
size.width = min(size.width , 270.0)
self.validLayout = size
var origin: CGPoint = CGPoint(x: 0.0, y: 20.0)
let iconSpacing: CGFloat = 6.0
let iconSize = CGSize(width: 32.0, height: 32.0)
let nameSize = self.iconNameNode.measure(size)
let totalIconWidth = iconSize.width + iconSpacing + nameSize.width + 4.0 + iconSize.width
let iconBackgroundFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - totalIconWidth) / 2.0), y: origin.y), size: CGSize(width: totalIconWidth, height: iconSize.height))
transition.updateFrame(node: self.iconBackgroundNode, frame: iconBackgroundFrame)
transition.updateFrame(node: self.iconAvatarNode, frame: CGRect(origin: iconBackgroundFrame.origin, size: iconSize).insetBy(dx: 1.0, dy: 1.0))
transition.updateFrame(node: self.iconNameNode, frame: CGRect(origin: CGPoint(x: iconBackgroundFrame.minX + iconSize.width + iconSpacing, y: iconBackgroundFrame.minY + floorToScreenPixels((iconBackgroundFrame.height - nameSize.height) / 2.0)), size: nameSize))
self.iconAnimationNode.updateLayout(size: CGSize(width: 20.0, height: 20.0), theme: self.presentationTheme)
self.iconAnimationNode.frame = CGRect(origin: CGPoint(x: iconBackgroundFrame.maxX - iconSize.width - 3.0, y: iconBackgroundFrame.minY), size: iconSize).insetBy(dx: 6.0, dy: 6.0)
origin.y += iconSize.height + 16.0
let textSize = self.textNode.measure(CGSize(width: size.width - 32.0, height: size.height))
transition.updateFrame(node: self.textNode, frame: CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - textSize.width) / 2.0), y: origin.y), size: textSize))
let actionButtonHeight: CGFloat = 44.0
var minActionsWidth: CGFloat = 0.0
let maxActionWidth: CGFloat = floor(size.width / CGFloat(self.actionNodes.count))
let actionTitleInsets: CGFloat = 8.0
var effectiveActionLayout = TextAlertContentActionLayout.horizontal
for actionNode in self.actionNodes {
let actionTitleSize = actionNode.titleNode.updateLayout(CGSize(width: maxActionWidth, height: actionButtonHeight))
if case .horizontal = effectiveActionLayout, actionTitleSize.height > actionButtonHeight * 0.6667 {
effectiveActionLayout = .vertical
}
switch effectiveActionLayout {
case .horizontal:
minActionsWidth += actionTitleSize.width + actionTitleInsets
case .vertical:
minActionsWidth = max(minActionsWidth, actionTitleSize.width + actionTitleInsets)
}
}
let insets = UIEdgeInsets(top: 18.0, left: 18.0, bottom: 18.0, right: 18.0)
var contentWidth = minActionsWidth
contentWidth = max(contentWidth, 234.0)
var actionsHeight: CGFloat = 0.0
switch effectiveActionLayout {
case .horizontal:
actionsHeight = actionButtonHeight
case .vertical:
actionsHeight = actionButtonHeight * CGFloat(self.actionNodes.count)
}
let resultWidth = contentWidth + insets.left + insets.right
let resultSize = CGSize(width: resultWidth, height: iconSize.height + textSize.height + actionsHeight + 16.0 + insets.top + insets.bottom)
transition.updateFrame(node: self.actionNodesSeparator, frame: CGRect(origin: CGPoint(x: 0.0, y: resultSize.height - actionsHeight - UIScreenPixel), size: CGSize(width: resultSize.width, height: UIScreenPixel)))
var actionOffset: CGFloat = 0.0
let actionWidth: CGFloat = floor(resultSize.width / CGFloat(self.actionNodes.count))
var separatorIndex = -1
var nodeIndex = 0
for actionNode in self.actionNodes {
if separatorIndex >= 0 {
let separatorNode = self.actionVerticalSeparators[separatorIndex]
switch effectiveActionLayout {
case .horizontal:
transition.updateFrame(node: separatorNode, frame: CGRect(origin: CGPoint(x: actionOffset - UIScreenPixel, y: resultSize.height - actionsHeight), size: CGSize(width: UIScreenPixel, height: actionsHeight - UIScreenPixel)))
case .vertical:
transition.updateFrame(node: separatorNode, frame: CGRect(origin: CGPoint(x: 0.0, y: resultSize.height - actionsHeight + actionOffset - UIScreenPixel), size: CGSize(width: resultSize.width, height: UIScreenPixel)))
}
}
separatorIndex += 1
let currentActionWidth: CGFloat
switch effectiveActionLayout {
case .horizontal:
if nodeIndex == self.actionNodes.count - 1 {
currentActionWidth = resultSize.width - actionOffset
} else {
currentActionWidth = actionWidth
}
case .vertical:
currentActionWidth = resultSize.width
}
let actionNodeFrame: CGRect
switch effectiveActionLayout {
case .horizontal:
actionNodeFrame = CGRect(origin: CGPoint(x: actionOffset, y: resultSize.height - actionsHeight), size: CGSize(width: currentActionWidth, height: actionButtonHeight))
actionOffset += currentActionWidth
case .vertical:
actionNodeFrame = CGRect(origin: CGPoint(x: 0.0, y: resultSize.height - actionsHeight + actionOffset), size: CGSize(width: currentActionWidth, height: actionButtonHeight))
actionOffset += actionButtonHeight
}
transition.updateFrame(node: actionNode, frame: actionNodeFrame)
nodeIndex += 1
}
return resultSize
}
}
func webAppEmojiStatusAlertController(
context: AccountContext,
accountPeer: EnginePeer,
botName: String,
icons: [TelegramMediaFile.Accessor],
completion: @escaping (Bool) -> Void
) -> AlertController {
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
let theme = presentationData.theme
let strings = presentationData.strings
var dismissImpl: ((Bool) -> Void)?
var contentNode: WebAppEmojiStatusAlertContentNode?
let actions: [TextAlertAction] = [TextAlertAction(type: .genericAction, title: strings.WebApp_EmojiPermission_Decline, action: {
dismissImpl?(true)
completion(false)
}), TextAlertAction(type: .defaultAction, title: strings.WebApp_EmojiPermission_Allow, action: {
dismissImpl?(true)
completion(true)
})]
contentNode = WebAppEmojiStatusAlertContentNode(context: context, theme: AlertControllerTheme(presentationData: presentationData), ptheme: theme, strings: strings, accountPeer: accountPeer, botName: botName, icons: icons, actions: actions)
let controller = AlertController(theme: AlertControllerTheme(presentationData: presentationData), contentNode: contentNode!)
dismissImpl = { [weak controller] animated in
if animated {
controller?.dismissAnimated()
} else {
controller?.dismiss()
}
}
return controller
}
@@ -0,0 +1,427 @@
import Foundation
import UIKit
import SwiftSignalKit
import AsyncDisplayKit
import Display
import ComponentFlow
import Postbox
import TelegramCore
import TelegramPresentationData
import TelegramUIPreferences
import AccountContext
import AppBundle
import AvatarNode
import CheckNode
import Markdown
import EmojiStatusComponent
private let textFont = Font.regular(13.0)
private let boldTextFont = Font.semibold(13.0)
private func formattedText(_ text: String, color: UIColor, linkColor: UIColor, textAlignment: NSTextAlignment = .natural) -> NSAttributedString {
return parseMarkdownIntoAttributedString(text, attributes: MarkdownAttributes(body: MarkdownAttributeSet(font: textFont, textColor: color), bold: MarkdownAttributeSet(font: boldTextFont, textColor: color), link: MarkdownAttributeSet(font: textFont, textColor: linkColor), linkAttribute: { _ in return nil}), textAlignment: textAlignment)
}
private final class WebAppLaunchConfirmationAlertContentNode: AlertContentNode {
private let context: AccountContext
private let presentationTheme: PresentationTheme
private let strings: PresentationStrings
private let peer: EnginePeer
private let title: String
private let text: String
private let showMore: Bool
private let titleNode: ImmediateTextNode
private var titleCredibilityIconView: ComponentHostView<Empty>?
private let textNode: ASTextNode
private let avatarNode: AvatarNode
private let moreButton: HighlightableButtonNode
private let arrowNode: ASImageNode
private let allowWriteCheckNode: InteractiveCheckNode
private let allowWriteLabelNode: ASTextNode
private let actionNodesSeparator: ASDisplayNode
private let actionNodes: [TextAlertContentActionNode]
private let actionVerticalSeparators: [ASDisplayNode]
private var validLayout: CGSize?
private let morePressed: () -> Void
private let termsPressed: () -> Void
override var dismissOnOutsideTap: Bool {
return self.isUserInteractionEnabled
}
var allowWriteAccess: Bool = true {
didSet {
self.allowWriteCheckNode.setSelected(self.allowWriteAccess, animated: true)
}
}
init(context: AccountContext, theme: AlertControllerTheme, ptheme: PresentationTheme, strings: PresentationStrings, peer: EnginePeer, title: String, text: String, showMore: Bool, requestWriteAccess: Bool, actions: [TextAlertAction], morePressed: @escaping () -> Void, termsPressed: @escaping () -> Void) {
self.context = context
self.strings = strings
self.presentationTheme = ptheme
self.peer = peer
self.title = title
self.text = text
self.showMore = showMore
self.morePressed = morePressed
self.termsPressed = termsPressed
self.titleNode = ImmediateTextNode()
self.titleNode.displaysAsynchronously = false
self.titleNode.maximumNumberOfLines = 1
self.titleNode.textAlignment = .center
self.textNode = ASTextNode()
self.textNode.displaysAsynchronously = false
self.textNode.maximumNumberOfLines = 0
self.avatarNode = AvatarNode(font: avatarPlaceholderFont(size: 26.0))
self.moreButton = HighlightableButtonNode()
self.arrowNode = ASImageNode()
self.arrowNode.displaysAsynchronously = false
self.arrowNode.displayWithoutProcessing = true
self.arrowNode.isHidden = !showMore
self.arrowNode.contentMode = .scaleAspectFit
self.allowWriteCheckNode = InteractiveCheckNode(theme: CheckNodeTheme(backgroundColor: theme.accentColor, strokeColor: theme.contrastColor, borderColor: theme.controlBorderColor, overlayBorder: false, hasInset: false, hasShadow: false))
self.allowWriteCheckNode.setSelected(true, animated: false)
self.allowWriteLabelNode = ASTextNode()
self.allowWriteLabelNode.maximumNumberOfLines = 4
self.allowWriteLabelNode.isUserInteractionEnabled = true
self.actionNodesSeparator = ASDisplayNode()
self.actionNodesSeparator.isLayerBacked = true
self.actionNodes = actions.map { action -> TextAlertContentActionNode in
return TextAlertContentActionNode(theme: theme, action: action)
}
var actionVerticalSeparators: [ASDisplayNode] = []
if actions.count > 1 {
for _ in 0 ..< actions.count - 1 {
let separatorNode = ASDisplayNode()
separatorNode.isLayerBacked = true
actionVerticalSeparators.append(separatorNode)
}
}
self.actionVerticalSeparators = actionVerticalSeparators
super.init()
self.addSubnode(self.titleNode)
self.addSubnode(self.textNode)
self.addSubnode(self.avatarNode)
self.addSubnode(self.moreButton)
self.moreButton.addSubnode(self.arrowNode)
if requestWriteAccess {
self.addSubnode(self.allowWriteCheckNode)
self.addSubnode(self.allowWriteLabelNode)
}
self.addSubnode(self.actionNodesSeparator)
for actionNode in self.actionNodes {
self.addSubnode(actionNode)
}
for separatorNode in self.actionVerticalSeparators {
self.addSubnode(separatorNode)
}
self.allowWriteCheckNode.valueChanged = { [weak self] value in
if let strongSelf = self {
strongSelf.allowWriteAccess = !strongSelf.allowWriteAccess
}
}
self.updateTheme(theme)
self.avatarNode.setPeer(context: context, theme: ptheme, peer: peer)
self.moreButton.addTarget(self, action: #selector(self.moreButtonPressed), forControlEvents: .touchUpInside)
}
override func didLoad() {
super.didLoad()
self.allowWriteLabelNode.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.allowWriteTap(_:))))
self.textNode.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.termsTap(_:))))
}
@objc private func allowWriteTap(_ gestureRecognizer: UITapGestureRecognizer) {
if self.allowWriteCheckNode.isUserInteractionEnabled {
self.allowWriteAccess = !self.allowWriteAccess
}
}
@objc private func termsTap(_ gestureRecognizer: UITapGestureRecognizer) {
self.termsPressed()
}
@objc private func moreButtonPressed() {
self.morePressed()
}
override func updateTheme(_ theme: AlertControllerTheme) {
self.titleNode.attributedText = NSAttributedString(string: self.title, font: Font.semibold(17.0), textColor: theme.primaryColor, paragraphAlignment: .center)
self.textNode.attributedText = formattedText(self.text, color: theme.primaryColor, linkColor: theme.accentColor, textAlignment: .center)
self.moreButton.setAttributedTitle(NSAttributedString(string: self.strings.WebApp_LaunchMoreInfo, font: Font.regular(13.0), textColor: theme.accentColor), for: .normal)
self.arrowNode.image = generateTintedImage(image: UIImage(bundleImageName: "Peer Info/AlertArrow"), color: theme.accentColor)
self.allowWriteLabelNode.attributedText = formattedText(strings.WebApp_AddToAttachmentAllowMessages(self.peer.compactDisplayTitle).string, color: theme.primaryColor, linkColor: theme.primaryColor)
self.actionNodesSeparator.backgroundColor = theme.separatorColor
for actionNode in self.actionNodes {
actionNode.updateTheme(theme)
}
for separatorNode in self.actionVerticalSeparators {
separatorNode.backgroundColor = theme.separatorColor
}
if let size = self.validLayout {
_ = self.updateLayout(size: size, transition: .immediate)
}
}
override func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition) -> CGSize {
var size = size
size.width = min(size.width, 270.0)
self.validLayout = size
var origin: CGPoint = CGPoint(x: 0.0, y: 20.0)
let avatarSize = CGSize(width: 60.0, height: 60.0)
self.avatarNode.updateSize(size: avatarSize)
let avatarFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - avatarSize.width) / 2.0), y: origin.y), size: avatarSize)
transition.updateFrame(node: self.avatarNode, frame: avatarFrame)
origin.y += avatarSize.height + 17.0
if let arrowImage = self.arrowNode.image {
let arrowFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - arrowImage.size.width) / 2.0), y: origin.y + floorToScreenPixels((avatarSize.height - arrowImage.size.height) / 2.0)), size: arrowImage.size)
transition.updateFrame(node: self.arrowNode, frame: arrowFrame)
}
let titleSize = self.titleNode.updateLayout(CGSize(width: size.width - 32.0, height: size.height))
var totalWidth = titleSize.width
var statusContent: EmojiStatusComponent.Content?
if self.peer.isScam {
statusContent = .text(color: self.presentationTheme.list.itemDestructiveColor, string: self.strings.Message_ScamAccount.uppercased())
} else if self.peer.isFake {
statusContent = .text(color: self.presentationTheme.list.itemDestructiveColor, string: self.strings.Message_FakeAccount.uppercased())
} else if self.peer.isVerified {
statusContent = .verified(fillColor: self.presentationTheme.list.itemCheckColors.fillColor, foregroundColor: self.presentationTheme.list.itemCheckColors.foregroundColor, sizeType: .large)
}
if let statusContent {
let titleCredibilityIconTransition: ComponentTransition = .immediate
let titleCredibilityIconView: ComponentHostView<Empty>
if let current = self.titleCredibilityIconView {
titleCredibilityIconView = current
} else {
titleCredibilityIconView = ComponentHostView<Empty>()
self.titleCredibilityIconView = titleCredibilityIconView
self.view.addSubview(titleCredibilityIconView)
}
let titleIconSize = titleCredibilityIconView.update(
transition: titleCredibilityIconTransition,
component: AnyComponent(EmojiStatusComponent(
context: self.context,
animationCache: self.context.animationCache,
animationRenderer: self.context.animationRenderer,
content: statusContent,
isVisibleForAnimations: true,
action: {
}
)),
environment: {},
containerSize: CGSize(width: 20.0, height: 20.0)
)
totalWidth += titleIconSize.width + 2.0
titleCredibilityIconTransition.setFrame(view: titleCredibilityIconView, frame: CGRect(origin: CGPoint(x:floorToScreenPixels((size.width - totalWidth) / 2.0) + titleSize.width + 2.0, y: origin.y), size: titleIconSize))
}
transition.updateFrame(node: self.titleNode, frame: CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - totalWidth) / 2.0), y: origin.y), size: titleSize))
origin.y += titleSize.height + 6.0
var entriesHeight: CGFloat = 0.0
if self.showMore {
let moreButtonSize = self.moreButton.measure(CGSize(width: size.width - 32.0, height: size.height))
transition.updateFrame(node: self.moreButton, frame: CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - moreButtonSize.width) / 2.0) - 5.0, y: origin.y), size: moreButtonSize))
transition.updateFrame(node: self.arrowNode, frame: CGRect(origin: CGPoint(x: moreButtonSize.width + 3.0, y: 4.0), size: CGSize(width: 9.0, height: 9.0)))
origin.y += moreButtonSize.height + 22.0
entriesHeight += 37.0
}
let textSize = self.textNode.measure(CGSize(width: size.width - 32.0, height: size.height))
transition.updateFrame(node: self.textNode, frame: CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - textSize.width) / 2.0), y: origin.y), size: textSize))
origin.y += textSize.height
if self.allowWriteLabelNode.supernode != nil {
origin.y += 16.0
entriesHeight += 16.0
let checkSize = CGSize(width: 22.0, height: 22.0)
let condensedSize = CGSize(width: size.width - 76.0, height: size.height)
let allowWriteSize = self.allowWriteLabelNode.measure(condensedSize)
transition.updateFrame(node: self.allowWriteLabelNode, frame: CGRect(origin: CGPoint(x: 46.0, y: origin.y), size: allowWriteSize))
transition.updateFrame(node: self.allowWriteCheckNode, frame: CGRect(origin: CGPoint(x: 12.0, y: origin.y - 2.0), size: checkSize))
origin.y += allowWriteSize.height
entriesHeight += allowWriteSize.height
}
let actionButtonHeight: CGFloat = 44.0
var minActionsWidth: CGFloat = 0.0
let maxActionWidth: CGFloat = floor(size.width / CGFloat(self.actionNodes.count))
let actionTitleInsets: CGFloat = 8.0
var effectiveActionLayout = TextAlertContentActionLayout.vertical
for actionNode in self.actionNodes {
let actionTitleSize = actionNode.titleNode.updateLayout(CGSize(width: maxActionWidth, height: actionButtonHeight))
if case .horizontal = effectiveActionLayout, actionTitleSize.height > actionButtonHeight * 0.6667 {
effectiveActionLayout = .vertical
}
switch effectiveActionLayout {
case .horizontal:
minActionsWidth += actionTitleSize.width + actionTitleInsets
case .vertical:
minActionsWidth = max(minActionsWidth, actionTitleSize.width + actionTitleInsets)
}
}
let insets = UIEdgeInsets(top: 18.0, left: 18.0, bottom: 18.0, right: 18.0)
let contentWidth = max(size.width, minActionsWidth)
var actionsHeight: CGFloat = 0.0
switch effectiveActionLayout {
case .horizontal:
actionsHeight = actionButtonHeight
case .vertical:
actionsHeight = actionButtonHeight * CGFloat(self.actionNodes.count)
}
let resultSize = CGSize(width: contentWidth, height: avatarSize.height + titleSize.height + textSize.height + entriesHeight + actionsHeight + 25.0 + insets.top + insets.bottom)
transition.updateFrame(node: self.actionNodesSeparator, frame: CGRect(origin: CGPoint(x: 0.0, y: resultSize.height - actionsHeight - UIScreenPixel), size: CGSize(width: resultSize.width, height: UIScreenPixel)))
var actionOffset: CGFloat = 0.0
let actionWidth: CGFloat = floor(resultSize.width / CGFloat(self.actionNodes.count))
var separatorIndex = -1
var nodeIndex = 0
for actionNode in self.actionNodes {
if separatorIndex >= 0 {
let separatorNode = self.actionVerticalSeparators[separatorIndex]
switch effectiveActionLayout {
case .horizontal:
transition.updateFrame(node: separatorNode, frame: CGRect(origin: CGPoint(x: actionOffset - UIScreenPixel, y: resultSize.height - actionsHeight), size: CGSize(width: UIScreenPixel, height: actionsHeight - UIScreenPixel)))
case .vertical:
transition.updateFrame(node: separatorNode, frame: CGRect(origin: CGPoint(x: 0.0, y: resultSize.height - actionsHeight + actionOffset - UIScreenPixel), size: CGSize(width: resultSize.width, height: UIScreenPixel)))
}
}
separatorIndex += 1
let currentActionWidth: CGFloat
switch effectiveActionLayout {
case .horizontal:
if nodeIndex == self.actionNodes.count - 1 {
currentActionWidth = resultSize.width - actionOffset
} else {
currentActionWidth = actionWidth
}
case .vertical:
currentActionWidth = resultSize.width
}
let actionNodeFrame: CGRect
switch effectiveActionLayout {
case .horizontal:
actionNodeFrame = CGRect(origin: CGPoint(x: actionOffset, y: resultSize.height - actionsHeight), size: CGSize(width: currentActionWidth, height: actionButtonHeight))
actionOffset += currentActionWidth
case .vertical:
actionNodeFrame = CGRect(origin: CGPoint(x: 0.0, y: resultSize.height - actionsHeight + actionOffset), size: CGSize(width: currentActionWidth, height: actionButtonHeight))
actionOffset += actionButtonHeight
}
transition.updateFrame(node: actionNode, frame: actionNodeFrame)
nodeIndex += 1
}
return resultSize
}
}
public func webAppLaunchConfirmationController(
context: AccountContext,
updatedPresentationData: (initial: PresentationData, signal: Signal<PresentationData, NoError>)?,
peer: EnginePeer,
requestWriteAccess: Bool = false,
completion: @escaping (Bool) -> Void,
showMore: (() -> Void)?,
openTerms: @escaping () -> Void
) -> AlertController {
let theme = defaultDarkColorPresentationTheme
let presentationData: PresentationData
if let updatedPresentationData {
presentationData = updatedPresentationData.initial
} else {
presentationData = context.sharedContext.currentPresentationData.with { $0 }
}
let strings = presentationData.strings
var dismissImpl: ((Bool) -> Void)?
var getContentNodeImpl: (() -> WebAppLaunchConfirmationAlertContentNode?)?
let actions: [TextAlertAction] = [TextAlertAction(type: .defaultAction, title: presentationData.strings.WebApp_LaunchOpenApp, action: {
if requestWriteAccess, let allowWriteAccess = getContentNodeImpl?()?.allowWriteAccess {
completion(allowWriteAccess)
} else {
completion(false)
}
dismissImpl?(true)
}), TextAlertAction(type: .genericAction, title: presentationData.strings.Common_Cancel, action: {
dismissImpl?(true)
})]
let title = peer.compactDisplayTitle
let text = presentationData.strings.WebApp_LaunchTermsConfirmation
let contentNode = WebAppLaunchConfirmationAlertContentNode(context: context, theme: AlertControllerTheme(presentationData: presentationData), ptheme: theme, strings: strings, peer: peer, title: title, text: text, showMore: showMore != nil, requestWriteAccess: requestWriteAccess, actions: actions, morePressed: {
dismissImpl?(true)
showMore?()
}, termsPressed: {
dismissImpl?(true)
openTerms()
})
getContentNodeImpl = { [weak contentNode] in
return contentNode
}
let controller = AlertController(theme: AlertControllerTheme(presentationData: presentationData), contentNode: contentNode)
dismissImpl = { [weak controller] animated in
if animated {
controller?.dismissAnimated()
} else {
controller?.dismiss()
}
}
return controller
}
@@ -0,0 +1,292 @@
import Foundation
import UIKit
import SwiftSignalKit
import AsyncDisplayKit
import Display
import Postbox
import TelegramCore
import TelegramPresentationData
import TelegramUIPreferences
import AccountContext
import AppBundle
import AvatarNode
import Markdown
import CheckNode
private func generateBoostIcon(theme: PresentationTheme) -> UIImage? {
let size = CGSize(width: 28.0, height: 28.0)
return generateImage(size, contextGenerator: { size, context in
let bounds = CGRect(origin: .zero, size: size)
context.clear(bounds)
context.addEllipse(in: bounds.insetBy(dx: 1.0, dy: 1.0))
context.clip()
var locations: [CGFloat] = [1.0, 0.0]
let colors: [CGColor] = [UIColor(rgb: 0x36c089).cgColor, UIColor(rgb: 0x3ca5eb).cgColor]
let colorSpace = CGColorSpaceCreateDeviceRGB()
let gradient = CGGradient(colorsSpace: colorSpace, colors: colors as CFArray, locations: &locations)!
context.drawLinearGradient(gradient, start: CGPoint(x: 0.0, y: 0.0), end: CGPoint(x: size.width, y: size.height), options: CGGradientDrawingOptions())
if let image = generateTintedImage(image: UIImage(bundleImageName: "Chat/Attach Menu/Location"), color: .white), let cgImage = image.cgImage {
context.draw(cgImage, in: bounds.insetBy(dx: 6.0, dy: 6.0))
}
context.resetClip()
let lineWidth = 2.0 - UIScreenPixel
context.setLineWidth(lineWidth)
context.setStrokeColor(theme.actionSheet.opaqueItemBackgroundColor.cgColor)
context.strokeEllipse(in: bounds.insetBy(dx: lineWidth / 2.0 + UIScreenPixel, dy: lineWidth / 2.0 + UIScreenPixel))
}, opaque: false)
}
private final class WebAppLocationAlertContentNode: AlertContentNode {
private let strings: PresentationStrings
private let text: String
private let textNode: ASTextNode
private let avatarNode: AvatarNode
private let arrowNode: ASImageNode
private let secondAvatarNode: AvatarNode
private let iconNode: ASImageNode
private let actionNodesSeparator: ASDisplayNode
private let actionNodes: [TextAlertContentActionNode]
private let actionVerticalSeparators: [ASDisplayNode]
private var validLayout: CGSize?
override var dismissOnOutsideTap: Bool {
return self.isUserInteractionEnabled
}
init(context: AccountContext, theme: AlertControllerTheme, ptheme: PresentationTheme, strings: PresentationStrings, accountPeer: EnginePeer, botPeer: EnginePeer, text: String, actions: [TextAlertAction]) {
self.strings = strings
self.text = text
self.textNode = ASTextNode()
self.textNode.maximumNumberOfLines = 0
self.avatarNode = AvatarNode(font: avatarPlaceholderFont(size: 26.0))
self.arrowNode = ASImageNode()
self.arrowNode.displaysAsynchronously = false
self.arrowNode.displayWithoutProcessing = true
self.secondAvatarNode = AvatarNode(font: avatarPlaceholderFont(size: 26.0))
self.iconNode = ASImageNode()
self.iconNode.displaysAsynchronously = false
self.iconNode.image = generateBoostIcon(theme: ptheme)
self.actionNodesSeparator = ASDisplayNode()
self.actionNodesSeparator.isLayerBacked = true
self.actionNodes = actions.map { action -> TextAlertContentActionNode in
return TextAlertContentActionNode(theme: theme, action: action)
}
var actionVerticalSeparators: [ASDisplayNode] = []
if actions.count > 1 {
for _ in 0 ..< actions.count - 1 {
let separatorNode = ASDisplayNode()
separatorNode.isLayerBacked = true
actionVerticalSeparators.append(separatorNode)
}
}
self.actionVerticalSeparators = actionVerticalSeparators
super.init()
self.addSubnode(self.textNode)
self.addSubnode(self.avatarNode)
self.addSubnode(self.arrowNode)
self.addSubnode(self.secondAvatarNode)
self.addSubnode(self.iconNode)
self.addSubnode(self.actionNodesSeparator)
for actionNode in self.actionNodes {
self.addSubnode(actionNode)
}
for separatorNode in self.actionVerticalSeparators {
self.addSubnode(separatorNode)
}
self.updateTheme(theme)
self.avatarNode.setPeer(context: context, theme: ptheme, peer: accountPeer)
self.secondAvatarNode.setPeer(context: context, theme: ptheme, peer: botPeer)
}
override func updateTheme(_ theme: AlertControllerTheme) {
self.textNode.attributedText = parseMarkdownIntoAttributedString(self.text, attributes: MarkdownAttributes(
body: MarkdownAttributeSet(font: Font.regular(13.0), textColor: theme.primaryColor),
bold: MarkdownAttributeSet(font: Font.semibold(13.0), textColor: theme.primaryColor),
link: MarkdownAttributeSet(font: Font.regular(13.0), textColor: theme.primaryColor),
linkAttribute: { url in
return ("URL", url)
}
), textAlignment: .center)
self.arrowNode.image = generateTintedImage(image: UIImage(bundleImageName: "Peer Info/AlertArrow"), color: theme.secondaryColor)
self.actionNodesSeparator.backgroundColor = theme.separatorColor
for actionNode in self.actionNodes {
actionNode.updateTheme(theme)
}
for separatorNode in self.actionVerticalSeparators {
separatorNode.backgroundColor = theme.separatorColor
}
if let size = self.validLayout {
_ = self.updateLayout(size: size, transition: .immediate)
}
}
override func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition) -> CGSize {
var size = size
size.width = min(size.width, 270.0)
self.validLayout = size
var origin: CGPoint = CGPoint(x: 0.0, y: 20.0)
let avatarSize = CGSize(width: 60.0, height: 60.0)
self.avatarNode.updateSize(size: avatarSize)
let avatarFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - avatarSize.width) / 2.0) - 44.0, y: origin.y), size: avatarSize)
transition.updateFrame(node: self.avatarNode, frame: avatarFrame)
if let arrowImage = self.arrowNode.image {
let arrowFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - arrowImage.size.width) / 2.0), y: origin.y + floorToScreenPixels((avatarSize.height - arrowImage.size.height) / 2.0)), size: arrowImage.size)
transition.updateFrame(node: self.arrowNode, frame: arrowFrame)
}
let secondAvatarFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - avatarSize.width) / 2.0) + 44.0, y: origin.y), size: avatarSize)
transition.updateFrame(node: self.secondAvatarNode, frame: secondAvatarFrame)
if let icon = self.iconNode.image {
let iconFrame = CGRect(origin: CGPoint(x: avatarFrame.maxX + 4.0 - icon.size.width, y: avatarFrame.maxY + 4.0 - icon.size.height), size: icon.size)
transition.updateFrame(node: self.iconNode, frame: iconFrame)
}
origin.y += avatarSize.height + 10.0
let textSize = self.textNode.measure(CGSize(width: size.width - 32.0, height: size.height))
transition.updateFrame(node: self.textNode, frame: CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - textSize.width) / 2.0), y: origin.y), size: textSize))
origin.y += textSize.height + 10.0
let actionButtonHeight: CGFloat = 44.0
var minActionsWidth: CGFloat = 0.0
let maxActionWidth: CGFloat = floor(size.width / CGFloat(self.actionNodes.count))
let actionTitleInsets: CGFloat = 8.0
var effectiveActionLayout = TextAlertContentActionLayout.horizontal
for actionNode in self.actionNodes {
let actionTitleSize = actionNode.titleNode.updateLayout(CGSize(width: maxActionWidth, height: actionButtonHeight))
if case .horizontal = effectiveActionLayout, actionTitleSize.height > actionButtonHeight * 0.6667 {
effectiveActionLayout = .vertical
}
switch effectiveActionLayout {
case .horizontal:
minActionsWidth += actionTitleSize.width + actionTitleInsets
case .vertical:
minActionsWidth = max(minActionsWidth, actionTitleSize.width + actionTitleInsets)
}
}
let insets = UIEdgeInsets(top: 18.0, left: 18.0, bottom: 18.0, right: 18.0)
let contentWidth = max(size.width, minActionsWidth)
var actionsHeight: CGFloat = 0.0
switch effectiveActionLayout {
case .horizontal:
actionsHeight = actionButtonHeight
case .vertical:
actionsHeight = actionButtonHeight * CGFloat(self.actionNodes.count)
}
let resultSize = CGSize(width: contentWidth, height: avatarSize.height + textSize.height + actionsHeight + 16.0 + insets.top + insets.bottom)
transition.updateFrame(node: self.actionNodesSeparator, frame: CGRect(origin: CGPoint(x: 0.0, y: resultSize.height - actionsHeight - UIScreenPixel), size: CGSize(width: resultSize.width, height: UIScreenPixel)))
var actionOffset: CGFloat = 0.0
let actionWidth: CGFloat = floor(resultSize.width / CGFloat(self.actionNodes.count))
var separatorIndex = -1
var nodeIndex = 0
for actionNode in self.actionNodes {
if separatorIndex >= 0 {
let separatorNode = self.actionVerticalSeparators[separatorIndex]
switch effectiveActionLayout {
case .horizontal:
transition.updateFrame(node: separatorNode, frame: CGRect(origin: CGPoint(x: actionOffset - UIScreenPixel, y: resultSize.height - actionsHeight), size: CGSize(width: UIScreenPixel, height: actionsHeight - UIScreenPixel)))
case .vertical:
transition.updateFrame(node: separatorNode, frame: CGRect(origin: CGPoint(x: 0.0, y: resultSize.height - actionsHeight + actionOffset - UIScreenPixel), size: CGSize(width: resultSize.width, height: UIScreenPixel)))
}
}
separatorIndex += 1
let currentActionWidth: CGFloat
switch effectiveActionLayout {
case .horizontal:
if nodeIndex == self.actionNodes.count - 1 {
currentActionWidth = resultSize.width - actionOffset
} else {
currentActionWidth = actionWidth
}
case .vertical:
currentActionWidth = resultSize.width
}
let actionNodeFrame: CGRect
switch effectiveActionLayout {
case .horizontal:
actionNodeFrame = CGRect(origin: CGPoint(x: actionOffset, y: resultSize.height - actionsHeight), size: CGSize(width: currentActionWidth, height: actionButtonHeight))
actionOffset += currentActionWidth
case .vertical:
actionNodeFrame = CGRect(origin: CGPoint(x: 0.0, y: resultSize.height - actionsHeight + actionOffset), size: CGSize(width: currentActionWidth, height: actionButtonHeight))
actionOffset += actionButtonHeight
}
transition.updateFrame(node: actionNode, frame: actionNodeFrame)
nodeIndex += 1
}
return resultSize
}
}
func webAppLocationAlertController(context: AccountContext, accountPeer: EnginePeer, botPeer: EnginePeer, completion: @escaping (Bool) -> Void) -> AlertController {
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
let strings = presentationData.strings
let text = strings.WebApp_LocationPermission_Text(botPeer.compactDisplayTitle, botPeer.compactDisplayTitle).string
var dismissImpl: ((Bool) -> Void)?
var contentNode: WebAppLocationAlertContentNode?
let actions: [TextAlertAction] = [TextAlertAction(type: .genericAction, title: strings.WebApp_LocationPermission_Decline, action: {
dismissImpl?(true)
completion(false)
}), TextAlertAction(type: .defaultAction, title: strings.WebApp_LocationPermission_Allow, action: {
dismissImpl?(true)
completion(true)
})]
contentNode = WebAppLocationAlertContentNode(context: context, theme: AlertControllerTheme(presentationData: presentationData), ptheme: presentationData.theme, strings: strings, accountPeer: accountPeer, botPeer: botPeer, text: text, actions: actions)
let controller = AlertController(theme: AlertControllerTheme(presentationData: presentationData), contentNode: contentNode!)
dismissImpl = { [weak controller] animated in
if animated {
controller?.dismissAnimated()
} else {
controller?.dismiss()
}
}
return controller
}
@@ -0,0 +1,406 @@
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import SwiftSignalKit
import TelegramCore
import Postbox
import TelegramPresentationData
import TelegramUIPreferences
import ItemListUI
import PresentationDataUtils
import AccountContext
import WallpaperBackgroundNode
import ListItemComponentAdaptor
import ChatMessageItemImpl
final class PeerNameColorChatPreviewItem: ListViewItem, ItemListItem, ListItemComponentAdaptor.ItemGenerator {
struct MessageItem: Equatable {
static func ==(lhs: MessageItem, rhs: MessageItem) -> Bool {
if lhs.text != rhs.text {
return false
}
if areMediaArraysEqual(lhs.media, rhs.media) {
return false
}
if lhs.botAddress != rhs.botAddress {
return false
}
return true
}
let text: String
let entities: TextEntitiesMessageAttribute?
let media: [Media]
let replyMarkup: ReplyMarkupMessageAttribute?
let botAddress: String
}
let context: AccountContext
let theme: PresentationTheme
let componentTheme: PresentationTheme
let strings: PresentationStrings
let sectionId: ItemListSectionId
let fontSize: PresentationFontSize
let chatBubbleCorners: PresentationChatBubbleCorners
let wallpaper: TelegramWallpaper
let dateTimeFormat: PresentationDateTimeFormat
let nameDisplayOrder: PresentationPersonNameOrder
let messageItems: [MessageItem]
init(context: AccountContext, theme: PresentationTheme, componentTheme: PresentationTheme, strings: PresentationStrings, sectionId: ItemListSectionId, fontSize: PresentationFontSize, chatBubbleCorners: PresentationChatBubbleCorners, wallpaper: TelegramWallpaper, dateTimeFormat: PresentationDateTimeFormat, nameDisplayOrder: PresentationPersonNameOrder, messageItems: [MessageItem]) {
self.context = context
self.theme = theme
self.componentTheme = componentTheme
self.strings = strings
self.sectionId = sectionId
self.fontSize = fontSize
self.chatBubbleCorners = chatBubbleCorners
self.wallpaper = wallpaper
self.dateTimeFormat = dateTimeFormat
self.nameDisplayOrder = nameDisplayOrder
self.messageItems = messageItems
}
func nodeConfiguredForParams(async: @escaping (@escaping () -> Void) -> Void, params: ListViewItemLayoutParams, synchronousLoads: Bool, previousItem: ListViewItem?, nextItem: ListViewItem?, completion: @escaping (ListViewItemNode, @escaping () -> (Signal<Void, NoError>?, (ListViewItemApply) -> Void)) -> Void) {
async {
let node = PeerNameColorChatPreviewItemNode()
let (layout, apply) = node.asyncLayout()(self, params, itemListNeighbors(item: self, topItem: previousItem as? ItemListItem, bottomItem: nextItem as? ItemListItem))
node.contentSize = layout.contentSize
node.insets = layout.insets
Queue.mainQueue().async {
completion(node, {
return (nil, { _ in apply() })
})
}
}
}
func updateNode(async: @escaping (@escaping () -> Void) -> Void, node: @escaping () -> ListViewItemNode, params: ListViewItemLayoutParams, previousItem: ListViewItem?, nextItem: ListViewItem?, animation: ListViewItemUpdateAnimation, completion: @escaping (ListViewItemNodeLayout, @escaping (ListViewItemApply) -> Void) -> Void) {
Queue.mainQueue().async {
if let nodeValue = node() as? PeerNameColorChatPreviewItemNode {
let makeLayout = nodeValue.asyncLayout()
async {
let (layout, apply) = makeLayout(self, params, itemListNeighbors(item: self, topItem: previousItem as? ItemListItem, bottomItem: nextItem as? ItemListItem))
Queue.mainQueue().async {
completion(layout, { _ in
apply()
})
}
}
}
}
}
public func item() -> ListViewItem {
return self
}
public static func ==(lhs: PeerNameColorChatPreviewItem, rhs: PeerNameColorChatPreviewItem) -> Bool {
if lhs.context !== rhs.context {
return false
}
if lhs.theme !== rhs.theme {
return false
}
if lhs.componentTheme !== rhs.componentTheme {
return false
}
if lhs.strings !== rhs.strings {
return false
}
if lhs.fontSize != rhs.fontSize {
return false
}
if lhs.chatBubbleCorners != rhs.chatBubbleCorners {
return false
}
if lhs.wallpaper != rhs.wallpaper {
return false
}
if lhs.dateTimeFormat != rhs.dateTimeFormat {
return false
}
if lhs.nameDisplayOrder != rhs.nameDisplayOrder {
return false
}
if lhs.messageItems != rhs.messageItems {
return false
}
return true
}
}
final class PeerNameColorChatPreviewItemNode: ListViewItemNode {
private var backgroundNode: WallpaperBackgroundNode?
private let topStripeNode: ASDisplayNode
private let bottomStripeNode: ASDisplayNode
private let maskNode: ASImageNode
private let containerNode: ASDisplayNode
private var messageNodes: [ListViewItemNode]?
private var itemHeaderNodes: [ListViewItemNode.HeaderId: ListViewItemHeaderNode] = [:]
private var item: PeerNameColorChatPreviewItem?
private let disposable = MetaDisposable()
init() {
self.topStripeNode = ASDisplayNode()
self.topStripeNode.isLayerBacked = true
self.bottomStripeNode = ASDisplayNode()
self.bottomStripeNode.isLayerBacked = true
self.maskNode = ASImageNode()
self.containerNode = ASDisplayNode()
self.containerNode.subnodeTransform = CATransform3DMakeRotation(CGFloat.pi, 0.0, 0.0, 1.0)
super.init(layerBacked: false, dynamicBounce: false)
self.clipsToBounds = true
self.isUserInteractionEnabled = false
self.addSubnode(self.containerNode)
}
deinit {
self.disposable.dispose()
}
func asyncLayout() -> (_ item: PeerNameColorChatPreviewItem, _ params: ListViewItemLayoutParams, _ neighbors: ItemListNeighbors) -> (ListViewItemNodeLayout, () -> Void) {
let currentNodes = self.messageNodes
var currentBackgroundNode = self.backgroundNode
return { item, params, neighbors in
if currentBackgroundNode == nil {
currentBackgroundNode = createWallpaperBackgroundNode(context: item.context, forChatDisplay: false)
currentBackgroundNode?.update(wallpaper: item.wallpaper, animated: false)
currentBackgroundNode?.updateBubbleTheme(bubbleTheme: item.componentTheme, bubbleCorners: item.chatBubbleCorners)
}
var insets: UIEdgeInsets
let separatorHeight = UIScreenPixel
var items: [ListViewItem] = []
for messageItem in item.messageItems.reversed() {
let authorPeerId = EnginePeer.Id(namespace: Namespaces.Peer.CloudUser, id: PeerId.Id._internalFromInt64Value(0))
let botPeerId = EnginePeer.Id(namespace: Namespaces.Peer.CloudUser, id: PeerId.Id._internalFromInt64Value(1))
var peers = SimpleDictionary<PeerId, Peer>()
let messages = SimpleDictionary<MessageId, Message>()
peers[authorPeerId] = TelegramUser(id: authorPeerId, accessHash: nil, firstName: nil, lastName: nil, username: nil, phone: nil, photo: [], botInfo: nil, restrictionInfo: nil, flags: [], emojiStatus: nil, usernames: [], storiesHidden: nil, nameColor: nil, backgroundEmojiId: nil, profileColor: nil, profileBackgroundEmojiId: nil, subscriberCount: nil, verificationIconFileId: nil)
peers[botPeerId] = TelegramUser(id: botPeerId, accessHash: nil, firstName: messageItem.botAddress, lastName: "", username: messageItem.botAddress, phone: nil, photo: [], botInfo: nil, restrictionInfo: nil, flags: [], emojiStatus: nil, usernames: [], storiesHidden: nil, nameColor: nil, backgroundEmojiId: nil, profileColor: nil, profileBackgroundEmojiId: nil, subscriberCount: nil, verificationIconFileId: nil)
let media = messageItem.media
var attributes: [MessageAttribute] = []
if let entities = messageItem.entities {
attributes.append(entities)
}
if let replyMarkup = messageItem.replyMarkup {
attributes.append(replyMarkup)
}
attributes.append(InlineBotMessageAttribute(peerId: botPeerId, title: nil))
let message = Message(stableId: 1, stableVersion: 0, id: MessageId(peerId: authorPeerId, namespace: 0, id: 1), globallyUniqueId: nil, groupingKey: nil, groupInfo: nil, threadId: nil, timestamp: 66000, flags: [.Incoming], tags: [], globalTags: [], localTags: [], customTags: [], forwardInfo: nil, author: peers[authorPeerId], text: messageItem.text, attributes: attributes, media: media, peers: peers, associatedMessages: messages, associatedMessageIds: [], associatedMedia: [:], associatedThreadInfo: nil, associatedStories: [:])
items.append(item.context.sharedContext.makeChatMessagePreviewItem(context: item.context, messages: [message], theme: item.componentTheme, strings: item.strings, wallpaper: item.wallpaper, fontSize: item.fontSize, chatBubbleCorners: item.chatBubbleCorners, dateTimeFormat: item.dateTimeFormat, nameOrder: item.nameDisplayOrder, forcedResourceStatus: nil, tapMessage: nil, clickThroughMessage: nil, backgroundNode: currentBackgroundNode, availableReactions: nil, accountPeer: nil, isCentered: false, isPreview: true, isStandalone: false))
}
var nodes: [ListViewItemNode] = []
if let messageNodes = currentNodes {
nodes = messageNodes
for i in 0 ..< items.count {
let itemNode = messageNodes[i]
items[i].updateNode(async: { $0() }, node: {
return itemNode
}, params: params, previousItem: i == 0 ? nil : items[i - 1], nextItem: i == (items.count - 1) ? nil : items[i + 1], animation: .None, completion: { (layout, apply) in
let nodeFrame = CGRect(origin: itemNode.frame.origin, size: CGSize(width: layout.size.width, height: layout.size.height))
itemNode.contentSize = layout.contentSize
itemNode.insets = layout.insets
itemNode.frame = nodeFrame
itemNode.isUserInteractionEnabled = false
Queue.mainQueue().after(0.01) {
apply(ListViewItemApply(isOnScreen: true))
}
})
}
} else {
var messageNodes: [ListViewItemNode] = []
for i in 0 ..< items.count {
var itemNode: ListViewItemNode?
items[i].nodeConfiguredForParams(async: { $0() }, params: params, synchronousLoads: false, previousItem: i == 0 ? nil : items[i - 1], nextItem: i == (items.count - 1) ? nil : items[i + 1], completion: { node, apply in
itemNode = node
apply().1(ListViewItemApply(isOnScreen: true))
})
itemNode!.isUserInteractionEnabled = false
messageNodes.append(itemNode!)
}
nodes = messageNodes
}
var contentSize = CGSize(width: params.width, height: 4.0 + 4.0)
for node in nodes {
contentSize.height += node.frame.size.height
}
insets = itemListNeighborsGroupedInsets(neighbors, params)
if params.width <= 320.0 {
insets.top = 0.0
}
let layout = ListViewItemNodeLayout(contentSize: contentSize, insets: insets)
let layoutSize = layout.size
let leftInset = params.leftInset
let rightInset = params.leftInset
return (layout, { [weak self] in
if let strongSelf = self {
strongSelf.item = item
if let currentBackgroundNode {
currentBackgroundNode.update(wallpaper: item.wallpaper, animated: false)
currentBackgroundNode.updateBubbleTheme(bubbleTheme: item.theme, bubbleCorners: item.chatBubbleCorners)
}
strongSelf.containerNode.frame = CGRect(origin: CGPoint(), size: contentSize)
strongSelf.messageNodes = nodes
var topOffset: CGFloat = 4.0
for node in nodes {
if node.supernode == nil {
strongSelf.containerNode.addSubnode(node)
}
node.updateFrame(CGRect(origin: CGPoint(x: 0.0, y: topOffset), size: node.frame.size), within: layoutSize)
topOffset += node.frame.size.height
if let header = node.headers()?.first(where: { $0 is ChatMessageAvatarHeader }) {
let headerFrame = CGRect(origin: CGPoint(x: 0.0, y: 3.0 + node.frame.minY), size: CGSize(width: layoutSize.width, height: header.height))
let stickLocationDistanceFactor: CGFloat = 0.0
let id = header.id
let headerNode: ListViewItemHeaderNode
if let current = strongSelf.itemHeaderNodes[id] {
headerNode = current
headerNode.updateFrame(headerFrame, within: layoutSize)
if headerNode.item !== header {
header.updateNode(headerNode, previous: nil, next: nil)
headerNode.item = header
}
headerNode.updateLayoutInternal(size: headerFrame.size, leftInset: leftInset, rightInset: rightInset, transition: .immediate)
headerNode.updateStickDistanceFactor(stickLocationDistanceFactor, distance: 0.0, transition: .immediate)
} else {
headerNode = header.node(synchronousLoad: true)
if headerNode.item !== header {
header.updateNode(headerNode, previous: nil, next: nil)
headerNode.item = header
}
headerNode.frame = headerFrame
headerNode.updateLayoutInternal(size: headerFrame.size, leftInset: leftInset, rightInset: rightInset, transition: .immediate)
strongSelf.itemHeaderNodes[id] = headerNode
strongSelf.containerNode.addSubnode(headerNode)
headerNode.updateStickDistanceFactor(stickLocationDistanceFactor, distance: 0.0, transition: .immediate)
}
}
}
if let currentBackgroundNode = currentBackgroundNode, strongSelf.backgroundNode !== currentBackgroundNode {
strongSelf.backgroundNode = currentBackgroundNode
strongSelf.insertSubnode(currentBackgroundNode, at: 0)
}
strongSelf.topStripeNode.backgroundColor = item.theme.list.itemBlocksSeparatorColor
strongSelf.bottomStripeNode.backgroundColor = item.theme.list.itemBlocksSeparatorColor
if strongSelf.topStripeNode.supernode == nil {
strongSelf.insertSubnode(strongSelf.topStripeNode, at: 1)
}
if strongSelf.bottomStripeNode.supernode == nil {
strongSelf.insertSubnode(strongSelf.bottomStripeNode, at: 2)
}
if strongSelf.maskNode.supernode == nil {
strongSelf.insertSubnode(strongSelf.maskNode, at: 3)
}
if params.isStandalone {
strongSelf.topStripeNode.isHidden = true
strongSelf.bottomStripeNode.isHidden = true
strongSelf.maskNode.isHidden = true
} else {
let hasCorners = itemListHasRoundedBlockLayout(params)
var hasTopCorners = false
var hasBottomCorners = false
switch neighbors.top {
case .sameSection(false):
strongSelf.topStripeNode.isHidden = true
default:
hasTopCorners = true
strongSelf.topStripeNode.isHidden = hasCorners
}
let bottomStripeInset: CGFloat
let bottomStripeOffset: CGFloat
switch neighbors.bottom {
case .sameSection(false):
bottomStripeInset = 0.0
bottomStripeOffset = -separatorHeight
strongSelf.bottomStripeNode.isHidden = false
default:
bottomStripeInset = 0.0
bottomStripeOffset = 0.0
hasBottomCorners = true
strongSelf.bottomStripeNode.isHidden = hasCorners
}
strongSelf.maskNode.image = hasCorners ? PresentationResourcesItemList.cornersImage(item.componentTheme, top: hasTopCorners, bottom: hasBottomCorners) : nil
strongSelf.topStripeNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -min(insets.top, separatorHeight)), size: CGSize(width: layoutSize.width, height: separatorHeight))
strongSelf.bottomStripeNode.frame = CGRect(origin: CGPoint(x: bottomStripeInset, y: contentSize.height + bottomStripeOffset), size: CGSize(width: layoutSize.width - bottomStripeInset, height: separatorHeight))
}
let backgroundFrame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: params.width, height: contentSize.height + min(insets.top, separatorHeight) + min(insets.bottom, separatorHeight)))
let displayMode: WallpaperDisplayMode
if abs(params.availableHeight - params.width) < 100.0, params.availableHeight > 700.0 {
displayMode = .halfAspectFill
} else {
if backgroundFrame.width > backgroundFrame.height * 4.0 {
if params.availableHeight < 700.0 {
displayMode = .halfAspectFill
} else {
displayMode = .aspectFill
}
} else {
displayMode = .aspectFill
}
}
if let backgroundNode = strongSelf.backgroundNode {
backgroundNode.frame = backgroundFrame
backgroundNode.updateLayout(size: backgroundNode.bounds.size, displayMode: displayMode, transition: .immediate)
}
strongSelf.maskNode.frame = backgroundFrame.insetBy(dx: params.leftInset, dy: 0.0)
}
})
}
}
override func animateInsertion(_ currentTimestamp: Double, duration: Double, options: ListViewItemAnimationOptions) {
self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.4)
}
override func animateRemoved(_ currentTimestamp: Double, duration: Double) {
self.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false)
}
}
@@ -0,0 +1,568 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import ComponentFlow
import SwiftSignalKit
import Postbox
import TelegramCore
import Markdown
import TextFormat
import TelegramPresentationData
import ViewControllerComponent
import SheetComponent
import BalancedTextComponent
import MultilineTextComponent
import BundleIconComponent
import ButtonComponent
import ItemListUI
import AccountContext
import PresentationDataUtils
import ListSectionComponent
import ListItemComponentAdaptor
import TelegramStringFormatting
import UndoUI
import ChatMessagePaymentAlertController
private final class SheetContent: CombinedComponent {
typealias EnvironmentType = ViewControllerComponentContainer.Environment
let context: AccountContext
let botName: String
let botAddress: String
let preparedMessage: PreparedInlineMessage
let dismiss: () -> Void
init(
context: AccountContext,
botName: String,
botAddress: String,
preparedMessage: PreparedInlineMessage,
dismiss: @escaping () -> Void
) {
self.context = context
self.botName = botName
self.botAddress = botAddress
self.preparedMessage = preparedMessage
self.dismiss = dismiss
}
static func ==(lhs: SheetContent, rhs: SheetContent) -> Bool {
if lhs.context !== rhs.context {
return false
}
return true
}
static var body: Body {
let closeButton = Child(Button.self)
let title = Child(Text.self)
let amountSection = Child(ListSectionComponent.self)
let button = Child(ButtonComponent.self)
return { context in
let environment = context.environment[EnvironmentType.self]
let component = context.component
let controller = environment.controller
let theme = environment.theme.withModalBlocksBackground()
let strings = environment.strings
let presentationData = component.context.sharedContext.currentPresentationData.with { $0 }
let sideInset: CGFloat = 16.0
var contentSize = CGSize(width: context.availableSize.width, height: 18.0)
let constrainedTitleWidth = context.availableSize.width - 16.0 * 2.0
let closeButton = closeButton.update(
component: Button(
content: AnyComponent(Text(text: environment.strings.Common_Cancel, font: Font.regular(17.0), color: theme.actionSheet.controlAccentColor)),
action: {
component.dismiss()
}
),
availableSize: CGSize(width: 120.0, height: 30.0),
transition: .immediate
)
context.add(closeButton
.position(CGPoint(x: closeButton.size.width / 2.0 + sideInset, y: 28.0))
)
let title = title.update(
component: Text(text: environment.strings.WebApp_ShareMessage_Title, font: Font.bold(17.0), color: theme.list.itemPrimaryTextColor),
availableSize: CGSize(width: constrainedTitleWidth, height: context.availableSize.height),
transition: .immediate
)
context.add(title
.position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + title.size.height / 2.0))
)
contentSize.height += title.size.height
contentSize.height += 40.0
let amountFont = Font.regular(13.0)
let amountTextColor = theme.list.freeTextColor
let amountMarkdownAttributes = MarkdownAttributes(body: MarkdownAttributeSet(font: amountFont, textColor: amountTextColor), bold: MarkdownAttributeSet(font: amountFont, textColor: amountTextColor), link: MarkdownAttributeSet(font: amountFont, textColor: theme.list.itemAccentColor), linkAttribute: { contents in
return (TelegramTextAttributes.URL, contents)
})
let amountInfoString = NSMutableAttributedString(attributedString: parseMarkdownIntoAttributedString(environment.strings.WebApp_ShareMessage_Info(component.botName).string, attributes: amountMarkdownAttributes, textAlignment: .natural))
let amountFooter = AnyComponent(MultilineTextComponent(
text: .plain(amountInfoString),
maximumNumberOfLines: 0,
highlightColor: environment.theme.list.itemAccentColor.withAlphaComponent(0.1),
highlightInset: UIEdgeInsets(top: 0.0, left: 0.0, bottom: 0.0, right: -8.0),
highlightAction: { attributes in
if let _ = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.URL)] {
return NSAttributedString.Key(rawValue: TelegramTextAttributes.URL)
} else {
return nil
}
},
tapAction: { attributes, _ in
if let controller = controller() as? WebAppMessagePreviewScreen, let navigationController = controller.navigationController as? NavigationController {
component.context.sharedContext.openExternalUrl(context: component.context, urlContext: .generic, url: strings.Stars_PaidContent_AmountInfo_URL, forceExternal: false, presentationData: presentationData, navigationController: navigationController, dismissInput: {})
}
}
))
var text: String = ""
var entities: TextEntitiesMessageAttribute?
var media: [Media] = []
var replyMarkup: ReplyMarkupMessageAttribute?
switch component.preparedMessage.result {
case let .internalReference(reference):
switch reference.message {
case let .auto(textValue, entitiesValue, replyMarkupValue):
text = textValue
entities = entitiesValue
if let file = reference.file {
media = [file]
} else if let image = reference.image {
media = [image]
}
replyMarkup = replyMarkupValue
case let .text(textValue, entitiesValue, disableUrlPreview, previewParameters, replyMarkupValue):
text = textValue
entities = entitiesValue
let _ = disableUrlPreview
let _ = previewParameters
replyMarkup = replyMarkupValue
case let .contact(contact, replyMarkupValue):
media = [contact]
replyMarkup = replyMarkupValue
case let .mapLocation(map, replyMarkupValue):
media = [map]
replyMarkup = replyMarkupValue
case let .invoice(invoice, replyMarkupValue):
media = [invoice]
replyMarkup = replyMarkupValue
default:
break
}
case let .externalReference(reference):
switch reference.message {
case let .auto(textValue, entitiesValue, replyMarkupValue):
text = textValue
entities = entitiesValue
if let content = reference.content {
media = [content]
}
replyMarkup = replyMarkupValue
case let .text(textValue, entitiesValue, disableUrlPreview, previewParameters, replyMarkupValue):
text = textValue
entities = entitiesValue
let _ = disableUrlPreview
let _ = previewParameters
replyMarkup = replyMarkupValue
case let .contact(contact, replyMarkupValue):
media = [contact]
replyMarkup = replyMarkupValue
case let .mapLocation(map, replyMarkupValue):
media = [map]
replyMarkup = replyMarkupValue
case let .invoice(invoice, replyMarkupValue):
media = [invoice]
replyMarkup = replyMarkupValue
default:
break
}
}
let messageItem = PeerNameColorChatPreviewItem.MessageItem(
text: text,
entities: entities,
media: media,
replyMarkup: replyMarkup,
botAddress: component.botAddress
)
let listItemParams = ListViewItemLayoutParams(width: context.availableSize.width - sideInset * 2.0, leftInset: 0.0, rightInset: 0.0, availableHeight: 10000.0, isStandalone: true)
let amountSection = amountSection.update(
component: ListSectionComponent(
theme: theme,
header: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(
string: environment.strings.WebApp_ShareMessage_PreviewTitle.uppercased(),
font: Font.regular(presentationData.listsFontSize.itemListBaseHeaderFontSize),
textColor: theme.list.freeTextColor
)),
maximumNumberOfLines: 0
)),
footer: amountFooter,
items: [
AnyComponentWithIdentity(id: 0, component: AnyComponent(ListItemComponentAdaptor(
itemGenerator: PeerNameColorChatPreviewItem(
context: component.context,
theme: environment.theme,
componentTheme: environment.theme,
strings: environment.strings,
sectionId: 0,
fontSize: presentationData.chatFontSize,
chatBubbleCorners: presentationData.chatBubbleCorners,
wallpaper: presentationData.chatWallpaper,
dateTimeFormat: environment.dateTimeFormat,
nameDisplayOrder: presentationData.nameDisplayOrder,
messageItems: [messageItem]
),
params: listItemParams
)))
]
),
environment: {},
availableSize: CGSize(width: context.availableSize.width - sideInset * 2.0, height: .greatestFiniteMagnitude),
transition: context.transition
)
context.add(amountSection
.position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + amountSection.size.height / 2.0))
.clipsToBounds(true)
.cornerRadius(10.0)
)
contentSize.height += amountSection.size.height
contentSize.height += 32.0
let buttonString: String = environment.strings.WebApp_ShareMessage_Share
let buttonAttributedString = NSMutableAttributedString(string: buttonString, font: Font.semibold(17.0), textColor: theme.list.itemCheckColors.foregroundColor, paragraphAlignment: .center)
let button = button.update(
component: ButtonComponent(
background: ButtonComponent.Background(
color: theme.list.itemCheckColors.fillColor,
foreground: theme.list.itemCheckColors.foregroundColor,
pressedColor: theme.list.itemCheckColors.fillColor.withMultipliedAlpha(0.9),
cornerRadius: 10.0
),
content: AnyComponentWithIdentity(
id: AnyHashable(0),
component: AnyComponent(MultilineTextComponent(text: .plain(buttonAttributedString)))
),
isEnabled: true,
displaysProgress: false,
action: {
if let controller = controller() as? WebAppMessagePreviewScreen {
controller.proceed()
}
}
),
availableSize: CGSize(width: context.availableSize.width - sideInset * 2.0, height: 50),
transition: .immediate
)
context.add(button
.clipsToBounds(true)
.cornerRadius(10.0)
.position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + button.size.height / 2.0))
)
contentSize.height += button.size.height
contentSize.height += 15.0
contentSize.height += max(environment.inputHeight, environment.safeInsets.bottom)
return contentSize
}
}
final class State: ComponentState {
var cachedCloseImage: (UIImage, PresentationTheme)?
}
func makeState() -> State {
return State()
}
}
private final class WebAppMessagePreviewSheetComponent: CombinedComponent {
typealias EnvironmentType = ViewControllerComponentContainer.Environment
private let context: AccountContext
private let botName: String
private let botAddress: String
private let preparedMessage: PreparedInlineMessage
init(
context: AccountContext,
botName: String,
botAddress: String,
preparedMessage: PreparedInlineMessage
) {
self.context = context
self.botName = botName
self.botAddress = botAddress
self.preparedMessage = preparedMessage
}
static func ==(lhs: WebAppMessagePreviewSheetComponent, rhs: WebAppMessagePreviewSheetComponent) -> Bool {
if lhs.context !== rhs.context {
return false
}
return true
}
static var body: Body {
let sheet = Child(SheetComponent<(EnvironmentType)>.self)
let animateOut = StoredActionSlot(Action<Void>.self)
return { context in
let environment = context.environment[EnvironmentType.self]
let controller = environment.controller
let sheet = sheet.update(
component: SheetComponent<EnvironmentType>(
content: AnyComponent<EnvironmentType>(SheetContent(
context: context.component.context,
botName: context.component.botName,
botAddress: context.component.botAddress,
preparedMessage: context.component.preparedMessage,
dismiss: {
animateOut.invoke(Action { _ in
if let controller = controller() as? WebAppMessagePreviewScreen {
controller.completeWithResult(false)
controller.dismiss(completion: nil)
}
})
}
)),
backgroundColor: .color(environment.theme.list.blocksBackgroundColor),
followContentSizeChanges: false,
clipsContent: true,
isScrollEnabled: false,
animateOut: animateOut
),
environment: {
environment
SheetComponentEnvironment(
isDisplaying: environment.value.isVisible,
isCentered: environment.metrics.widthClass == .regular,
hasInputHeight: !environment.inputHeight.isZero,
regularMetricsSize: CGSize(width: 430.0, height: 900.0),
dismiss: { animated in
if animated {
animateOut.invoke(Action { _ in
if let controller = controller() as? WebAppMessagePreviewScreen {
controller.completeWithResult(false)
controller.dismiss(completion: nil)
}
})
} else {
if let controller = controller() as? WebAppMessagePreviewScreen {
controller.completeWithResult(false)
controller.dismiss(completion: nil)
}
}
}
)
},
availableSize: context.availableSize,
transition: context.transition
)
context.add(sheet
.position(CGPoint(x: context.availableSize.width / 2.0, y: context.availableSize.height / 2.0))
)
return context.availableSize
}
}
}
public final class WebAppMessagePreviewScreen: ViewControllerComponentContainer {
private let context: AccountContext
private let preparedMessage: PreparedInlineMessage
fileprivate let completion: (Bool) -> Void
public init(
context: AccountContext,
botName: String,
botAddress: String,
preparedMessage: PreparedInlineMessage,
completion: @escaping (Bool) -> Void
) {
self.context = context
self.preparedMessage = preparedMessage
self.completion = completion
super.init(
context: context,
component: WebAppMessagePreviewSheetComponent(
context: context,
botName: botName,
botAddress: botAddress,
preparedMessage: preparedMessage
),
navigationBarAppearance: .none,
statusBarStyle: .ignore,
theme: .default
)
self.navigationPresentation = .flatModal
}
required public init(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func presentPaidMessageAlertIfNeeded(peers: [EnginePeer], requiresStars: [EnginePeer.Id: Int64], completion: @escaping () -> Void) {
}
fileprivate func complete(peers: [EnginePeer], controller: ViewController?) {
let _ = (self.context.engine.data.get(
EngineDataMap(
peers.map { TelegramEngine.EngineData.Item.Peer.SendPaidMessageStars.init(id: $0.id) }
),
EngineDataList(
peers.map { TelegramEngine.EngineData.Item.Peer.RenderedPeer.init(id: $0.id) }
)
)
|> deliverOnMainQueue).start(next: { [weak self] sendPaidMessageStars, renderedPeers in
guard let self else {
return
}
let renderedPeers = renderedPeers.compactMap({ $0 })
var totalAmount: StarsAmount = .zero
var chargingPeers: [EngineRenderedPeer] = []
for peer in renderedPeers {
if let maybeAmount = sendPaidMessageStars[peer.peerId], let amount = maybeAmount {
totalAmount = totalAmount + amount
chargingPeers.append(peer)
}
}
let presentationData = self.context.sharedContext.currentPresentationData.with { $0 }
let proceed = { [weak self] in
guard let self else {
return
}
for peer in peers {
var starsAmount: StarsAmount?
if let maybeAmount = sendPaidMessageStars[peer.id], let amount = maybeAmount {
starsAmount = amount
}
let _ = self.context.engine.messages.enqueueOutgoingMessage(
to: peer.id,
replyTo: nil,
storyId: nil,
content: .preparedInlineMessage(self.preparedMessage),
sendPaidMessageStars: starsAmount
).start()
}
let text: String
if peers.count == 1, let peer = peers.first {
let peerName = peer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder)
text = presentationData.strings.Conversation_ForwardTooltip_Chat_One(peerName).string
} else if peers.count == 2, let firstPeer = peers.first, let secondPeer = peers.last {
let firstPeerName = firstPeer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder)
let secondPeerName = secondPeer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder)
text = presentationData.strings.Conversation_ForwardTooltip_TwoChats_One(firstPeerName, secondPeerName).string
} else if let peer = peers.first {
let peerName = peer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder)
text = presentationData.strings.Conversation_ForwardTooltip_ManyChats_One(peerName, "\(peers.count - 1)").string
} else {
text = ""
}
if let navigationController = self.navigationController as? NavigationController {
Queue.mainQueue().after(1.0) {
guard let lastController = navigationController.viewControllers.last as? ViewController else {
return
}
lastController.present(UndoOverlayController(presentationData: presentationData, content: .forward(savedMessages: false, text: text), elevatedLayout: false, position: .top, animateInAsReplacement: true, action: { action in
return false
}), in: .window(.root))
}
}
self.completeWithResult(true)
self.dismiss()
controller?.dismiss()
}
if totalAmount.value > 0 {
let controller = chatMessagePaymentAlertController(
context: nil,
presentationData: presentationData,
updatedPresentationData: nil,
peers: chargingPeers,
count: 1,
amount: totalAmount,
totalAmount: totalAmount,
hasCheck: false,
navigationController: self.navigationController as? NavigationController,
completion: { _ in
proceed()
}
)
self.present(controller, in: .window(.root))
} else {
proceed()
}
})
}
private var completed = false
fileprivate func completeWithResult(_ result: Bool) {
guard !self.completed else {
return
}
self.completion(result)
}
fileprivate func proceed() {
let peerTypes = self.preparedMessage.peerTypes
var types: [ReplyMarkupButtonRequestPeerType] = []
if peerTypes.contains(.users) {
types.append(.user(.init(isBot: false, isPremium: nil)))
}
if peerTypes.contains(.bots) {
types.append(.user(.init(isBot: true, isPremium: nil)))
}
if peerTypes.contains(.channels) {
types.append(.channel(.init(isCreator: false, hasUsername: nil, userAdminRights: TelegramChatAdminRights(rights: [.canPostMessages]), botAdminRights: nil)))
}
if peerTypes.contains(.groups) {
types.append(.group(.init(isCreator: false, hasUsername: nil, isForum: nil, botParticipant: false, userAdminRights: nil, botAdminRights: nil)))
}
let controller = self.context.sharedContext.makePeerSelectionController(PeerSelectionControllerParams(context: self.context, filter: [.excludeRecent, .doNotSearchMessages], requestPeerType: types, hasContactSelector: false, multipleSelection: true, selectForumThreads: true, immediatelyActivateMultipleSelection: true))
controller.multiplePeersSelected = { [weak self, weak controller] peers, _, _, _, _, _ in
guard let self else {
return
}
self.complete(peers: peers, controller: controller)
}
self.push(controller)
}
public func dismissAnimated() {
self.completeWithResult(false)
if let view = self.node.hostView.findTaggedView(tag: SheetComponent<ViewControllerComponentContainer.Environment>.View.Tag()) as? SheetComponent<ViewControllerComponentContainer.Environment>.View {
view.dismissAnimated()
}
}
}
@@ -0,0 +1,130 @@
import Foundation
import NaturalLanguage
import SwiftSignalKit
import TelegramCore
import AccountContext
import TelegramUIPreferences
public struct WebAppPermissionsState: Codable {
enum CodingKeys: String, CodingKey {
case location
case emojiStatus
}
public struct Location: Codable {
enum CodingKeys: String, CodingKey {
case isRequested
case isAllowed
}
public let isRequested: Bool
public let isAllowed: Bool
public init(
isRequested: Bool,
isAllowed: Bool
) {
self.isRequested = isRequested
self.isAllowed = isAllowed
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.isRequested = try container.decode(Bool.self, forKey: .isRequested)
self.isAllowed = try container.decode(Bool.self, forKey: .isAllowed)
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(self.isRequested, forKey: .isRequested)
try container.encode(self.isAllowed, forKey: .isAllowed)
}
}
public struct EmojiStatus: Codable {
enum CodingKeys: String, CodingKey {
case isRequested
}
public let isRequested: Bool
public init(
isRequested: Bool
) {
self.isRequested = isRequested
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.isRequested = try container.decode(Bool.self, forKey: .isRequested)
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(self.isRequested, forKey: .isRequested)
}
}
public let location: Location?
public let emojiStatus: EmojiStatus?
public init(
location: Location?,
emojiStatus: EmojiStatus?
) {
self.location = location
self.emojiStatus = emojiStatus
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.location = try container.decodeIfPresent(WebAppPermissionsState.Location.self, forKey: .location)
self.emojiStatus = try container.decodeIfPresent(WebAppPermissionsState.EmojiStatus.self, forKey: .emojiStatus)
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encodeIfPresent(self.location, forKey: .location)
try container.encodeIfPresent(self.emojiStatus, forKey: .emojiStatus)
}
}
public func webAppPermissionsState(context: AccountContext, peerId: EnginePeer.Id) -> Signal<WebAppPermissionsState?, NoError> {
let key = EngineDataBuffer(length: 8)
key.setInt64(0, value: peerId.id._internalGetInt64Value())
return context.engine.data.subscribe(TelegramEngine.EngineData.Item.ItemCache.Item(collectionId: ApplicationSpecificItemCacheCollectionId.webAppPermissionsState, id: key))
|> map { entry -> WebAppPermissionsState? in
return entry?.get(WebAppPermissionsState.self)
}
}
private func updateWebAppPermissionsState(context: AccountContext, peerId: EnginePeer.Id, state: WebAppPermissionsState?) -> Signal<Never, NoError> {
let key = EngineDataBuffer(length: 8)
key.setInt64(0, value: peerId.id._internalGetInt64Value())
if let state {
return context.engine.itemCache.put(collectionId: ApplicationSpecificItemCacheCollectionId.webAppPermissionsState, id: key, item: state)
} else {
return context.engine.itemCache.remove(collectionId: ApplicationSpecificItemCacheCollectionId.webAppPermissionsState, id: key)
}
}
public func updateWebAppPermissionsStateInteractively(context: AccountContext, peerId: EnginePeer.Id, _ f: @escaping (WebAppPermissionsState?) -> WebAppPermissionsState?) -> Signal<Never, NoError> {
let key = EngineDataBuffer(length: 8)
key.setInt64(0, value: peerId.id._internalGetInt64Value())
return context.engine.data.get(TelegramEngine.EngineData.Item.ItemCache.Item(collectionId: ApplicationSpecificItemCacheCollectionId.webAppPermissionsState, id: key))
|> map { entry -> WebAppPermissionsState? in
return entry?.get(WebAppPermissionsState.self)
}
|> mapToSignal { current -> Signal<Never, NoError> in
return updateWebAppPermissionsState(context: context, peerId: peerId, state: f(current))
}
}
@@ -0,0 +1,303 @@
import Foundation
import Security
import SwiftSignalKit
import TelegramCore
import AccountContext
final class WebAppSecureStorage {
enum Error {
case quotaExceeded
case canRestore
case storageNotEmpty
case unknown
}
struct StorageValue: Codable {
let timestamp: Int32
let accountName: String
let value: String
}
static private let maxKeyCount = 10
private init() {
}
static private func keyPrefix(uuid: String, botId: EnginePeer.Id) -> String {
return "WebBot\(UInt64(bitPattern: botId.toInt64()))U\(uuid)Key_"
}
static private func makeQuery(uuid: String, botId: EnginePeer.Id, key: String) -> [String: Any] {
let identifier = self.keyPrefix(uuid: uuid, botId: botId) + key
return [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: identifier,
kSecAttrService as String: "TMASecureStorage"
]
}
static private func countKeys(uuid: String, botId: EnginePeer.Id) -> Int {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "TMASecureStorage",
kSecMatchLimit as String: kSecMatchLimitAll,
kSecReturnAttributes as String: true
]
var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
if status == errSecSuccess, let items = result as? [[String: Any]] {
let relevantPrefix = self.keyPrefix(uuid: uuid, botId: botId)
let count = items.filter {
if let account = $0[kSecAttrAccount as String] as? String {
return account.hasPrefix(relevantPrefix)
}
return false
}.count
return count
}
return 0
}
static func setValue(context: AccountContext, botId: EnginePeer.Id, key: String, value: String?) -> Signal<Never, WebAppSecureStorage.Error> {
return combineLatest(
context.engine.peers.secureBotStorageUuid(),
context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: context.account.peerId))
)
|> castError(WebAppSecureStorage.Error.self)
|> mapToSignal { uuid, accountPeer in
var query = makeQuery(uuid: uuid, botId: botId, key: key)
guard let value else {
let status = SecItemDelete(query as CFDictionary)
if status == errSecSuccess || status == errSecItemNotFound {
return .complete()
} else {
return .fail(.unknown)
}
}
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
let storageValue = StorageValue(
timestamp: Int32(CFAbsoluteTimeGetCurrent() + kCFAbsoluteTimeIntervalSince1970),
accountName: accountPeer?.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder) ?? "",
value: value
)
guard let storageValueData = try? JSONEncoder().encode(storageValue) else {
return .fail(.unknown)
}
query[kSecAttrAccessible as String] = kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly
let status = SecItemCopyMatching(query as CFDictionary, nil)
if status == errSecSuccess {
let updateQuery: [String: Any] = [
kSecValueData as String: storageValueData
]
let updateStatus = SecItemUpdate(query as CFDictionary, updateQuery as CFDictionary)
if updateStatus == errSecSuccess {
return .complete()
} else {
return .fail(.unknown)
}
} else if status == errSecItemNotFound {
let currentCount = countKeys(uuid: uuid, botId: botId)
if currentCount >= maxKeyCount {
return .fail(.quotaExceeded)
}
query[kSecValueData as String] = storageValueData
let createStatus = SecItemAdd(query as CFDictionary, nil)
if createStatus == errSecSuccess {
return .complete()
} else {
return .fail(.unknown)
}
} else {
return .fail(.unknown)
}
}
}
static func getValue(context: AccountContext, botId: EnginePeer.Id, key: String) -> Signal<String?, WebAppSecureStorage.Error> {
return context.engine.peers.secureBotStorageUuid()
|> castError(WebAppSecureStorage.Error.self)
|> mapToSignal { uuid in
var query = makeQuery(uuid: uuid, botId: botId, key: key)
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
if status == errSecSuccess, let storageValueData = result as? Data, let storageValue = try? JSONDecoder().decode(StorageValue.self, from: storageValueData) {
return .single(storageValue.value)
} else if status == errSecItemNotFound {
return findRestorableKeys(context: context, botId: botId, key: key)
|> castError(WebAppSecureStorage.Error.self)
|> mapToSignal { restorableKeys in
if !restorableKeys.isEmpty {
return .fail(.canRestore)
} else {
return .single(nil)
}
}
} else {
return .fail(.unknown)
}
}
}
static func checkRestoreAvailability(context: AccountContext, botId: EnginePeer.Id, key: String) -> Signal<[ExistingKey], WebAppSecureStorage.Error> {
return context.engine.peers.secureBotStorageUuid()
|> castError(WebAppSecureStorage.Error.self)
|> mapToSignal { uuid in
let currentCount = countKeys(uuid: uuid, botId: botId)
guard currentCount == 0 else {
return .fail(.storageNotEmpty)
}
return findRestorableKeys(context: context, botId: botId, key: key)
|> castError(WebAppSecureStorage.Error.self)
}
}
private static func findRestorableKeys(context: AccountContext, botId: EnginePeer.Id, key: String) -> Signal<[ExistingKey], NoError> {
let storedKeys = getAllStoredKeys(botId: botId, key: key)
guard !storedKeys.isEmpty else {
return .single([])
}
return context.sharedContext.activeAccountContexts
|> take(1)
|> mapToSignal { _, accountContexts, _ in
let signals = accountContexts.map { $0.1.engine.peers.secureBotStorageUuid() }
return combineLatest(signals)
|> map { activeUuids in
let inactiveAccountKeys = storedKeys.filter { !activeUuids.contains($0.uuid) }
return inactiveAccountKeys
}
}
}
static func transferAllValues(context: AccountContext, fromUuid: String, botId: EnginePeer.Id) -> Signal<Never, WebAppSecureStorage.Error> {
return context.engine.peers.secureBotStorageUuid()
|> castError(WebAppSecureStorage.Error.self)
|> mapToSignal { toUuid in
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "TMASecureStorage",
kSecMatchLimit as String: kSecMatchLimitAll,
kSecReturnAttributes as String: true,
kSecReturnData as String: true
]
var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
if status == errSecSuccess, let items = result as? [[String: Any]] {
let fromPrefix = keyPrefix(uuid: fromUuid, botId: botId)
let toPrefix = keyPrefix(uuid: toUuid, botId: botId)
for item in items {
if let account = item[kSecAttrAccount as String] as? String, account.hasPrefix(fromPrefix), let data = item[kSecValueData as String] as? Data {
let keySuffix = account.dropFirst(fromPrefix.count)
let newKeyIdentifier = toPrefix + keySuffix
let newKeyQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: newKeyIdentifier,
kSecAttrService as String: "TMASecureStorage",
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly
]
SecItemAdd(newKeyQuery as CFDictionary, nil)
}
}
return clearStorage(uuid: fromUuid, botId: botId)
} else {
return .complete()
}
}
}
struct ExistingKey: Equatable {
let uuid: String
let accountName: String
let timestamp: Int32
}
private static func getAllStoredKeys(botId: EnginePeer.Id, key: String) -> [ExistingKey] {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "TMASecureStorage",
kSecMatchLimit as String: kSecMatchLimitAll,
kSecReturnAttributes as String: true,
kSecReturnData as String: true
]
var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
var storedKeys: [ExistingKey] = []
if status == errSecSuccess, let items = result as? [[String: Any]] {
let botIdString = "\(UInt64(bitPattern: botId.toInt64()))"
for item in items {
if let account = item[kSecAttrAccount as String] as? String, account.contains("WebBot\(botIdString)U"), account.hasSuffix("Key_\(key)"), let valueData = item[kSecValueData as String] as? Data, let value = try? JSONDecoder().decode(StorageValue.self, from: valueData) {
if let range = account.range(of: "WebBot\(botIdString)U"), let endRange = account.range(of: "Key_\(key)") {
let startIndex = range.upperBound
let endIndex = endRange.lowerBound
let uuid = String(account[startIndex..<endIndex])
storedKeys.append(ExistingKey(
uuid: uuid,
accountName: value.accountName,
timestamp: value.timestamp
))
}
}
}
}
return storedKeys
}
static func clearStorage(context: AccountContext, botId: EnginePeer.Id) -> Signal<Never, WebAppSecureStorage.Error> {
return context.engine.peers.secureBotStorageUuid()
|> castError(WebAppSecureStorage.Error.self)
|> mapToSignal { uuid in
return clearStorage(uuid: uuid, botId: botId)
}
}
static func clearStorage(uuid: String, botId: EnginePeer.Id) -> Signal<Never, WebAppSecureStorage.Error> {
let serviceQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "TMASecureStorage",
kSecMatchLimit as String: kSecMatchLimitAll,
kSecReturnAttributes as String: true
]
var result: CFTypeRef?
let status = SecItemCopyMatching(serviceQuery as CFDictionary, &result)
if status == errSecSuccess, let items = result as? [[String: Any]] {
let relevantPrefix = self.keyPrefix(uuid: uuid, botId: botId)
for item in items {
if let account = item[kSecAttrAccount as String] as? String, account.hasPrefix(relevantPrefix) {
let deleteQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: account,
kSecAttrService as String: "TMASecureStorage"
]
SecItemDelete(deleteQuery as CFDictionary)
}
}
}
return .complete()
}
}
@@ -0,0 +1,493 @@
import Foundation
import UIKit
import Display
import ComponentFlow
import SwiftSignalKit
import TelegramCore
import Markdown
import TextFormat
import TelegramPresentationData
import TelegramStringFormatting
import ViewControllerComponent
import SheetComponent
import BundleIconComponent
import MultilineTextComponent
import ButtonComponent
import ListSectionComponent
import ListActionItemComponent
import AccountContext
import AvatarNode
private final class SheetContent: CombinedComponent {
typealias EnvironmentType = ViewControllerComponentContainer.Environment
let context: AccountContext
let peer: EnginePeer
let existingKeys: [WebAppSecureStorage.ExistingKey]
let completion: (String) -> Void
let dismiss: () -> Void
init(
context: AccountContext,
peer: EnginePeer,
existingKeys: [WebAppSecureStorage.ExistingKey],
completion: @escaping (String) -> Void,
dismiss: @escaping () -> Void
) {
self.context = context
self.peer = peer
self.existingKeys = existingKeys
self.completion = completion
self.dismiss = dismiss
}
static func ==(lhs: SheetContent, rhs: SheetContent) -> Bool {
if lhs.context !== rhs.context {
return false
}
if lhs.peer != rhs.peer {
return false
}
if lhs.existingKeys != rhs.existingKeys {
return false
}
return true
}
final class State: ComponentState {
var selectedUuid: String?
}
func makeState() -> State {
return State()
}
static var body: Body {
let closeButton = Child(Button.self)
let title = Child(MultilineTextComponent.self)
let avatar = Child(AvatarComponent.self)
let text = Child(MultilineTextComponent.self)
let keys = Child(ListSectionComponent.self)
let button = Child(ButtonComponent.self)
return { context in
let environment = context.environment[EnvironmentType.self]
let component = context.component
let state = context.state
let theme = environment.theme.withModalBlocksBackground()
let strings = environment.strings
let sideInset: CGFloat = 16.0 + environment.safeInsets.left
let textSideInset: CGFloat = 32.0 + environment.safeInsets.left
let titleFont = Font.semibold(17.0)
let textFont = Font.regular(13.0)
let boldTextFont = Font.semibold(13.0)
let textColor = theme.actionSheet.primaryTextColor
var contentSize = CGSize(width: context.availableSize.width, height: 18.0)
let closeButton = closeButton.update(
component: Button(
content: AnyComponent(Text(text: strings.Common_Cancel, font: Font.regular(17.0), color: theme.actionSheet.controlAccentColor)),
action: { [weak component] in
component?.dismiss()
}
),
availableSize: CGSize(width: 100.0, height: 30.0),
transition: .immediate
)
context.add(closeButton
.position(CGPoint(x: environment.safeInsets.left + 16.0 + closeButton.size.width / 2.0, y: 28.0))
)
let title = title.update(
component: MultilineTextComponent(
text: .plain(NSAttributedString(string: strings.WebApp_ImportData_Title, font: titleFont, textColor: textColor)),
horizontalAlignment: .center,
maximumNumberOfLines: 1,
lineSpacing: 0.1
),
availableSize: CGSize(width: context.availableSize.width - textSideInset * 2.0, height: context.availableSize.height),
transition: .immediate
)
context.add(title
.position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + title.size.height / 2.0))
)
contentSize.height += title.size.height
contentSize.height += 24.0
let avatar = avatar.update(
component: AvatarComponent(
context: component.context,
peer: component.peer,
size: CGSize(width: 80.0, height: 80.0)
),
availableSize: CGSize(width: 80.0, height: 80.0),
transition: .immediate
)
context.add(avatar
.position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + avatar.size.height / 2.0))
)
contentSize.height += avatar.size.height
contentSize.height += 22.0
let text = text.update(
component: MultilineTextComponent(
text: .markdown(
text: strings.WebApp_ImportData_Description(component.peer.compactDisplayTitle).string,
attributes: MarkdownAttributes(
body: MarkdownAttributeSet(font: textFont, textColor: textColor),
bold: MarkdownAttributeSet(font: boldTextFont, textColor: textColor),
link: MarkdownAttributeSet(font: textFont, textColor: textColor),
linkAttribute: { _ in return nil }
)
),
horizontalAlignment: .center,
maximumNumberOfLines: 0,
lineSpacing: 0.2
),
availableSize: CGSize(width: context.availableSize.width - textSideInset * 2.0, height: context.availableSize.height),
transition: .immediate
)
context.add(text
.position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + text.size.height / 2.0))
)
contentSize.height += text.size.height
contentSize.height += 29.0
let presentationData = component.context.sharedContext.currentPresentationData.with { $0 }
var items: [AnyComponentWithIdentity<Empty>] = []
for key in component.existingKeys {
var titleComponents: [AnyComponentWithIdentity<Empty>] = []
titleComponents.append(
AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(
string: key.accountName,
font: Font.semibold(presentationData.listsFontSize.itemListBaseFontSize),
textColor: environment.theme.list.itemPrimaryTextColor
)),
maximumNumberOfLines: 1
)))
)
titleComponents.append(
AnyComponentWithIdentity(id: AnyHashable(1), component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(
string: strings.WebApp_ImportData_CreatedOn(stringForMediumCompactDate(timestamp: key.timestamp, strings: strings, dateTimeFormat: environment.dateTimeFormat)).string,
font: Font.regular(floor(presentationData.listsFontSize.itemListBaseFontSize * 15.0 / 17.0)),
textColor: environment.theme.list.itemSecondaryTextColor
)),
maximumNumberOfLines: 1
)))
)
items.append(AnyComponentWithIdentity(id: key.uuid, component: AnyComponent(ListActionItemComponent(
theme: theme,
title: AnyComponent(VStack(titleComponents, alignment: .left, spacing: 3.0)),
contentInsets: UIEdgeInsets(top: 10.0, left: 0.0, bottom: 10.0, right: 0.0),
leftIcon: .check(ListActionItemComponent.LeftIcon.Check(isSelected: key.uuid == state.selectedUuid, isEnabled: true, toggle: nil)),
accessory: nil,
action: { [weak state] _ in
if let state {
state.selectedUuid = key.uuid
state.updated(transition: .spring(duration: 0.3))
}
}
))))
}
let keys = keys.update(
component: ListSectionComponent(
theme: environment.theme,
header: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(
string: strings.WebApp_ImportData_AccountHeader.uppercased(),
font: Font.regular(presentationData.listsFontSize.itemListBaseHeaderFontSize),
textColor: environment.theme.list.freeTextColor
)),
maximumNumberOfLines: 0
)),
footer: nil,
items: items
),
availableSize: CGSize(width: context.availableSize.width - sideInset * 2.0, height: 1000.0),
transition: context.transition
)
context.add(keys
.position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + keys.size.height / 2.0))
)
contentSize.height += keys.size.height
contentSize.height += 24.0
let button = button.update(
component: ButtonComponent(
background: ButtonComponent.Background(
color: theme.list.itemCheckColors.fillColor,
foreground: theme.list.itemCheckColors.foregroundColor,
pressedColor: theme.list.itemCheckColors.fillColor.withMultipliedAlpha(0.9)
),
content: AnyComponentWithIdentity(
id: AnyHashable("import"),
component: AnyComponent(
MultilineTextComponent(text: .plain(NSAttributedString(string: strings.WebApp_ImportData_Import, font: Font.semibold(17.0), textColor: theme.list.itemCheckColors.foregroundColor, paragraphAlignment: .center)))
)
),
isEnabled: state.selectedUuid != nil,
allowActionWhenDisabled: true,
displaysProgress: false,
action: { [weak state] in
guard let state else {
return
}
if let selectedUuid = state.selectedUuid {
component.completion(selectedUuid)
component.dismiss()
}
}
),
availableSize: CGSize(width: context.availableSize.width - sideInset * 2.0, height: 50.0),
transition: context.transition
)
context.add(button
.position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + button.size.height / 2.0))
.cornerRadius(10.0)
)
contentSize.height += button.size.height
contentSize.height += 7.0
let effectiveBottomInset: CGFloat = environment.metrics.isTablet ? 0.0 : environment.safeInsets.bottom
contentSize.height += 5.0 + effectiveBottomInset
return contentSize
}
}
}
private final class SheetContainerComponent: CombinedComponent {
typealias EnvironmentType = ViewControllerComponentContainer.Environment
let context: AccountContext
let peer: EnginePeer
let existingKeys: [WebAppSecureStorage.ExistingKey]
let completion: (String) -> Void
init(
context: AccountContext,
peer: EnginePeer,
existingKeys: [WebAppSecureStorage.ExistingKey],
completion: @escaping (String) -> Void
) {
self.context = context
self.peer = peer
self.existingKeys = existingKeys
self.completion = completion
}
static func ==(lhs: SheetContainerComponent, rhs: SheetContainerComponent) -> Bool {
if lhs.context !== rhs.context {
return false
}
if lhs.peer != rhs.peer {
return false
}
if lhs.existingKeys != rhs.existingKeys {
return false
}
return true
}
static var body: Body {
let sheet = Child(SheetComponent<EnvironmentType>.self)
let animateOut = StoredActionSlot(Action<Void>.self)
let sheetExternalState = SheetComponent<EnvironmentType>.ExternalState()
return { context in
let environment = context.environment[EnvironmentType.self]
let theme = environment.theme.withModalBlocksBackground()
let controller = environment.controller
let sheet = sheet.update(
component: SheetComponent<EnvironmentType>(
content: AnyComponent<EnvironmentType>(SheetContent(
context: context.component.context,
peer: context.component.peer,
existingKeys: context.component.existingKeys,
completion: context.component.completion,
dismiss: {
animateOut.invoke(Action { _ in
if let controller = controller() {
controller.dismiss(completion: nil)
}
})
}
)),
backgroundColor: .color(theme.list.blocksBackgroundColor),
followContentSizeChanges: true,
externalState: sheetExternalState,
animateOut: animateOut
),
environment: {
environment
SheetComponentEnvironment(
isDisplaying: environment.value.isVisible,
isCentered: environment.metrics.widthClass == .regular,
hasInputHeight: !environment.inputHeight.isZero,
regularMetricsSize: CGSize(width: 430.0, height: 900.0),
dismiss: { animated in
if animated {
animateOut.invoke(Action { _ in
if let controller = controller() {
controller.dismiss(completion: nil)
}
})
} else {
if let controller = controller() {
controller.dismiss(completion: nil)
}
}
}
)
},
availableSize: context.availableSize,
transition: context.transition
)
context.add(sheet
.position(CGPoint(x: context.availableSize.width / 2.0, y: context.availableSize.height / 2.0))
)
if let controller = controller(), !controller.automaticallyControlPresentationContextLayout {
let layout = ContainerViewLayout(
size: context.availableSize,
metrics: environment.metrics,
deviceMetrics: environment.deviceMetrics,
intrinsicInsets: UIEdgeInsets(top: 0.0, left: 0.0, bottom: max(environment.safeInsets.bottom, sheetExternalState.contentHeight), right: 0.0),
safeInsets: UIEdgeInsets(top: 0.0, left: environment.safeInsets.left, bottom: 0.0, right: environment.safeInsets.right),
additionalInsets: .zero,
statusBarHeight: environment.statusBarHeight,
inputHeight: nil,
inputHeightIsInteractivellyChanging: false,
inVoiceOver: false
)
controller.presentationContext.containerLayoutUpdated(layout, transition: context.transition.containedViewLayoutTransition)
}
return context.availableSize
}
}
}
final class WebAppSecureStorageTransferScreen: ViewControllerComponentContainer {
init(
context: AccountContext,
peer: EnginePeer,
existingKeys: [WebAppSecureStorage.ExistingKey],
completion: @escaping (String?) -> Void
) {
super.init(
context: context,
component: SheetContainerComponent(
context: context,
peer: peer,
existingKeys: existingKeys,
completion: completion
),
navigationBarAppearance: .none,
statusBarStyle: .ignore,
theme: .default
)
self.navigationPresentation = .flatModal
}
required init(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func dismissAnimated() {
if let view = self.node.hostView.findTaggedView(tag: SheetComponent<ViewControllerComponentContainer.Environment>.View.Tag()) as? SheetComponent<ViewControllerComponentContainer.Environment>.View {
view.dismissAnimated()
}
}
}
private final class AvatarComponent: Component {
let context: AccountContext
let peer: EnginePeer
let size: CGSize?
init(context: AccountContext, peer: EnginePeer, size: CGSize? = nil) {
self.context = context
self.peer = peer
self.size = size
}
static func ==(lhs: AvatarComponent, rhs: AvatarComponent) -> Bool {
if lhs.context !== rhs.context {
return false
}
if lhs.peer != rhs.peer {
return false
}
if lhs.size != rhs.size {
return false
}
return true
}
final class View: UIView {
private var avatarNode: AvatarNode?
private var component: AvatarComponent?
private weak var state: EmptyComponentState?
override init(frame: CGRect) {
super.init(frame: frame)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func update(component: AvatarComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
self.component = component
self.state = state
let size = component.size ?? availableSize
let avatarNode: AvatarNode
if let current = self.avatarNode {
avatarNode = current
} else {
avatarNode = AvatarNode(font: avatarPlaceholderFont(size: floor(size.width * 0.5)))
avatarNode.displaysAsynchronously = false
self.avatarNode = avatarNode
self.addSubview(avatarNode.view)
}
avatarNode.frame = CGRect(origin: CGPoint(), size: size)
avatarNode.setPeer(
context: component.context,
theme: component.context.sharedContext.currentPresentationData.with({ $0 }).theme,
peer: component.peer,
synchronousLoad: true,
displayDimensions: size
)
avatarNode.updateSize(size: size)
return size
}
}
func makeView() -> View {
return View(frame: CGRect())
}
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}
@@ -0,0 +1,416 @@
import Foundation
import UIKit
import Display
import ComponentFlow
import SwiftSignalKit
import TelegramCore
import Markdown
import TextFormat
import TelegramPresentationData
import ViewControllerComponent
import SheetComponent
import BalancedTextComponent
import MultilineTextComponent
import BundleIconComponent
import ButtonComponent
import AccountContext
import PresentationDataUtils
import PremiumPeerShortcutComponent
import GiftAnimationComponent
import GlassBarButtonComponent
private final class SheetContent: CombinedComponent {
typealias EnvironmentType = ViewControllerComponentContainer.Environment
let context: AccountContext
let botName: String
let accountPeer: EnginePeer
let file: TelegramMediaFile
let duration: Int32?
let dismiss: () -> Void
init(
context: AccountContext,
botName: String,
accountPeer: EnginePeer,
file: TelegramMediaFile,
duration: Int32?,
dismiss: @escaping () -> Void
) {
self.context = context
self.botName = botName
self.accountPeer = accountPeer
self.file = file
self.duration = duration
self.dismiss = dismiss
}
static func ==(lhs: SheetContent, rhs: SheetContent) -> Bool {
if lhs.context !== rhs.context {
return false
}
if lhs.botName != rhs.botName {
return false
}
if lhs.file != rhs.file {
return false
}
return true
}
final class State: ComponentState {
}
func makeState() -> State {
return State()
}
static var body: Body {
let background = Child(RoundedRectangle.self)
let animation = Child(GiftAnimationComponent.self)
let closeButton = Child(GlassBarButtonComponent.self)
let title = Child(Text.self)
let text = Child(BalancedTextComponent.self)
let peerShortcut = Child(PremiumPeerShortcutComponent.self)
let button = Child(ButtonComponent.self)
return { context in
let environment = context.environment[EnvironmentType.self]
let component = context.component
let presentationData = component.context.sharedContext.currentPresentationData.with { $0 }
let theme = presentationData.theme
let strings = presentationData.strings
var contentSize = CGSize(width: context.availableSize.width, height: 18.0)
let background = background.update(
component: RoundedRectangle(color: theme.actionSheet.opaqueItemBackgroundColor, cornerRadius: 8.0),
availableSize: CGSize(width: context.availableSize.width, height: 1000.0),
transition: .immediate
)
context.add(background
.position(CGPoint(x: context.availableSize.width / 2.0, y: background.size.height / 2.0))
)
let animation = animation.update(
component: GiftAnimationComponent(
context: component.context,
theme: environment.theme,
file: component.file
),
availableSize: CGSize(width: 128.0, height: 128.0),
transition: .immediate
)
context.add(animation
.position(CGPoint(x: context.availableSize.width / 2.0, y: animation.size.height / 2.0 + 12.0))
)
let closeButton = closeButton.update(
component: GlassBarButtonComponent(
size: CGSize(width: 40.0, height: 40.0),
backgroundColor: theme.rootController.navigationBar.glassBarButtonBackgroundColor,
isDark: theme.overallDarkAppearance,
state: .generic,
component: AnyComponentWithIdentity(id: "close", component: AnyComponent(
BundleIconComponent(
name: "Navigation/Close",
tintColor: theme.rootController.navigationBar.glassBarButtonForegroundColor
)
)),
action: { _ in
component.dismiss()
}
),
availableSize: CGSize(width: 40.0, height: 40.0),
transition: .immediate
)
context.add(closeButton
.position(CGPoint(x: 16.0 + closeButton.size.width / 2.0, y: 16.0 + closeButton.size.height / 2.0))
)
let constrainedTitleWidth = context.availableSize.width - 16.0 * 2.0
contentSize.height += 128.0
let title = title.update(
component: Text(text: strings.WebApp_Emoji_Title, font: Font.bold(24.0), color: theme.list.itemPrimaryTextColor),
availableSize: CGSize(width: constrainedTitleWidth, height: context.availableSize.height),
transition: .immediate
)
context.add(title
.position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + title.size.height / 2.0))
)
contentSize.height += title.size.height
contentSize.height += 13.0
let textFont = Font.regular(15.0)
let boldTextFont = Font.semibold(15.0)
let textColor = theme.actionSheet.primaryTextColor
let linkColor = theme.actionSheet.controlAccentColor
let markdownAttributes = MarkdownAttributes(body: MarkdownAttributeSet(font: textFont, textColor: textColor), bold: MarkdownAttributeSet(font: boldTextFont, textColor: textColor), link: MarkdownAttributeSet(font: textFont, textColor: linkColor), linkAttribute: { contents in
return (TelegramTextAttributes.URL, contents)
})
var textString: String
if let duration = component.duration {
let durationString = scheduledTimeIntervalString(strings: strings, value: duration)
textString = strings.WebApp_Emoji_DurationText(component.botName, durationString).string
} else {
textString = strings.WebApp_Emoji_Text(component.botName).string
}
let text = text.update(
component: BalancedTextComponent(
text: .markdown(
text: textString,
attributes: markdownAttributes
),
horizontalAlignment: .center,
maximumNumberOfLines: 0,
lineSpacing: 0.2
),
availableSize: CGSize(width: constrainedTitleWidth, height: context.availableSize.height),
transition: .immediate
)
context.add(text
.position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + text.size.height / 2.0))
)
contentSize.height += text.size.height
contentSize.height += 15.0
let peerShortcut = peerShortcut.update(
component: PremiumPeerShortcutComponent(
context: component.context,
theme: theme,
peer: component.accountPeer,
icon: component.file
),
availableSize: CGSize(width: context.availableSize.width - 32.0, height: context.availableSize.height),
transition: .immediate
)
context.add(peerShortcut
.position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + peerShortcut.size.height / 2.0))
)
contentSize.height += peerShortcut.size.height
contentSize.height += 32.0
let controller = environment.controller() as? WebAppSetEmojiStatusScreen
let button = button.update(
component: ButtonComponent(
background: ButtonComponent.Background(
style: .glass,
color: theme.list.itemCheckColors.fillColor,
foreground: theme.list.itemCheckColors.foregroundColor,
pressedColor: theme.list.itemCheckColors.fillColor.withMultipliedAlpha(0.9),
),
content: AnyComponentWithIdentity(
id: AnyHashable(0),
component: AnyComponent(MultilineTextComponent(text: .plain(NSMutableAttributedString(string: strings.WebApp_Emoji_Confirm, font: Font.semibold(17.0), textColor: theme.list.itemCheckColors.foregroundColor, paragraphAlignment: .center))))
),
isEnabled: true,
displaysProgress: false,
action: { [weak controller] in
controller?.complete(result: true)
controller?.dismissAnimated()
}
),
availableSize: CGSize(width: context.availableSize.width - 30.0 * 2.0, height: 52.0),
transition: .immediate
)
context.add(button
.clipsToBounds(true)
.cornerRadius(10.0)
.position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + button.size.height / 2.0))
)
contentSize.height += button.size.height
contentSize.height += 48.0
return contentSize
}
}
}
private final class WebAppSetEmojiStatusSheetComponent: CombinedComponent {
typealias EnvironmentType = ViewControllerComponentContainer.Environment
private let context: AccountContext
private let botName: String
private let accountPeer: EnginePeer
private let file: TelegramMediaFile
private let duration: Int32?
init(
context: AccountContext,
botName: String,
accountPeer: EnginePeer,
file: TelegramMediaFile,
duration: Int32?
) {
self.context = context
self.botName = botName
self.accountPeer = accountPeer
self.file = file
self.duration = duration
}
static func ==(lhs: WebAppSetEmojiStatusSheetComponent, rhs: WebAppSetEmojiStatusSheetComponent) -> Bool {
if lhs.context !== rhs.context {
return false
}
if lhs.botName != rhs.botName {
return false
}
if lhs.accountPeer != rhs.accountPeer {
return false
}
if lhs.duration != rhs.duration {
return false
}
return true
}
static var body: Body {
let sheet = Child(SheetComponent<(EnvironmentType)>.self)
let animateOut = StoredActionSlot(Action<Void>.self)
return { context in
let environment = context.environment[EnvironmentType.self]
let controller = environment.controller
let sheet = sheet.update(
component: SheetComponent<EnvironmentType>(
content: AnyComponent<EnvironmentType>(SheetContent(
context: context.component.context,
botName: context.component.botName,
accountPeer: context.component.accountPeer,
file: context.component.file,
duration: context.component.duration,
dismiss: {
animateOut.invoke(Action { _ in
if let controller = controller() as? WebAppSetEmojiStatusScreen {
controller.complete(result: false)
controller.dismiss(completion: nil)
}
})
}
)),
style: .glass,
backgroundColor: .color(environment.theme.list.modalBlocksBackgroundColor),
followContentSizeChanges: true,
clipsContent: true,
animateOut: animateOut
),
environment: {
environment
SheetComponentEnvironment(
isDisplaying: environment.value.isVisible,
isCentered: environment.metrics.widthClass == .regular,
hasInputHeight: !environment.inputHeight.isZero,
regularMetricsSize: CGSize(width: 430.0, height: 900.0),
dismiss: { animated in
if animated {
animateOut.invoke(Action { _ in
if let controller = controller() as? WebAppSetEmojiStatusScreen {
controller.complete(result: false)
controller.dismiss(completion: nil)
}
})
} else {
if let controller = controller() as? WebAppSetEmojiStatusScreen {
controller.complete(result: false)
controller.dismiss(completion: nil)
}
}
}
)
},
availableSize: context.availableSize,
transition: context.transition
)
context.add(sheet
.position(CGPoint(x: context.availableSize.width / 2.0, y: context.availableSize.height / 2.0))
)
return context.availableSize
}
}
}
public final class WebAppSetEmojiStatusScreen: ViewControllerComponentContainer {
private let context: AccountContext
private let completion: (Bool) -> Void
public init(
context: AccountContext,
botName: String,
accountPeer: EnginePeer,
file: TelegramMediaFile,
duration: Int32?,
completion: @escaping (Bool) -> Void
) {
self.context = context
self.completion = completion
super.init(
context: context,
component: WebAppSetEmojiStatusSheetComponent(
context: context,
botName: botName,
accountPeer: accountPeer,
file: file,
duration: duration
),
navigationBarAppearance: .none,
statusBarStyle: .ignore,
theme: .default
)
self.navigationPresentation = .flatModal
}
required public init(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private var didComplete = false
fileprivate func complete(result: Bool) {
guard !self.didComplete else {
return
}
self.didComplete = true
self.completion(result)
}
public func dismissAnimated() {
if let view = self.node.hostView.findTaggedView(tag: SheetComponent<ViewControllerComponentContainer.Environment>.View.Tag()) as? SheetComponent<ViewControllerComponentContainer.Environment>.View {
view.dismissAnimated()
}
}
}
func generateCloseButtonImage(backgroundColor: UIColor, foregroundColor: UIColor) -> UIImage? {
return generateImage(CGSize(width: 30.0, height: 30.0), contextGenerator: { size, context in
context.clear(CGRect(origin: CGPoint(), size: size))
context.setFillColor(backgroundColor.cgColor)
context.fillEllipse(in: CGRect(origin: CGPoint(), size: size))
context.setLineWidth(2.0)
context.setLineCap(.round)
context.setStrokeColor(foregroundColor.cgColor)
context.move(to: CGPoint(x: 10.0, y: 10.0))
context.addLine(to: CGPoint(x: 20.0, y: 20.0))
context.strokePath()
context.move(to: CGPoint(x: 20.0, y: 10.0))
context.addLine(to: CGPoint(x: 10.0, y: 20.0))
context.strokePath()
})
}
@@ -0,0 +1,400 @@
import Foundation
import UIKit
import SwiftSignalKit
import AsyncDisplayKit
import Display
import Postbox
import TelegramCore
import TelegramPresentationData
import TelegramUIPreferences
import AccountContext
import AppBundle
import AvatarNode
import CheckNode
import Markdown
import TextFormat
private let textFont = Font.regular(13.0)
private let boldTextFont = Font.semibold(13.0)
private func formattedText(_ text: String, fontSize: CGFloat, color: UIColor, linkColor: UIColor, textAlignment: NSTextAlignment = .natural) -> NSAttributedString {
return parseMarkdownIntoAttributedString(text, attributes: MarkdownAttributes(body: MarkdownAttributeSet(font: Font.regular(fontSize), textColor: color), bold: MarkdownAttributeSet(font: Font.semibold(fontSize), textColor: color), link: MarkdownAttributeSet(font: Font.regular(fontSize), textColor: linkColor), linkAttribute: { _ in return (TelegramTextAttributes.URL, "") }), textAlignment: textAlignment)
}
private final class WebAppTermsAlertContentNode: AlertContentNode, ASGestureRecognizerDelegate {
private let strings: PresentationStrings
private let title: String
private let text: String
private let additionalText: String?
private let titleNode: ImmediateTextNode
private let textNode: ImmediateTextNode
private let additionalTextNode: ImmediateTextNode
private let acceptTermsCheckNode: InteractiveCheckNode
private let acceptTermsLabelNode: ImmediateTextNode
private let actionNodesSeparator: ASDisplayNode
private let actionNodes: [TextAlertContentActionNode]
private let actionVerticalSeparators: [ASDisplayNode]
private var validLayout: CGSize?
override var dismissOnOutsideTap: Bool {
return self.isUserInteractionEnabled
}
var acceptedTerms: Bool = false {
didSet {
self.acceptTermsCheckNode.setSelected(self.acceptedTerms, animated: true)
if let firstAction = self.actionNodes.first {
firstAction.actionEnabled = self.acceptedTerms
}
}
}
var openTerms: () -> Void = {}
init(context: AccountContext, theme: AlertControllerTheme, ptheme: PresentationTheme, strings: PresentationStrings, title: String, text: String, additionalText: String?, actions: [TextAlertAction]) {
self.strings = strings
self.title = title
self.text = text
self.additionalText = additionalText
self.titleNode = ImmediateTextNode()
self.titleNode.displaysAsynchronously = false
self.titleNode.maximumNumberOfLines = 1
self.titleNode.textAlignment = .center
self.textNode = ImmediateTextNode()
self.textNode.maximumNumberOfLines = 0
self.textNode.displaysAsynchronously = false
self.textNode.lineSpacing = 0.1
self.textNode.textAlignment = .center
self.additionalTextNode = ImmediateTextNode()
self.additionalTextNode.maximumNumberOfLines = 0
self.additionalTextNode.displaysAsynchronously = false
self.additionalTextNode.lineSpacing = 0.1
self.additionalTextNode.textAlignment = .center
self.acceptTermsCheckNode = InteractiveCheckNode(theme: CheckNodeTheme(backgroundColor: theme.accentColor, strokeColor: theme.contrastColor, borderColor: theme.controlBorderColor, overlayBorder: false, hasInset: false, hasShadow: false))
self.acceptTermsLabelNode = ImmediateTextNode()
self.acceptTermsLabelNode.maximumNumberOfLines = 4
self.actionNodesSeparator = ASDisplayNode()
self.actionNodesSeparator.isLayerBacked = true
self.actionNodes = actions.map { action -> TextAlertContentActionNode in
return TextAlertContentActionNode(theme: theme, action: action)
}
var actionVerticalSeparators: [ASDisplayNode] = []
if actions.count > 1 {
for _ in 0 ..< actions.count - 1 {
let separatorNode = ASDisplayNode()
separatorNode.isLayerBacked = true
actionVerticalSeparators.append(separatorNode)
}
}
self.actionVerticalSeparators = actionVerticalSeparators
super.init()
self.addSubnode(self.titleNode)
self.addSubnode(self.textNode)
self.addSubnode(self.additionalTextNode)
self.addSubnode(self.acceptTermsCheckNode)
self.addSubnode(self.acceptTermsLabelNode)
self.addSubnode(self.actionNodesSeparator)
for actionNode in self.actionNodes {
self.addSubnode(actionNode)
}
for separatorNode in self.actionVerticalSeparators {
self.addSubnode(separatorNode)
}
self.acceptTermsCheckNode.valueChanged = { [weak self] value in
if let strongSelf = self {
strongSelf.acceptedTerms = !strongSelf.acceptedTerms
}
}
self.acceptTermsLabelNode.highlightAttributeAction = { attributes in
if let _ = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.URL)] {
return NSAttributedString.Key(rawValue: TelegramTextAttributes.URL)
} else {
return nil
}
}
self.acceptTermsLabelNode.tapAttributeAction = { [weak self] attributes, _ in
if let _ = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.URL)] {
self?.openTerms()
}
}
self.updateTheme(theme)
}
override func didLoad() {
super.didLoad()
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(self.acceptTap(_:)))
tapGesture.delegate = self.wrappedGestureRecognizerDelegate
self.view.addGestureRecognizer(tapGesture)
if let firstAction = self.actionNodes.first {
firstAction.actionEnabled = false
}
}
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
let location = gestureRecognizer.location(in: self.acceptTermsLabelNode.view)
if self.acceptTermsLabelNode.bounds.contains(location) {
return true
}
return false
}
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
if !self.bounds.contains(point) {
return nil
}
if let (_, attributes) = self.acceptTermsLabelNode.attributesAtPoint(self.view.convert(point, to: self.acceptTermsLabelNode.view)) {
if attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.URL)] == nil {
return self.view
}
}
return super.hitTest(point, with: event)
}
@objc private func acceptTap(_ gestureRecognizer: UITapGestureRecognizer) {
let location = gestureRecognizer.location(in: self.acceptTermsLabelNode.view)
if self.acceptTermsCheckNode.isUserInteractionEnabled {
if let attributes = self.acceptTermsLabelNode.attributesAtPoint(location)?.1 {
if attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.URL)] != nil {
return
}
}
self.acceptedTerms = !self.acceptedTerms
}
}
override func updateTheme(_ theme: AlertControllerTheme) {
self.titleNode.attributedText = NSAttributedString(string: self.title, font: Font.semibold(17.0), textColor: theme.primaryColor, paragraphAlignment: .center)
self.textNode.attributedText = formattedText(self.text, fontSize: 13.0, color: theme.primaryColor, linkColor: theme.accentColor, textAlignment: .center)
if let additionalText = self.additionalText {
self.additionalTextNode.attributedText = formattedText(additionalText, fontSize: 13.0, color: theme.primaryColor, linkColor: theme.accentColor, textAlignment: .center)
} else {
self.additionalTextNode.attributedText = nil
}
let attributedAgreeText = parseMarkdownIntoAttributedString(
self.strings.WebApp_DisclaimerAgree,
attributes: MarkdownAttributes(
body: MarkdownAttributeSet(font: textFont, textColor: theme.primaryColor),
bold: MarkdownAttributeSet(font: boldTextFont, textColor: theme.primaryColor),
link: MarkdownAttributeSet(font: textFont, textColor: theme.accentColor),
linkAttribute: { contents in
return (TelegramTextAttributes.URL, contents)
}
)
)
self.acceptTermsLabelNode.attributedText = attributedAgreeText
self.acceptTermsLabelNode.linkHighlightColor = theme.accentColor.withAlphaComponent(0.2)
self.actionNodesSeparator.backgroundColor = theme.separatorColor
for actionNode in self.actionNodes {
actionNode.updateTheme(theme)
}
for separatorNode in self.actionVerticalSeparators {
separatorNode.backgroundColor = theme.separatorColor
}
if let size = self.validLayout {
_ = self.updateLayout(size: size, transition: .immediate)
}
}
override func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition) -> CGSize {
var size = size
size.width = min(size.width, 270.0)
self.validLayout = size
var origin: CGPoint = CGPoint(x: 0.0, y: 17.0)
let titleSize = self.titleNode.updateLayout(CGSize(width: size.width - 32.0, height: size.height))
transition.updateFrame(node: self.titleNode, frame: CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - titleSize.width) / 2.0), y: origin.y), size: titleSize))
origin.y += titleSize.height + 4.0
var entriesHeight: CGFloat = 0.0
let textSize = self.textNode.updateLayout(CGSize(width: size.width - 48.0, height: size.height))
transition.updateFrame(node: self.textNode, frame: CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - textSize.width) / 2.0), y: origin.y), size: textSize))
origin.y += textSize.height
if self.acceptTermsLabelNode.supernode != nil {
origin.y += 21.0
entriesHeight += 21.0
let checkSize = CGSize(width: 22.0, height: 22.0)
let condensedSize = CGSize(width: size.width - 76.0, height: size.height)
let spacing: CGFloat = 12.0
let acceptTermsSize = self.acceptTermsLabelNode.updateLayout(condensedSize)
let acceptTermsTotalWidth = checkSize.width + spacing + acceptTermsSize.width
let acceptTermsOriginX = floorToScreenPixels((size.width - acceptTermsTotalWidth) / 2.0)
transition.updateFrame(node: self.acceptTermsCheckNode, frame: CGRect(origin: CGPoint(x: acceptTermsOriginX, y: origin.y - 3.0), size: checkSize))
transition.updateFrame(node: self.acceptTermsLabelNode, frame: CGRect(origin: CGPoint(x: acceptTermsOriginX + checkSize.width + spacing, y: origin.y), size: acceptTermsSize))
origin.y += acceptTermsSize.height
entriesHeight += acceptTermsSize.height
origin.y += 21.0
}
let additionalTextSize = self.additionalTextNode.updateLayout(CGSize(width: size.width - 48.0, height: size.height))
transition.updateFrame(node: self.additionalTextNode, frame: CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - additionalTextSize.width) / 2.0), y: origin.y), size: additionalTextSize))
origin.y += additionalTextSize.height
if additionalTextSize.height > 0.0 {
entriesHeight += 20.0
}
let actionButtonHeight: CGFloat = 44.0
var minActionsWidth: CGFloat = 0.0
let maxActionWidth: CGFloat = floor(size.width / CGFloat(self.actionNodes.count))
let actionTitleInsets: CGFloat = 8.0
var effectiveActionLayout = TextAlertContentActionLayout.vertical
for actionNode in self.actionNodes {
let actionTitleSize = actionNode.titleNode.updateLayout(CGSize(width: maxActionWidth, height: actionButtonHeight))
if case .horizontal = effectiveActionLayout, actionTitleSize.height > actionButtonHeight * 0.6667 {
effectiveActionLayout = .vertical
}
switch effectiveActionLayout {
case .horizontal:
minActionsWidth += actionTitleSize.width + actionTitleInsets
case .vertical:
minActionsWidth = max(minActionsWidth, actionTitleSize.width + actionTitleInsets)
}
}
let insets = UIEdgeInsets(top: 18.0, left: 18.0, bottom: 18.0, right: 18.0)
let contentWidth = max(size.width, minActionsWidth)
var actionsHeight: CGFloat = 0.0
switch effectiveActionLayout {
case .horizontal:
actionsHeight = actionButtonHeight
case .vertical:
actionsHeight = actionButtonHeight * CGFloat(self.actionNodes.count)
}
let resultSize = CGSize(width: contentWidth, height: titleSize.height + textSize.height + additionalTextSize.height + entriesHeight + actionsHeight + 3.0 + insets.top + insets.bottom)
transition.updateFrame(node: self.actionNodesSeparator, frame: CGRect(origin: CGPoint(x: 0.0, y: resultSize.height - actionsHeight - UIScreenPixel), size: CGSize(width: resultSize.width, height: UIScreenPixel)))
var actionOffset: CGFloat = 0.0
let actionWidth: CGFloat = floor(resultSize.width / CGFloat(self.actionNodes.count))
var separatorIndex = -1
var nodeIndex = 0
for actionNode in self.actionNodes {
if separatorIndex >= 0 {
let separatorNode = self.actionVerticalSeparators[separatorIndex]
switch effectiveActionLayout {
case .horizontal:
transition.updateFrame(node: separatorNode, frame: CGRect(origin: CGPoint(x: actionOffset - UIScreenPixel, y: resultSize.height - actionsHeight), size: CGSize(width: UIScreenPixel, height: actionsHeight - UIScreenPixel)))
case .vertical:
transition.updateFrame(node: separatorNode, frame: CGRect(origin: CGPoint(x: 0.0, y: resultSize.height - actionsHeight + actionOffset - UIScreenPixel), size: CGSize(width: resultSize.width, height: UIScreenPixel)))
}
}
separatorIndex += 1
let currentActionWidth: CGFloat
switch effectiveActionLayout {
case .horizontal:
if nodeIndex == self.actionNodes.count - 1 {
currentActionWidth = resultSize.width - actionOffset
} else {
currentActionWidth = actionWidth
}
case .vertical:
currentActionWidth = resultSize.width
}
let actionNodeFrame: CGRect
switch effectiveActionLayout {
case .horizontal:
actionNodeFrame = CGRect(origin: CGPoint(x: actionOffset, y: resultSize.height - actionsHeight), size: CGSize(width: currentActionWidth, height: actionButtonHeight))
actionOffset += currentActionWidth
case .vertical:
actionNodeFrame = CGRect(origin: CGPoint(x: 0.0, y: resultSize.height - actionsHeight + actionOffset), size: CGSize(width: currentActionWidth, height: actionButtonHeight))
actionOffset += actionButtonHeight
}
transition.updateFrame(node: actionNode, frame: actionNodeFrame)
nodeIndex += 1
}
return resultSize
}
}
public func webAppTermsAlertController(
context: AccountContext,
updatedPresentationData: (initial: PresentationData, signal: Signal<PresentationData, NoError>)?,
bot: AttachMenuBot,
completion: @escaping (Bool) -> Void,
dismissed: @escaping () -> Void = {}
) -> AlertController {
let theme = defaultDarkColorPresentationTheme
let presentationData: PresentationData
if let updatedPresentationData {
presentationData = updatedPresentationData.initial
} else {
presentationData = context.sharedContext.currentPresentationData.with { $0 }
}
let strings = presentationData.strings
var dismissImpl: ((Bool) -> Void)?
let actions: [TextAlertAction] = [TextAlertAction(type: .defaultAction, title: presentationData.strings.WebApp_DisclaimerContinue, action: {
completion(true)
dismissImpl?(true)
}), TextAlertAction(type: .genericAction, title: presentationData.strings.Common_Cancel, action: {
dismissed()
dismissImpl?(true)
})]
let title = presentationData.strings.WebApp_DisclaimerTitle
let text = presentationData.strings.WebApp_DisclaimerText
let additionalText: String? = nil
let contentNode = WebAppTermsAlertContentNode(context: context, theme: AlertControllerTheme(presentationData: presentationData), ptheme: theme, strings: strings, title: title, text: text, additionalText: additionalText, actions: actions)
contentNode.openTerms = {
context.sharedContext.openExternalUrl(context: context, urlContext: .generic, url: presentationData.strings.WebApp_Disclaimer_URL, forceExternal: true, presentationData: context.sharedContext.currentPresentationData.with { $0 }, navigationController: nil, dismissInput: {
})
}
let controller = AlertController(theme: AlertControllerTheme(presentationData: presentationData), contentNode: contentNode)
controller.dismissed = { outside in
if outside {
dismissed()
}
}
dismissImpl = { [weak controller] animated in
if animated {
controller?.dismissAnimated()
} else {
controller?.dismiss()
}
}
return controller
}
@@ -0,0 +1,162 @@
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import TelegramPresentationData
import ComponentFlow
import EmojiStatusComponent
import AccountContext
public struct WebAppTitle: Equatable {
public var title: String
public var counter: String
public var isVerified: Bool
public init(title: String, counter: String, isVerified: Bool) {
self.title = title
self.counter = counter
self.isVerified = isVerified
}
}
public final class WebAppTitleView: UIView {
private let context: AccountContext
private let titleNode: ImmediateTextNode
private let subtitleNode: ImmediateTextNode
private var titleCredibilityIconView: ComponentHostView<Empty>?
public var title: WebAppTitle = WebAppTitle(title: "", counter: "", isVerified: false) {
didSet {
if self.title != oldValue {
self.update()
}
}
}
public var theme: PresentationTheme {
didSet {
self.update()
}
}
private let isAttachMenu: Bool
private var primaryTextColor: UIColor?
private var secondaryTextColor: UIColor?
public func updateTextColors(primary: UIColor?, secondary: UIColor?, transition: ContainedViewLayoutTransition) {
self.primaryTextColor = primary
self.secondaryTextColor = secondary
if case let .animated(duration, curve) = transition {
if let snapshotView = self.snapshotContentTree() {
snapshotView.frame = self.bounds
self.addSubview(snapshotView)
snapshotView.layer.animateAlpha(from: 1.0, to: 0.0, duration: duration, timingFunction: curve.timingFunction, removeOnCompletion: false, completion: { _ in
snapshotView.removeFromSuperview()
})
self.titleNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: duration, timingFunction: curve.timingFunction)
self.subtitleNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: duration, timingFunction: curve.timingFunction)
}
}
self.update()
}
private func update() {
let primaryTextColor = self.primaryTextColor ?? self.theme.rootController.navigationBar.primaryTextColor
let secondaryTextColor = self.secondaryTextColor ?? self.theme.rootController.navigationBar.secondaryTextColor
self.titleNode.attributedText = NSAttributedString(string: self.title.title, font: Font.semibold(17.0), textColor: primaryTextColor)
self.subtitleNode.attributedText = NSAttributedString(string: self.title.counter, font: Font.regular(13.0), textColor: secondaryTextColor)
self.accessibilityLabel = self.title.title
self.accessibilityValue = self.title.counter
self.setNeedsLayout()
}
public init(context: AccountContext, theme: PresentationTheme, isAttachMenu: Bool) {
self.context = context
self.theme = theme
self.isAttachMenu = isAttachMenu
self.titleNode = ImmediateTextNode()
self.titleNode.displaysAsynchronously = false
self.titleNode.maximumNumberOfLines = 1
self.titleNode.truncationType = .end
self.titleNode.isOpaque = false
self.subtitleNode = ImmediateTextNode()
self.subtitleNode.displaysAsynchronously = false
self.subtitleNode.maximumNumberOfLines = 1
self.subtitleNode.truncationType = .end
self.subtitleNode.isOpaque = false
super.init(frame: CGRect())
self.isAccessibilityElement = true
self.accessibilityTraits = .header
self.addSubnode(self.titleNode)
self.addSubnode(self.subtitleNode)
}
required public init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override public func layoutSubviews() {
super.layoutSubviews()
let size = self.bounds.size
let spacing: CGFloat = 0.0
let titleSize = self.titleNode.updateLayout(CGSize(width: max(1.0, size.width), height: size.height))
let subtitleSize = self.subtitleNode.updateLayout(CGSize(width: max(1.0, size.width), height: size.height))
let combinedHeight = titleSize.height + subtitleSize.height + spacing
var totalWidth = titleSize.width
let topOffset = self.isAttachMenu ? 3.0 : 0.0
if self.title.isVerified {
let statusContent: EmojiStatusComponent.Content = .verified(fillColor: self.theme.list.itemCheckColors.fillColor, foregroundColor: self.theme.list.itemCheckColors.foregroundColor, sizeType: .large)
let titleCredibilityIconTransition: ComponentTransition = .immediate
let titleCredibilityIconView: ComponentHostView<Empty>
if let current = self.titleCredibilityIconView {
titleCredibilityIconView = current
} else {
titleCredibilityIconView = ComponentHostView<Empty>()
self.titleCredibilityIconView = titleCredibilityIconView
self.addSubview(titleCredibilityIconView)
}
let titleIconSize = titleCredibilityIconView.update(
transition: titleCredibilityIconTransition,
component: AnyComponent(EmojiStatusComponent(
context: self.context,
animationCache: self.context.animationCache,
animationRenderer: self.context.animationRenderer,
content: statusContent,
isVisibleForAnimations: true,
action: {
}
)),
environment: {},
containerSize: CGSize(width: 20.0, height: 20.0)
)
totalWidth += titleIconSize.width + 2.0
titleCredibilityIconTransition.setFrame(view: titleCredibilityIconView, frame: CGRect(origin: CGPoint(x:floorToScreenPixels((size.width - totalWidth) / 2.0) + titleSize.width + 2.0, y: topOffset + floorToScreenPixels(floorToScreenPixels((size.height - combinedHeight) / 2.0 + titleSize.height / 2.0) - titleIconSize.height / 2.0)), size: titleIconSize))
}
let titleFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - totalWidth) / 2.0), y: topOffset + floorToScreenPixels((size.height - combinedHeight) / 2.0)), size: titleSize)
self.titleNode.frame = titleFrame
let subtitleFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - subtitleSize.width) / 2.0), y: topOffset + floorToScreenPixels((size.height - combinedHeight) / 2.0) + titleSize.height + spacing), size: subtitleSize)
self.subtitleNode.frame = subtitleFrame
}
}
@@ -0,0 +1,284 @@
import Foundation
import UIKit
import Display
import WebKit
import SwiftSignalKit
import TelegramCore
private let findActiveElementY = """
function getOffset(el) {
const rect = el.getBoundingClientRect();
return {
left: rect.left + window.scrollX,
top: rect.top + window.scrollY
};
}
getOffset(document.activeElement).top;
"""
private class WeakGameScriptMessageHandler: 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)
}
}
private class WebViewTouchGestureRecognizer: UITapGestureRecognizer {
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
self.state = .began
}
}
private let eventProxySource = "var TelegramWebviewProxyProto = function() {}; " +
"TelegramWebviewProxyProto.prototype.postEvent = function(eventName, eventData) { " +
"window.webkit.messageHandlers.performAction.postMessage({'eventName': eventName, 'eventData': eventData}); " +
"}; " +
"var TelegramWebviewProxy = new TelegramWebviewProxyProto();"
private let selectionSource = "var css = '*{-webkit-touch-callout:none;} :not(input):not(textarea):not([\"contenteditable\"=\"true\"]){-webkit-user-select:none;}';"
+ " var head = document.head || document.getElementsByTagName('head')[0];"
+ " var style = document.createElement('style'); style.type = 'text/css';" +
" style.appendChild(document.createTextNode(css)); head.appendChild(style);"
private let videoSource = """
document.addEventListener('DOMContentLoaded', () => {
function tgBrowserDisableWebkitEnterFullscreen(videoElement) {
if (videoElement && videoElement.webkitEnterFullscreen) {
videoElement.setAttribute('playsinline', '');
}
}
function tgBrowserDisableFullscreenOnExistingVideos() {
document.querySelectorAll('video').forEach(tgBrowserDisableWebkitEnterFullscreen);
}
function tgBrowserHandleMutations(mutations) {
mutations.forEach((mutation) => {
if (mutation.addedNodes && mutation.addedNodes.length > 0) {
mutation.addedNodes.forEach((newNode) => {
if (newNode.tagName === 'VIDEO') {
tgBrowserDisableWebkitEnterFullscreen(newNode);
}
if (newNode.querySelectorAll) {
newNode.querySelectorAll('video').forEach(tgBrowserDisableWebkitEnterFullscreen);
}
});
}
});
}
tgBrowserDisableFullscreenOnExistingVideos();
const _tgbrowser_observer = new MutationObserver(tgBrowserHandleMutations);
_tgbrowser_observer.observe(document.body, {
childList: true,
subtree: true
});
function tgBrowserDisconnectObserver() {
_tgbrowser_observer.disconnect();
}
});
"""
final class WebAppWebView: WKWebView {
var handleScriptMessage: (WKScriptMessage) -> Void = { _ in }
var customInsets: UIEdgeInsets = .zero {
didSet {
if self.customInsets != oldValue {
self.setNeedsLayout()
}
}
}
override var safeAreaInsets: UIEdgeInsets {
return UIEdgeInsets(top: self.customInsets.top, left: self.customInsets.left, bottom: self.customInsets.bottom, right: self.customInsets.right)
}
init(account: Account) {
let configuration = WKWebViewConfiguration()
if #available(iOS 17.0, *) {
var uuid: UUID?
if let current = UserDefaults.standard.object(forKey: "TelegramWebStoreUUID_\(account.id.int64)") as? String {
uuid = UUID(uuidString: current)!
} else {
let mainAccountId: Int64
if let current = UserDefaults.standard.object(forKey: "TelegramWebStoreMainAccountId") as? Int64 {
mainAccountId = current
} else {
mainAccountId = account.id.int64
UserDefaults.standard.set(mainAccountId, forKey: "TelegramWebStoreMainAccountId")
}
if account.id.int64 != mainAccountId {
uuid = UUID()
UserDefaults.standard.set(uuid!.uuidString, forKey: "TelegramWebStoreUUID_\(account.id.int64)")
}
}
if let uuid {
configuration.websiteDataStore = WKWebsiteDataStore(forIdentifier: uuid)
}
}
let contentController = WKUserContentController()
var handleScriptMessageImpl: ((WKScriptMessage) -> Void)?
let eventProxyScript = WKUserScript(source: eventProxySource, injectionTime: .atDocumentStart, forMainFrameOnly: false)
contentController.addUserScript(eventProxyScript)
contentController.add(WeakGameScriptMessageHandler { message in
handleScriptMessageImpl?(message)
}, name: "performAction")
let selectionScript = WKUserScript(source: selectionSource, injectionTime: .atDocumentEnd, forMainFrameOnly: true)
contentController.addUserScript(selectionScript)
let videoScript = WKUserScript(source: videoSource, injectionTime: .atDocumentStart, forMainFrameOnly: false)
contentController.addUserScript(videoScript)
configuration.userContentController = contentController
configuration.allowsInlineMediaPlayback = true
configuration.allowsPictureInPictureMediaPlayback = false
if #available(iOS 10.0, *) {
configuration.mediaTypesRequiringUserActionForPlayback = []
} else {
configuration.mediaPlaybackRequiresUserAction = false
}
super.init(frame: CGRect(), configuration: configuration)
self.disablesInteractiveKeyboardGestureRecognizer = true
self.isOpaque = false
self.backgroundColor = .clear
if #available(iOS 9.0, *) {
self.allowsLinkPreview = false
}
if #available(iOS 11.0, *) {
self.scrollView.contentInsetAdjustmentBehavior = .never
}
self.interactiveTransitionGestureRecognizerTest = { point -> Bool in
return point.x > 30.0
}
self.allowsBackForwardNavigationGestures = false
if #available(iOS 16.4, *) {
self.isInspectable = true
}
handleScriptMessageImpl = { [weak self] message in
if let strongSelf = self {
strongSelf.handleScriptMessage(message)
}
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
print()
}
override func didMoveToSuperview() {
super.didMoveToSuperview()
if #available(iOS 11.0, *) {
let webScrollView = self.subviews.compactMap { $0 as? UIScrollView }.first
Queue.mainQueue().after(0.1, {
let contentView = webScrollView?.subviews.first(where: { $0.interactions.count > 1 })
guard let dragInteraction = (contentView?.interactions.compactMap { $0 as? UIDragInteraction }.first) else {
return
}
contentView?.removeInteraction(dragInteraction)
})
NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillChangeFrameNotification, object: nil)
NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillShowNotification, object: nil)
NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillHideNotification, object: nil)
}
}
func hideScrollIndicators() {
var hiddenViews: [UIView] = []
for view in self.scrollView.subviews.reversed() {
let minSize = min(view.frame.width, view.frame.height)
if minSize < 4.0 {
view.isHidden = true
hiddenViews.append(view)
}
}
Queue.mainQueue().after(2.0) {
for view in hiddenViews {
view.isHidden = false
}
}
}
func sendEvent(name: String, data: String?) {
let script = "window.TelegramGameProxy && window.TelegramGameProxy.receiveEvent && window.TelegramGameProxy.receiveEvent(\"\(name)\", \(data ?? "null"))"
self.evaluateJavaScript(script, completionHandler: { _, _ in
})
}
func updateMetrics(height: CGFloat, isExpanded: Bool, isStable: Bool, transition: ContainedViewLayoutTransition) {
let viewportData = "{height:\(height), is_expanded:\(isExpanded ? "true" : "false"), is_state_stable:\(isStable ? "true" : "false")}"
self.sendEvent(name: "viewport_changed", data: viewportData)
let safeInsetsData = "{top:\(self.customInsets.top), bottom:\(self.customInsets.bottom), left:\(self.customInsets.left), right:\(self.customInsets.right)}"
self.sendEvent(name: "safe_area_changed", data: safeInsetsData)
}
var lastTouchTimestamp: Double?
private(set) var didTouchOnce = false
var onFirstTouch: () -> Void = {}
func scrollToActiveElement(layout: ContainerViewLayout, completion: @escaping (CGPoint) -> Void, transition: ContainedViewLayoutTransition) {
self.evaluateJavaScript(findActiveElementY, completionHandler: { result, _ in
if let result = result as? CGFloat {
Queue.mainQueue().async {
let convertedY = result - self.scrollView.contentOffset.y
let viewportHeight = self.frame.height
if convertedY < 0.0 || (convertedY + 44.0) > viewportHeight {
let targetOffset: CGFloat
if convertedY < 0.0 {
targetOffset = max(0.0, result - 36.0)
} else {
targetOffset = max(0.0, result + 60.0 - viewportHeight)
}
let contentOffset = CGPoint(x: 0.0, y: targetOffset)
completion(contentOffset)
transition.animateView({
self.scrollView.contentOffset = contentOffset
})
}
}
}
})
}
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
let result = super.hitTest(point, with: event)
self.lastTouchTimestamp = CACurrentMediaTime()
if result != nil && !self.didTouchOnce {
self.didTouchOnce = true
self.onFirstTouch()
}
return result
}
override var inputAccessoryView: UIView? {
return nil
}
}