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,466 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import ComponentFlow
import SwiftSignalKit
import TelegramPresentationData
import AccountContext
import BundleIconComponent
import MultilineTextComponent
import UrlEscaping
final class AddressBarContentComponent: Component {
public typealias EnvironmentType = BrowserNavigationBarEnvironment
let theme: PresentationTheme
let strings: PresentationStrings
let metrics: LayoutMetrics
let url: String
let isSecure: Bool
let isExpanded: Bool
let performAction: ActionSlot<BrowserScreen.Action>
init(
theme: PresentationTheme,
strings: PresentationStrings,
metrics: LayoutMetrics,
url: String,
isSecure: Bool,
isExpanded: Bool,
performAction: ActionSlot<BrowserScreen.Action>
) {
self.theme = theme
self.strings = strings
self.metrics = metrics
self.url = url
self.isSecure = isSecure
self.isExpanded = isExpanded
self.performAction = performAction
}
static func ==(lhs: AddressBarContentComponent, rhs: AddressBarContentComponent) -> Bool {
if lhs.theme !== rhs.theme {
return false
}
if lhs.strings !== rhs.strings {
return false
}
if lhs.metrics != rhs.metrics {
return false
}
if lhs.url != rhs.url {
return false
}
if lhs.isSecure != rhs.isSecure {
return false
}
if lhs.isExpanded != rhs.isExpanded {
return false
}
return true
}
final class View: UIView, UITextFieldDelegate {
private final class TextField: UITextField {
override func textRect(forBounds bounds: CGRect) -> CGRect {
return bounds.integral
}
override var canBecomeFirstResponder: Bool {
var canBecomeFirstResponder = super.canBecomeFirstResponder
if !canBecomeFirstResponder && self.alpha.isZero {
canBecomeFirstResponder = true
}
return canBecomeFirstResponder
}
}
private struct Params: Equatable {
var theme: PresentationTheme
var strings: PresentationStrings
var size: CGSize
var isActive: Bool
var title: String
var isSecure: Bool
var collapseFraction: CGFloat
var isTablet: Bool
static func ==(lhs: Params, rhs: Params) -> Bool {
if lhs.theme !== rhs.theme {
return false
}
if lhs.strings !== rhs.strings {
return false
}
if lhs.size != rhs.size {
return false
}
if lhs.isActive != rhs.isActive {
return false
}
if lhs.title != rhs.title {
return false
}
if lhs.isSecure != rhs.isSecure {
return false
}
if lhs.collapseFraction != rhs.collapseFraction {
return false
}
if lhs.isTablet != rhs.isTablet {
return false
}
return true
}
}
private let activated: (Bool) -> Void = { _ in }
private let deactivated: (Bool) -> Void = { _ in }
private let backgroundLayer: SimpleLayer
private let iconView: UIImageView
private let clearIconView: UIImageView
private let clearIconButton: HighlightTrackingButton
private let cancelButtonTitle: ComponentView<Empty>
private let cancelButton: HighlightTrackingButton
private var placeholderContent = ComponentView<Empty>()
private var titleContent = ComponentView<Empty>()
private var textFrame: CGRect?
private var textField: TextField?
private var tapRecognizer: UITapGestureRecognizer?
private var params: Params?
private var component: AddressBarContentComponent?
public var wantsDisplayBelowKeyboard: Bool {
return self.textField != nil
}
init() {
self.backgroundLayer = SimpleLayer()
self.iconView = UIImageView()
self.clearIconView = UIImageView()
self.clearIconButton = HighlightableButton()
self.clearIconView.isHidden = false
self.clearIconButton.isHidden = false
self.cancelButtonTitle = ComponentView()
self.cancelButton = HighlightTrackingButton()
super.init(frame: CGRect())
self.layer.addSublayer(self.backgroundLayer)
self.addSubview(self.iconView)
self.addSubview(self.clearIconView)
self.addSubview(self.clearIconButton)
self.addSubview(self.cancelButton)
self.clipsToBounds = true
let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:)))
self.tapRecognizer = tapRecognizer
self.addGestureRecognizer(tapRecognizer)
self.cancelButton.highligthedChanged = { [weak self] highlighted in
if let strongSelf = self {
if highlighted {
if let cancelButtonTitleView = strongSelf.cancelButtonTitle.view {
cancelButtonTitleView.layer.removeAnimation(forKey: "opacity")
cancelButtonTitleView.alpha = 0.4
}
} else {
if let cancelButtonTitleView = strongSelf.cancelButtonTitle.view {
cancelButtonTitleView.alpha = 1.0
cancelButtonTitleView.layer.animateAlpha(from: 0.4, to: 1.0, duration: 0.2)
}
}
}
}
self.cancelButton.addTarget(self, action: #selector(self.cancelPressed), for: .touchUpInside)
self.clearIconButton.highligthedChanged = { [weak self] highlighted in
if let strongSelf = self {
if highlighted {
strongSelf.clearIconView.layer.removeAnimation(forKey: "opacity")
strongSelf.clearIconView.alpha = 0.4
} else {
strongSelf.clearIconView.alpha = 1.0
strongSelf.clearIconView.layer.animateAlpha(from: 0.4, to: 1.0, duration: 0.2)
}
}
}
self.clearIconButton.addTarget(self, action: #selector(self.clearPressed), for: .touchUpInside)
}
required public init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@objc private func tapGesture(_ recognizer: UITapGestureRecognizer) {
if case .ended = recognizer.state, let component = self.component, !component.isExpanded {
component.performAction.invoke(.openAddressBar)
}
}
private func activateTextInput() {
self.activated(true)
if let textField = self.textField {
textField.becomeFirstResponder()
Queue.mainQueue().after(0.3, {
textField.selectAll(nil)
})
}
}
private func deactivateTextInput() {
self.textField?.endEditing(true)
}
@objc private func cancelPressed() {
self.deactivated(self.textField?.isFirstResponder ?? false)
self.component?.performAction.invoke(.closeAddressBar)
}
@objc private func clearPressed() {
guard let textField = self.textField else {
return
}
textField.text = ""
self.textFieldChanged(textField)
}
public func textFieldDidBeginEditing(_ textField: UITextField) {
}
public func textFieldDidEndEditing(_ textField: UITextField) {
}
public func textFieldShouldReturn(_ textField: UITextField) -> Bool {
if let component = self.component {
let finalUrl = explicitUrl(textField.text ?? "")
component.performAction.invoke(.navigateTo(finalUrl, true))
}
textField.endEditing(true)
return false
}
@objc private func textFieldChanged(_ textField: UITextField) {
let text = textField.text ?? ""
self.clearIconView.isHidden = text.isEmpty
self.clearIconButton.isHidden = text.isEmpty
self.placeholderContent.view?.isHidden = !text.isEmpty
if let params = self.params {
self.update(theme: params.theme, strings: params.strings, size: params.size, isActive: params.isActive, title: params.title, isSecure: params.isSecure, collapseFraction: params.collapseFraction, isTablet: params.isTablet, transition: .immediate)
}
}
func update(component: AddressBarContentComponent, availableSize: CGSize, environment: Environment<BrowserNavigationBarEnvironment>, transition: ComponentTransition) -> CGSize {
let collapseFraction = environment[BrowserNavigationBarEnvironment.self].fraction
let wasExpanded = self.component?.isExpanded ?? false
self.component = component
if !wasExpanded && component.isExpanded {
self.activateTextInput()
}
if wasExpanded && !component.isExpanded {
self.deactivateTextInput()
}
let isActive = self.textField?.isFirstResponder ?? false
let title = getDisplayUrl(component.url, hostOnly: true)
self.update(theme: component.theme, strings: component.strings, size: availableSize, isActive: isActive, title: title.lowercased(), isSecure: component.isSecure, collapseFraction: collapseFraction, isTablet: component.metrics.isTablet, transition: transition)
return availableSize
}
public func update(theme: PresentationTheme, strings: PresentationStrings, size: CGSize, isActive: Bool, title: String, isSecure: Bool, collapseFraction: CGFloat, isTablet: Bool, transition: ComponentTransition) {
let params = Params(
theme: theme,
strings: strings,
size: size,
isActive: isActive,
title: title,
isSecure: isSecure,
collapseFraction: collapseFraction,
isTablet: isTablet
)
if self.params == params {
return
}
let isActiveWithText = self.component?.isExpanded ?? false
if self.params?.theme !== theme {
self.iconView.image = generateTintedImage(image: UIImage(bundleImageName: "Media Grid/Lock"), color: .white)?.withRenderingMode(.alwaysTemplate)
self.iconView.tintColor = theme.rootController.navigationSearchBar.inputIconColor
self.clearIconView.image = generateTintedImage(image: UIImage(bundleImageName: "Components/Search Bar/Clear"), color: .white)?.withRenderingMode(.alwaysTemplate)
self.clearIconView.tintColor = theme.rootController.navigationSearchBar.inputClearButtonColor
}
self.params = params
let sideInset: CGFloat = 10.0
let inputHeight: CGFloat = 36.0
let topInset: CGFloat = (size.height - inputHeight) / 2.0
self.backgroundLayer.backgroundColor = theme.rootController.navigationSearchBar.inputFillColor.cgColor
self.backgroundLayer.cornerRadius = 10.5
transition.setAlpha(layer: self.backgroundLayer, alpha: max(0.0, min(1.0, 1.0 - collapseFraction * 1.5)))
let cancelTextSize = self.cancelButtonTitle.update(
transition: .immediate,
component: AnyComponent(Text(
text: strings.Common_Cancel,
font: Font.regular(17.0),
color: theme.rootController.navigationBar.accentTextColor
)),
environment: {},
containerSize: CGSize(width: size.width - 32.0, height: 100.0)
)
let cancelButtonSpacing: CGFloat = 8.0
var backgroundFrame = CGRect(origin: CGPoint(x: sideInset, y: topInset), size: CGSize(width: size.width - sideInset * 2.0, height: inputHeight))
if isActiveWithText && !isTablet {
backgroundFrame.size.width -= cancelTextSize.width + cancelButtonSpacing
}
transition.setFrame(layer: self.backgroundLayer, frame: backgroundFrame)
transition.setFrame(view: self.cancelButton, frame: CGRect(origin: CGPoint(x: backgroundFrame.maxX, y: 0.0), size: CGSize(width: cancelButtonSpacing + cancelTextSize.width, height: size.height)))
self.cancelButton.isUserInteractionEnabled = isActiveWithText && !isTablet
let textX: CGFloat = backgroundFrame.minX + sideInset
let textFrame = CGRect(origin: CGPoint(x: textX, y: backgroundFrame.minY), size: CGSize(width: backgroundFrame.maxX - textX, height: backgroundFrame.height))
let placeholderSize = self.placeholderContent.update(
transition: transition,
component: AnyComponent(
Text(text: strings.WebBrowser_AddressPlaceholder, font: Font.regular(17.0), color: theme.rootController.navigationSearchBar.inputPlaceholderTextColor)
),
environment: {},
containerSize: size
)
if let placeholderContentView = self.placeholderContent.view {
if placeholderContentView.superview == nil {
placeholderContentView.alpha = 0.0
placeholderContentView.isHidden = true
self.addSubview(placeholderContentView)
}
let placeholderContentFrame = CGRect(origin: CGPoint(x: textFrame.minX, y: backgroundFrame.midY - placeholderSize.height / 2.0), size: placeholderSize)
transition.setFrame(view: placeholderContentView, frame: placeholderContentFrame)
transition.setAlpha(view: placeholderContentView, alpha: isActiveWithText ? 1.0 : 0.0)
}
let titleSize = self.titleContent.update(
transition: transition,
component: AnyComponent(
MultilineTextComponent(
text: .plain(NSAttributedString(string: title, font: Font.regular(17.0), textColor: theme.rootController.navigationSearchBar.inputTextColor)),
horizontalAlignment: .center,
truncationType: .end,
maximumNumberOfLines: 1
)
),
environment: {},
containerSize: CGSize(width: size.width - 36.0, height: size.height)
)
var titleContentFrame = CGRect(origin: CGPoint(x: isActiveWithText ? textFrame.minX : backgroundFrame.midX - titleSize.width / 2.0, y: backgroundFrame.midY - titleSize.height / 2.0), size: titleSize)
if isSecure && !isActiveWithText {
titleContentFrame.origin.x += 7.0
}
var titleSizeChanged = false
if let titleContentView = self.titleContent.view {
if titleContentView.superview == nil {
self.addSubview(titleContentView)
}
if titleContentView.frame.width != titleContentFrame.size.width {
titleSizeChanged = true
}
transition.setPosition(view: titleContentView, position: titleContentFrame.center)
titleContentView.bounds = CGRect(origin: .zero, size: titleContentFrame.size)
transition.setAlpha(view: titleContentView, alpha: isActiveWithText ? 0.0 : 1.0)
}
if let image = self.iconView.image {
let iconFrame = CGRect(origin: CGPoint(x: titleContentFrame.minX - image.size.width - 3.0, y: backgroundFrame.minY + floor((backgroundFrame.height - image.size.height) / 2.0)), size: image.size)
var iconTransition = transition
if titleSizeChanged {
iconTransition = .immediate
}
iconTransition.setFrame(view: self.iconView, frame: iconFrame)
transition.setAlpha(view: self.iconView, alpha: isActiveWithText || !isSecure ? 0.0 : 1.0)
}
if let image = self.clearIconView.image {
let iconFrame = CGRect(origin: CGPoint(x: backgroundFrame.maxX - image.size.width - 4.0, y: backgroundFrame.minY + floor((backgroundFrame.height - image.size.height) / 2.0)), size: image.size)
transition.setFrame(view: self.clearIconView, frame: iconFrame)
transition.setFrame(view: self.clearIconButton, frame: iconFrame.insetBy(dx: -8.0, dy: -10.0))
transition.setAlpha(view: self.clearIconView, alpha: isActiveWithText ? 1.0 : 0.0)
self.clearIconButton.isUserInteractionEnabled = isActiveWithText
}
if let cancelButtonTitleComponentView = self.cancelButtonTitle.view {
if cancelButtonTitleComponentView.superview == nil {
self.addSubview(cancelButtonTitleComponentView)
cancelButtonTitleComponentView.isUserInteractionEnabled = false
}
transition.setFrame(view: cancelButtonTitleComponentView, frame: CGRect(origin: CGPoint(x: backgroundFrame.maxX + cancelButtonSpacing, y: floor((size.height - cancelTextSize.height) / 2.0)), size: cancelTextSize))
transition.setAlpha(view: cancelButtonTitleComponentView, alpha: isActiveWithText && !isTablet ? 1.0 : 0.0)
}
let textFieldFrame = CGRect(origin: CGPoint(x: textFrame.minX, y: backgroundFrame.minY), size: CGSize(width: backgroundFrame.maxX - textFrame.minX, height: backgroundFrame.height))
let textField: TextField
if let current = self.textField {
textField = current
} else {
textField = TextField(frame: textFieldFrame)
textField.autocapitalizationType = .none
textField.autocorrectionType = .no
textField.keyboardType = .URL
textField.returnKeyType = .go
self.insertSubview(textField, belowSubview: self.clearIconView)
self.textField = textField
textField.delegate = self
textField.addTarget(self, action: #selector(self.textFieldChanged(_:)), for: .editingChanged)
}
let address = getDisplayUrl(self.component?.url ?? "", trim: false)
if textField.text != address {
textField.text = address
self.clearIconView.isHidden = address.isEmpty
self.clearIconButton.isHidden = address.isEmpty
self.placeholderContent.view?.isHidden = !address.isEmpty
}
textField.textColor = theme.rootController.navigationSearchBar.inputTextColor
transition.setFrame(view: textField, frame: CGRect(origin: CGPoint(x: backgroundFrame.minX + sideInset, y: backgroundFrame.minY - UIScreenPixel), size: CGSize(width: backgroundFrame.width - sideInset - 32.0, height: backgroundFrame.height)))
transition.setAlpha(view: textField, alpha: isActiveWithText ? 1.0 : 0.0)
textField.isUserInteractionEnabled = isActiveWithText
}
}
func makeView() -> View {
return View()
}
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<BrowserNavigationBarEnvironment>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, environment: environment, transition: transition)
}
}
@@ -0,0 +1,707 @@
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import ComponentFlow
import SwiftSignalKit
import Postbox
import TelegramCore
import AccountContext
import TelegramPresentationData
import ContextUI
import UndoUI
import ListActionItemComponent
final class BrowserAddressListComponent: Component {
let context: AccountContext
let theme: PresentationTheme
let strings: PresentationStrings
let insets: UIEdgeInsets
let metrics: LayoutMetrics
let addressBarFrame: CGRect
let performAction: ActionSlot<BrowserScreen.Action>
let presentInGlobalOverlay: (ViewController) -> Void
init(
context: AccountContext,
theme: PresentationTheme,
strings: PresentationStrings,
insets: UIEdgeInsets,
metrics: LayoutMetrics,
addressBarFrame: CGRect,
performAction: ActionSlot<BrowserScreen.Action>,
presentInGlobalOverlay: @escaping (ViewController) -> Void
) {
self.context = context
self.theme = theme
self.strings = strings
self.insets = insets
self.metrics = metrics
self.addressBarFrame = addressBarFrame
self.performAction = performAction
self.presentInGlobalOverlay = presentInGlobalOverlay
}
static func ==(lhs: BrowserAddressListComponent, rhs: BrowserAddressListComponent) -> Bool {
if lhs.context !== rhs.context {
return false
}
if lhs.theme !== rhs.theme {
return false
}
if lhs.strings !== rhs.strings {
return false
}
if lhs.insets != rhs.insets {
return false
}
if lhs.metrics != rhs.metrics {
return false
}
if lhs.addressBarFrame != rhs.addressBarFrame {
return false
}
return true
}
private struct ItemLayout: Equatable {
struct Section: Equatable {
var id: Int
var insets: UIEdgeInsets
var itemHeight: CGFloat
var itemCount: Int
var hasMore: Bool
var totalHeight: CGFloat
init(
id: Int,
insets: UIEdgeInsets,
itemHeight: CGFloat,
itemCount: Int,
hasMore: Bool
) {
self.id = id
self.insets = insets
self.itemHeight = itemHeight
self.itemCount = itemCount
self.hasMore = hasMore
var totalHeight = insets.top + itemHeight * CGFloat(itemCount) + insets.bottom
if hasMore {
totalHeight -= itemHeight
totalHeight += 44.0
}
self.totalHeight = totalHeight
}
}
var containerSize: CGSize
var insets: UIEdgeInsets
var sections: [Section]
var contentHeight: CGFloat
init(
containerSize: CGSize,
insets: UIEdgeInsets,
sections: [Section]
) {
self.containerSize = containerSize
self.insets = insets
self.sections = sections
var contentHeight: CGFloat = 0.0
for section in sections {
contentHeight += section.totalHeight
}
self.contentHeight = contentHeight
}
}
private final class ScrollView: UIScrollView {
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
return super.hitTest(point, with: event)
}
override func touchesShouldCancel(in view: UIView) -> Bool {
return true
}
}
final class View: UIView, UIScrollViewDelegate {
struct State {
let recent: [TelegramMediaWebpage]
let isRecentExpanded: Bool
let bookmarks: [Message]
}
private let outerView = UIButton()
private let shadowView = UIImageView()
private let backgroundView = UIView()
private let scrollView = ScrollView()
private let itemContainerView = UIView()
private let addressTemplateItem = ComponentView<Empty>()
private var visibleSectionHeaders: [Int: ComponentView<Empty>] = [:]
private var visibleItems: [AnyHashable: ComponentView<Empty>] = [:]
private var ignoreScrolling: Bool = false
private var component: BrowserAddressListComponent?
private weak var state: EmptyComponentState?
private var itemLayout: ItemLayout?
private var stateDisposable: Disposable?
private var stateValue: State?
private let isRecentExpanded = ValuePromise<Bool>(false)
override init(frame: CGRect) {
super.init(frame: frame)
self.backgroundView.clipsToBounds = true
self.scrollView.alwaysBounceVertical = true
self.scrollView.delegate = self
self.scrollView.showsVerticalScrollIndicator = false
self.addSubview(self.outerView)
self.addSubview(self.shadowView)
self.addSubview(self.backgroundView)
self.backgroundView.addSubview(self.scrollView)
self.scrollView.addSubview(self.itemContainerView)
self.outerView.addTarget(self, action: #selector(self.outerPressed), for: .touchUpInside)
}
required init?(coder: NSCoder) {
fatalError()
}
deinit {
self.stateDisposable?.dispose()
}
@objc private func outerPressed() {
self.component?.performAction.invoke(.closeAddressBar)
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
if !self.ignoreScrolling {
self.updateScrolling(transition: .immediate)
}
}
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
self.window?.endEditing(true)
cancelContextGestures(view: scrollView)
}
private func updateScrolling(transition: ComponentTransition) {
guard let component = self.component, let itemLayout = self.itemLayout, let state = self.stateValue else {
return
}
var topOffset = -self.scrollView.bounds.minY
topOffset = max(0.0, topOffset)
let visibleBounds = self.scrollView.bounds
var visibleFrame = self.scrollView.frame
visibleFrame.origin.x = 0.0
var validIds: [AnyHashable] = []
var validSectionHeaders: [AnyHashable] = []
var sectionOffset: CGFloat = 0.0
let sideInset: CGFloat = 0.0
let containerInset: CGFloat = 0.0
for sectionIndex in 0 ..< itemLayout.sections.count {
let section = itemLayout.sections[sectionIndex]
do {
var sectionHeaderFrame = CGRect(origin: CGPoint(x: sideInset, y: sectionOffset - self.scrollView.bounds.minY), size: CGSize(width: itemLayout.containerSize.width, height: section.insets.top))
let sectionHeaderMinY = topOffset + containerInset
let sectionHeaderMaxY = containerInset + sectionOffset - self.scrollView.bounds.minY + section.totalHeight - 28.0
sectionHeaderFrame.origin.y = max(sectionHeaderFrame.origin.y, sectionHeaderMinY)
sectionHeaderFrame.origin.y = min(sectionHeaderFrame.origin.y, sectionHeaderMaxY)
if visibleFrame.intersects(sectionHeaderFrame) {
validSectionHeaders.append(section.id)
let sectionHeader: ComponentView<Empty>
var sectionHeaderTransition = transition
if let current = self.visibleSectionHeaders[section.id] {
sectionHeader = current
} else {
if !transition.animation.isImmediate {
sectionHeaderTransition = .immediate
}
sectionHeader = ComponentView()
self.visibleSectionHeaders[section.id] = sectionHeader
}
let sectionTitle: String
if section.id == 0 {
sectionTitle = component.strings.WebBrowser_AddressBar_RecentlyVisited
} else if section.id == 1 {
sectionTitle = component.strings.WebBrowser_AddressBar_Bookmarks
} else {
sectionTitle = ""
}
let _ = sectionHeader.update(
transition: sectionHeaderTransition,
component: AnyComponent(SectionHeaderComponent(
theme: component.theme,
style: .plain,
title: sectionTitle,
insets: component.insets,
actionTitle: section.id == 0 ? component.strings.WebBrowser_AddressBar_RecentlyVisited_Clear : nil,
action: { [weak self] in
if let self, let component = self.component {
let _ = clearRecentlyVisitedLinks(engine: component.context.engine).start()
}
}
)),
environment: {},
containerSize: sectionHeaderFrame.size
)
if let sectionHeaderView = sectionHeader.view {
if sectionHeaderView.superview == nil {
self.backgroundView.addSubview(sectionHeaderView)
if !transition.animation.isImmediate {
sectionHeaderView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25)
}
}
let sectionXOffset = self.scrollView.frame.minX
sectionHeaderTransition.setFrame(view: sectionHeaderView, frame: sectionHeaderFrame.offsetBy(dx: sectionXOffset, dy: 0.0))
}
}
}
for i in 0 ..< section.itemCount {
var itemFrame = CGRect(origin: CGPoint(x: sideInset, y: sectionOffset + section.insets.top + CGFloat(i) * section.itemHeight), size: CGSize(width: itemLayout.containerSize.width, height: section.itemHeight))
if !visibleBounds.intersects(itemFrame) {
continue
}
var isMore = false
if section.hasMore && i == 3 {
isMore = true
itemFrame.size.height = 44.0
}
var id: String = ""
if section.id == 0 {
id = "recent_\(state.recent[i].content.url ?? "")"
if isMore {
id = "recent_more"
}
} else if section.id == 1 {
id = "bookmark_\(state.bookmarks[i].id.id)"
if isMore {
id = "bookmark_more"
}
}
let itemId = AnyHashable(id)
validIds.append(itemId)
var itemTransition = transition
let visibleItem: ComponentView<Empty>
if let current = self.visibleItems[itemId] {
visibleItem = current
} else {
visibleItem = ComponentView()
if !transition.animation.isImmediate {
itemTransition = .immediate
}
self.visibleItems[itemId] = visibleItem
}
if isMore {
let _ = visibleItem.update(
transition: itemTransition,
component: AnyComponent(
ListActionItemComponent(
theme: component.theme,
title: AnyComponent(Text(
text: component.strings.WebBrowser_AddressBar_ShowMore,
font: Font.regular(17.0),
color: component.theme.list.itemAccentColor
)),
leftIcon: .custom(
AnyComponentWithIdentity(
id: "icon",
component: AnyComponent(Image(
image: PresentationResourcesItemList.downArrowImage(component.theme),
size: CGSize(width: 30.0, height: 30.0)
))
),
false
),
accessory: nil,
action: { [weak self] _ in
self?.isRecentExpanded.set(true)
},
highlighting: .default,
updateIsHighlighted: { view, _ in
}
)
),
environment: {},
containerSize: itemFrame.size
)
} else {
var webPage: TelegramMediaWebpage?
var itemMessage: Message?
if section.id == 0 {
webPage = state.recent[i]
} else if section.id == 1 {
let message = state.bookmarks[i]
if let primaryUrl = getPrimaryUrl(message: message) {
if let media = message.media.first(where: { $0 is TelegramMediaWebpage }) as? TelegramMediaWebpage {
webPage = media
} else {
webPage = TelegramMediaWebpage(webpageId: MediaId(namespace: 0, id: 0), content: .Loaded(TelegramMediaWebpageLoadedContent(url: primaryUrl, displayUrl: "", hash: 0, type: nil, websiteName: "", title: message.text, text: "", embedUrl: nil, embedType: nil, embedSize: nil, duration: nil, author: nil, isMediaLargeByDefault: nil, imageIsVideoCover: false, image: nil, file: nil, story: nil, attributes: [], instantPage: nil)))
}
itemMessage = message
} else {
continue
}
}
let performAction = component.performAction
let _ = visibleItem.update(
transition: itemTransition,
component: AnyComponent(
BrowserAddressListItemComponent(
context: component.context,
theme: component.theme,
webPage: webPage!,
message: itemMessage,
hasNext: true,
insets: component.insets,
action: {
if let url = webPage?.content.url {
performAction.invoke(.navigateTo(url, false))
}
},
contextAction: { [weak self] webPage, message, sourceView, gesture in
guard let self, let component = self.component, let url = webPage.content.url else {
return
}
let presentationData = component.context.sharedContext.currentPresentationData.with { $0 }
var itemList: [ContextMenuItem] = []
itemList.append(.action(ContextMenuActionItem(text: presentationData.strings.WebBrowser_CopyLink, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Copy"), color: theme.contextMenu.primaryColor)
}, action: { [weak self] _, f in
f(.default)
UIPasteboard.general.string = url
if let self, let component = self.component {
component.presentInGlobalOverlay(UndoOverlayController(presentationData: presentationData, content: .linkCopied(title: nil, text: presentationData.strings.Conversation_LinkCopied), elevatedLayout: false, animateInAsReplacement: false, action: { _ in return false }))
}
})))
if let message {
itemList.append(.action(ContextMenuActionItem(text: presentationData.strings.WebBrowser_DeleteBookmark, textColor: .destructive, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Delete"), color: theme.contextMenu.destructiveColor)
}, action: { [weak self] _, f in
f(.dismissWithoutContent)
if let self, let component = self.component {
let _ = component.context.engine.messages.deleteMessagesInteractively(messageIds: [message.id], type: .forEveryone).startStandalone()
}
})))
} else {
itemList.append(.action(ContextMenuActionItem(text: presentationData.strings.WebBrowser_RemoveRecent, textColor: .destructive, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Delete"), color: theme.contextMenu.destructiveColor)
}, action: { [weak self] _, f in
f(.dismissWithoutContent)
if let self, let component = self.component, let url = webPage.content.url {
let _ = removeRecentlyVisitedLink(engine: component.context.engine, url: url).startStandalone()
}
})))
}
let items = ContextController.Items(content: .list(itemList))
let controller = ContextController(
presentationData: presentationData,
source: .extracted(BrowserAddressListContextExtractedContentSource(contentView: sourceView)),
items: .single(items),
recognizer: nil,
gesture: gesture
)
component.presentInGlobalOverlay(controller)
})
),
environment: {},
containerSize: itemFrame.size
)
}
if let itemView = visibleItem.view {
if itemView.superview == nil {
self.itemContainerView.addSubview(itemView)
if !transition.animation.isImmediate {
transition.animateAlpha(view: itemView, from: 0.0, to: 1.0)
}
}
itemTransition.setFrame(view: itemView, frame: itemFrame)
}
}
sectionOffset += section.totalHeight
}
var removeIds: [AnyHashable] = []
for (id, item) in self.visibleItems {
if !validIds.contains(id) {
removeIds.append(id)
if let itemView = item.view {
if !transition.animation.isImmediate {
itemView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, removeOnCompletion: false, completion: { _ in
itemView.removeFromSuperview()
})
} else {
itemView.removeFromSuperview()
}
}
}
}
for id in removeIds {
self.visibleItems.removeValue(forKey: id)
}
var removeSectionHeaderIds: [Int] = []
for (id, item) in self.visibleSectionHeaders {
if !validSectionHeaders.contains(id) {
removeSectionHeaderIds.append(id)
if let itemView = item.view {
if !transition.animation.isImmediate {
itemView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, removeOnCompletion: false, completion: { _ in
itemView.removeFromSuperview()
})
} else {
itemView.removeFromSuperview()
}
}
}
}
for id in removeSectionHeaderIds {
self.visibleSectionHeaders.removeValue(forKey: id)
}
}
func update(component: BrowserAddressListComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
let themeUpdated = self.component?.theme !== component.theme
if self.component == nil {
self.stateDisposable = combineLatest(queue: Queue.mainQueue(),
recentlyVisitedLinks(engine: component.context.engine),
self.isRecentExpanded.get(),
component.context.account.viewTracker.aroundMessageHistoryViewForLocation(.peer(peerId: component.context.account.peerId, threadId: nil), index: .upperBound, anchorIndex: .upperBound, count: 100, fixedCombinedReadStates: nil, tag: .tag(.webPage))
).start(next: { [weak self] recent, isRecentExpanded, view in
guard let self else {
return
}
var bookmarks: [Message] = []
for entry in view.0.entries.reversed() {
bookmarks.append(entry.message)
}
let isFirstTime = self.stateValue == nil
self.stateValue = State(
recent: recent,
isRecentExpanded: isRecentExpanded,
bookmarks: bookmarks
)
self.state?.updated(transition: isFirstTime ? .immediate : .easeInOut(duration: 0.25))
})
}
self.component = component
self.state = state
self.outerView.isHidden = !component.metrics.isTablet
self.outerView.frame = CGRect(origin: .zero, size: availableSize)
let containerFrame: CGRect
if component.metrics.isTablet {
let containerSize = CGSize(width: component.addressBarFrame.width + 32.0, height: 540.0)
containerFrame = CGRect(origin: CGPoint(x: floor(component.addressBarFrame.center.x - containerSize.width / 2.0), y: 72.0), size: containerSize)
self.backgroundView.layer.cornerRadius = 10.0
} else {
containerFrame = CGRect(origin: .zero, size: availableSize)
self.backgroundView.layer.cornerRadius = 0.0
}
let resetScrolling = self.scrollView.bounds.width != containerFrame.width
if themeUpdated {
self.backgroundView.backgroundColor = component.theme.list.plainBackgroundColor
}
let itemsContainerWidth = availableSize.width
let addressItemSize = self.addressTemplateItem.update(
transition: .immediate,
component: AnyComponent(BrowserAddressListItemComponent(
context: component.context,
theme: component.theme,
webPage: TelegramMediaWebpage(webpageId: EngineMedia.Id(namespace: 0, id: 0), content: .Loaded(TelegramMediaWebpageLoadedContent(url: "https://telegram.org", displayUrl: "https://telegram.org", hash: 0, type: nil, websiteName: "Telegram", title: "Telegram Telegram", text: "Telegram", embedUrl: nil, embedType: nil, embedSize: nil, duration: nil, author: nil, isMediaLargeByDefault: nil, imageIsVideoCover: false, image: nil, file: nil, story: nil, attributes: [], instantPage: nil))),
message: nil,
hasNext: true,
insets: .zero,
action: {},
contextAction: nil
)),
environment: {},
containerSize: CGSize(width: itemsContainerWidth, height: 1000.0)
)
var sections: [ItemLayout.Section] = []
if let state = self.stateValue {
if !state.recent.isEmpty {
var recentCount = state.recent.count
var hasMore = false
if recentCount > 4 && !state.isRecentExpanded {
recentCount = 4
hasMore = true
}
sections.append(ItemLayout.Section(
id: 0,
insets: UIEdgeInsets(top: 28.0, left: 0.0, bottom: 0.0, right: 0.0),
itemHeight: addressItemSize.height,
itemCount: recentCount,
hasMore: hasMore
))
}
if !state.bookmarks.isEmpty {
sections.append(ItemLayout.Section(
id: 1,
insets: UIEdgeInsets(top: 28.0, left: 0.0, bottom: 0.0, right: 0.0),
itemHeight: addressItemSize.height,
itemCount: state.bookmarks.count,
hasMore: false
))
}
}
let itemLayout = ItemLayout(containerSize: containerFrame.size, insets: .zero, sections: sections)
self.itemLayout = itemLayout
let containerWidth = containerFrame.size.width
let scrollContentHeight = max(itemLayout.contentHeight, containerFrame.size.height)
self.ignoreScrolling = true
transition.setFrame(view: self.scrollView, frame: CGRect(origin: .zero, size: containerFrame.size))
let contentSize = CGSize(width: containerWidth, height: scrollContentHeight)
if contentSize != self.scrollView.contentSize {
self.scrollView.contentSize = contentSize
}
if resetScrolling {
self.scrollView.bounds = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: containerWidth, height: containerFrame.size.height))
}
self.ignoreScrolling = false
self.updateScrolling(transition: transition)
transition.setFrame(view: self.backgroundView, frame: containerFrame)
transition.setFrame(view: self.itemContainerView, frame: CGRect(origin: .zero, size: CGSize(width: containerWidth, height: scrollContentHeight)))
if component.metrics.isTablet {
transition.setFrame(view: self.shadowView, frame: containerFrame.insetBy(dx: -60.0, dy: -60.0))
self.shadowView.isHidden = false
if self.shadowView.image == nil {
self.shadowView.image = generateShadowImage()
}
} else {
self.shadowView.isHidden = true
}
return availableSize
}
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
let result = super.hitTest(point, with: event)
if let component = self.component, component.metrics.isTablet {
let addressFrame = CGRect(origin: CGPoint(x: self.backgroundView.frame.minX, y: self.backgroundView.frame.minY - 48.0), size: CGSize(width: self.backgroundView.frame.width, height: 48.0))
if addressFrame.contains(point) {
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)
}
}
private func generateShadowImage() -> UIImage? {
return generateImage(CGSize(width: 140.0, height: 140.0), rotatedContext: { size, context in
context.clear(CGRect(origin: CGPoint(), size: size))
context.saveGState()
context.setShadow(offset: CGSize(), blur: 60.0, color: UIColor(white: 0.0, alpha: 0.4).cgColor)
let path = UIBezierPath(roundedRect: CGRect(x: 60.0, y: 60.0, width: 20.0, height: 20.0), cornerRadius: 10.0).cgPath
context.addPath(path)
context.fillPath()
context.restoreGState()
context.setBlendMode(.clear)
context.addPath(path)
context.fillPath()
})?.stretchableImage(withLeftCapWidth: 70, topCapHeight: 70)
}
private final class BrowserAddressListContextExtractedContentSource: ContextExtractedContentSource {
let keepInPlace: Bool = false
let ignoreContentTouches: Bool = false
let blurBackground: Bool = true
private let contentView: ContextExtractedContentContainingView
init(contentView: ContextExtractedContentContainingView) {
self.contentView = contentView
}
func takeView() -> ContextControllerTakeViewInfo? {
return ContextControllerTakeViewInfo(containingItem: .view(self.contentView), contentAreaInScreenSpace: UIScreen.main.bounds)
}
func putBack() -> ContextControllerPutBackViewInfo? {
return ContextControllerPutBackViewInfo(contentAreaInScreenSpace: UIScreen.main.bounds)
}
}
private func cancelContextGestures(view: UIView) {
if let gestureRecognizers = view.gestureRecognizers {
for gesture in gestureRecognizers {
if let gesture = gesture as? ContextGesture {
gesture.cancel()
}
}
}
for subview in view.subviews {
cancelContextGestures(view: subview)
}
}
@@ -0,0 +1,364 @@
import Foundation
import UIKit
import Display
import ComponentFlow
import SwiftSignalKit
import Postbox
import TelegramCore
import MultilineTextComponent
import TelegramPresentationData
import PhotoResources
import AccountContext
import ContextUI
import UrlEscaping
private let iconFont = Font.with(size: 30.0, design: .round, weight: .bold)
private let iconTextBackgroundImage = generateStretchableFilledCircleImage(radius: 6.0, color: UIColor(rgb: 0xFF9500))
final class BrowserAddressListItemComponent: Component {
let context: AccountContext
let theme: PresentationTheme
let webPage: TelegramMediaWebpage
var message: Message?
let hasNext: Bool
let insets: UIEdgeInsets
let action: () -> Void
let contextAction: ((TelegramMediaWebpage, Message?, ContextExtractedContentContainingView, ContextGesture) -> Void)?
init(
context: AccountContext,
theme: PresentationTheme,
webPage: TelegramMediaWebpage,
message: Message?,
hasNext: Bool,
insets: UIEdgeInsets,
action: @escaping () -> Void,
contextAction: ((TelegramMediaWebpage, Message?, ContextExtractedContentContainingView, ContextGesture) -> Void)?
) {
self.context = context
self.theme = theme
self.webPage = webPage
self.message = message
self.hasNext = hasNext
self.insets = insets
self.action = action
self.contextAction = contextAction
}
static func ==(lhs: BrowserAddressListItemComponent, rhs: BrowserAddressListItemComponent) -> Bool {
if lhs.theme !== rhs.theme {
return false
}
if lhs.webPage != rhs.webPage {
return false
}
if lhs.hasNext != rhs.hasNext {
return false
}
if lhs.insets != rhs.insets {
return false
}
return true
}
final class View: ContextControllerSourceView {
private let extractedContainerView = ContextExtractedContentContainingView()
private let containerButton = HighlightTrackingButton()
private let separatorLayer = SimpleLayer()
private var highlightedBackgroundLayer = SimpleLayer()
private var emptyIcon: UIImageView?
private var emptyLabel: ComponentView<Empty>?
private var icon = TransformImageNode()
private let title = ComponentView<Empty>()
private let subtitle = ComponentView<Empty>()
private var isExtractedToContextMenu: Bool = false
private var component: BrowserAddressListItemComponent?
private weak var state: EmptyComponentState?
private var currentIconImageRepresentation: TelegramMediaImageRepresentation?
override init(frame: CGRect) {
super.init(frame: frame)
self.layer.addSublayer(self.separatorLayer)
self.layer.addSublayer(self.highlightedBackgroundLayer)
self.addSubview(self.extractedContainerView)
self.targetViewForActivationProgress = self.extractedContainerView.contentView
self.highlightedBackgroundLayer.opacity = 0.0
self.extractedContainerView.contentView.addSubview(self.containerButton)
self.containerButton.addTarget(self, action: #selector(self.pressed), for: .touchUpInside)
self.containerButton.highligthedChanged = { [weak self] highlighted in
guard let self else {
return
}
if highlighted {
self.superview?.bringSubviewToFront(self)
self.highlightedBackgroundLayer.removeAnimation(forKey: "opacity")
self.highlightedBackgroundLayer.opacity = 1.0
} else {
self.highlightedBackgroundLayer.opacity = 0.0
self.highlightedBackgroundLayer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2)
}
}
self.extractedContainerView.isExtractedToContextPreviewUpdated = { [weak self] value in
guard let self, let component = self.component else {
return
}
self.containerButton.clipsToBounds = value
self.containerButton.backgroundColor = value ? component.theme.list.plainBackgroundColor : nil
self.containerButton.layer.cornerRadius = value ? 10.0 : 0.0
if value {
self.highlightedBackgroundLayer.opacity = 0.0
}
}
self.extractedContainerView.willUpdateIsExtractedToContextPreview = { [weak self] value, transition in
guard let self else {
return
}
self.isExtractedToContextMenu = value
let mappedTransition: ComponentTransition
if value {
mappedTransition = ComponentTransition(transition)
} else {
mappedTransition = ComponentTransition(animation: .curve(duration: 0.2, curve: .easeInOut))
}
self.state?.updated(transition: mappedTransition)
}
self.activated = { [weak self] gesture, _ in
guard let self, let component = self.component else {
gesture.cancel()
return
}
component.contextAction?(component.webPage, component.message, self.extractedContainerView, gesture)
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@objc private func pressed() {
guard let component = self.component else {
return
}
component.action()
}
func update(component: BrowserAddressListItemComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
let themeUpdated = self.component?.theme !== component.theme
let currentIconImageRepresentation = self.currentIconImageRepresentation
let iconSize = CGSize(width: 40.0, height: 40.0)
let height: CGFloat = 60.0
let leftInset: CGFloat = component.insets.left + 11.0 + iconSize.width + 11.0
let rightInset: CGFloat = 16.0
let titleSpacing: CGFloat = 2.0
let contextInset: CGFloat = self.isExtractedToContextMenu ? 12.0 : 0.0
let title: String
let subtitle: String
var parsedUrl: URL?
var iconImageReferenceAndRepresentation: (AnyMediaReference, TelegramMediaImageRepresentation)?
var updateIconImageSignal: Signal<(TransformImageArguments) -> DrawingContext?, NoError>?
if case let .Loaded(content) = component.webPage.content {
title = content.title ?? content.url
subtitle = getDisplayUrl(content.url)
parsedUrl = URL(string: content.url)
if let image = content.image {
if let representation = imageRepresentationLargerThan(image.representations, size: PixelDimensions(width: 80, height: 80)) {
if let message = component.message {
iconImageReferenceAndRepresentation = (.message(message: MessageReference(message), media: image), representation)
} else {
iconImageReferenceAndRepresentation = (.standalone(media: image), representation)
}
}
} else if let file = content.file {
if let representation = smallestImageRepresentation(file.previewRepresentations) {
if let message = component.message {
iconImageReferenceAndRepresentation = (.message(message: MessageReference(message), media: file), representation)
} else {
iconImageReferenceAndRepresentation = (.standalone(media: file), representation)
}
}
}
if currentIconImageRepresentation != iconImageReferenceAndRepresentation?.1 {
if let iconImageReferenceAndRepresentation = iconImageReferenceAndRepresentation {
if let imageReference = iconImageReferenceAndRepresentation.0.concrete(TelegramMediaImage.self) {
updateIconImageSignal = chatWebpageSnippetPhoto(account: component.context.account, userLocation: (component.message?.id.peerId).flatMap(MediaResourceUserLocation.peer) ?? .other, photoReference: imageReference)
} else if let fileReference = iconImageReferenceAndRepresentation.0.concrete(TelegramMediaFile.self) {
updateIconImageSignal = chatWebpageSnippetFile(account: component.context.account, userLocation: (component.message?.id.peerId).flatMap(MediaResourceUserLocation.peer) ?? .other, mediaReference: fileReference.abstract, representation: iconImageReferenceAndRepresentation.1)
}
} else {
updateIconImageSignal = .complete()
}
}
} else {
title = ""
subtitle = ""
}
self.component = component
self.state = state
self.currentIconImageRepresentation = iconImageReferenceAndRepresentation?.1
let titleSize = self.title.update(
transition: .immediate,
component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(string: title, font: Font.semibold(17.0), textColor: component.theme.list.itemPrimaryTextColor))
)),
environment: {},
containerSize: CGSize(width: availableSize.width - leftInset - rightInset, height: 100.0)
)
let subtitleSize = self.subtitle.update(
transition: .immediate,
component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(string: subtitle, font: Font.regular(15.0), textColor: component.theme.list.itemAccentColor))
)),
environment: {},
containerSize: CGSize(width: availableSize.width - leftInset - rightInset, height: 100.0)
)
let centralContentHeight = titleSize.height + subtitleSize.height + titleSpacing
let titleFrame = CGRect(origin: CGPoint(x: leftInset, y: floor((height - centralContentHeight) / 2.0)), size: titleSize)
if let titleView = self.title.view {
if titleView.superview == nil {
titleView.isUserInteractionEnabled = false
self.containerButton.addSubview(titleView)
}
titleView.frame = titleFrame
}
let subtitleFrame = CGRect(origin: CGPoint(x: leftInset, y: titleFrame.maxY + titleSpacing), size: subtitleSize)
if let subtitleView = self.subtitle.view {
if subtitleView.superview == nil {
subtitleView.isUserInteractionEnabled = false
self.containerButton.addSubview(subtitleView)
}
subtitleView.frame = subtitleFrame
}
let iconFrame = CGRect(origin: CGPoint(x: 11.0 + component.insets.left, y: floorToScreenPixels((height - iconSize.height) / 2.0)), size: iconSize)
let iconImageLayout = self.icon.asyncLayout()
var iconImageApply: (() -> Void)?
if let iconImageReferenceAndRepresentation = iconImageReferenceAndRepresentation {
let imageCorners = ImageCorners(radius: 6.0)
let arguments = TransformImageArguments(corners: imageCorners, imageSize: iconImageReferenceAndRepresentation.1.dimensions.cgSize.aspectFilled(iconSize), boundingSize: iconSize, intrinsicInsets: UIEdgeInsets(), emptyColor: component.theme.list.mediaPlaceholderColor)
iconImageApply = iconImageLayout(arguments)
}
if let iconImageApply = iconImageApply {
if let updateImageSignal = updateIconImageSignal {
self.icon.setSignal(updateImageSignal)
}
if self.icon.supernode == nil {
self.containerButton.addSubview(self.icon.view)
self.icon.frame = iconFrame
} else {
transition.setFrame(view: self.icon.view, frame: iconFrame)
}
iconImageApply()
if let emptyIcon = self.emptyIcon {
self.emptyIcon = nil
emptyIcon.removeFromSuperview()
}
if let emptyLabel = self.emptyLabel {
self.emptyLabel = nil
emptyLabel.view?.removeFromSuperview()
}
} else {
if self.icon.supernode != nil {
self.icon.view.removeFromSuperview()
}
let icon: UIImageView
let label: ComponentView<Empty>
if let currentEmptyIcon = self.emptyIcon, let currentEmptyLabel = self.emptyLabel {
icon = currentEmptyIcon
label = currentEmptyLabel
} else {
icon = UIImageView()
icon.image = iconTextBackgroundImage
self.containerButton.addSubview(icon)
label = ComponentView()
}
icon.frame = iconFrame
var iconText = ""
if let parsedUrl, let host = parsedUrl.host {
if parsedUrl.path.hasPrefix("/addstickers/") {
iconText = "S"
} else if parsedUrl.path.hasPrefix("/addemoji/") {
iconText = "E"
} else {
iconText = host[..<host.index(after: host.startIndex)].uppercased()
}
}
let labelSize = label.update(
transition: .immediate,
component: AnyComponent(Text(text: iconText, font: iconFont, color: .white)),
environment: {},
containerSize: iconSize
)
let labelFrame = CGRect(origin: CGPoint(x: iconFrame.minX + floorToScreenPixels((iconFrame.width - labelSize.width) / 2.0), y: iconFrame.minY + floorToScreenPixels((iconFrame.height - labelSize.height) / 2.0)), size: labelSize)
if let labelView = label.view {
if labelView.superview == nil {
self.containerButton.addSubview(labelView)
}
labelView.frame = labelFrame
}
}
if themeUpdated {
self.highlightedBackgroundLayer.backgroundColor = component.theme.list.itemHighlightedBackgroundColor.cgColor
self.separatorLayer.backgroundColor = component.theme.list.itemPlainSeparatorColor.cgColor
}
transition.setFrame(layer: self.highlightedBackgroundLayer, frame: CGRect(origin: .zero, size: CGSize(width: availableSize.width, height: height + UIScreenPixel)))
transition.setFrame(layer: self.separatorLayer, frame: CGRect(origin: CGPoint(x: leftInset, y: height), size: CGSize(width: availableSize.width - leftInset, height: UIScreenPixel)))
self.separatorLayer.isHidden = !component.hasNext
let containerFrame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: availableSize.width, height: height))
transition.setFrame(view: self.containerButton, frame: containerFrame.insetBy(dx: contextInset, dy: 0.0))
transition.setFrame(view: self.extractedContainerView, frame: containerFrame)
transition.setFrame(view: self.extractedContainerView.contentView, frame: containerFrame)
self.extractedContainerView.contentRect = containerFrame
self.isGestureEnabled = component.contextAction != nil
return CGSize(width: availableSize.width, height: height)
}
}
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,596 @@
import Foundation
import UIKit
import AccountContext
import AsyncDisplayKit
import Display
import SwiftSignalKit
import Postbox
import TelegramCore
import TelegramPresentationData
import PresentationDataUtils
import ChatControllerInteraction
import TelegramUIPreferences
import ChatPresentationInterfaceState
import TextFormat
import UrlWhitelist
import SearchUI
import SearchBarNode
import ChatHistorySearchContainerNode
import ContextUI
import UndoUI
public final class BrowserBookmarksScreen: ViewController {
final class Node: ViewControllerTracingNode, ASScrollViewDelegate {
private let context: AccountContext
private var presentationData: PresentationData
private weak var controller: BrowserBookmarksScreen?
private let controllerInteraction: ChatControllerInteraction
private var searchDisplayController: SearchDisplayController?
fileprivate let historyNode: ChatHistoryListNode
private let bottomPanelNode: BottomPanelNode
private var addedBookmark = false
private var validLayout: (ContainerViewLayout, CGFloat, CGFloat)?
init(context: AccountContext, controller: BrowserBookmarksScreen, presentationData: PresentationData) {
self.context = context
self.controller = controller
self.presentationData = presentationData
var openMessageImpl: ((Message) -> Bool)?
var openContextMenuImpl: ((Message, ASDisplayNode, CGRect, UIGestureRecognizer?) -> Void)?
self.controllerInteraction = ChatControllerInteraction(openMessage: { message, _ in
if let openMessageImpl = openMessageImpl {
return openMessageImpl(message)
} else {
return false
}
}, openPeer: { _, _, _, _ in
}, openPeerMention: { _, _ in
}, openMessageContextMenu: { message, _, sourceView, rect, gesture, _ in
openContextMenuImpl?(message, sourceView, rect, gesture)
}, openMessageReactionContextMenu: { _, _, _, _ in
}, updateMessageReaction: { _, _, _, _ in
}, activateMessagePinch: { _ in
}, openMessageContextActions: { _, _, _, _ in
}, navigateToMessage: { _, _, _ in
}, navigateToMessageStandalone: { _ in
}, navigateToThreadMessage: { _, _, _ in
}, tapMessage: nil, clickThroughMessage: { _, _ in
}, toggleMessagesSelection: { _, _ in
}, sendCurrentMessage: { _, _ in
}, sendMessage: { _ in
}, sendSticker: { _, _, _, _, _, _, _, _, _ in
return false
}, sendEmoji: { _, _, _ in
}, sendGif: { _, _, _, _, _ in
return false
}, sendBotContextResultAsGif: { _, _, _, _, _, _ in
return false
}, requestMessageActionCallback: { _, _, _, _, _ in
}, requestMessageActionUrlAuth: { _, _ in
}, activateSwitchInline: { _, _, _ in
}, openUrl: { [weak controller] url in
if let controller {
controller.openUrl(url.url)
controller.dismiss()
}
}, shareCurrentLocation: {
}, shareAccountContact: {
}, sendBotCommand: { _, _ in
}, openInstantPage: { message, _ in
if let openMessageImpl = openMessageImpl {
let _ = openMessageImpl(message)
}
}, openWallpaper: { _ in
}, openTheme: {_ in
}, openHashtag: { _, _ in
}, updateInputState: { _ in
}, updateInputMode: { _ in
}, openMessageShareMenu: { _ in
}, presentController: { _, _ in
}, presentControllerInCurrent: { _, _ in
}, navigationController: {
return nil
}, chatControllerNode: {
return nil
}, presentGlobalOverlayController: { _, _ in
}, callPeer: { _, _ in
}, openConferenceCall: { _ in
}, longTap: { _, _ in
}, todoItemLongTap: { _, _ in
}, openCheckoutOrReceipt: { _, _ in
}, openSearch: {
}, setupReply: { _ in
}, canSetupReply: { _ in
return .none
}, canSendMessages: {
return false
}, navigateToFirstDateMessage: { _, _ in
}, requestRedeliveryOfFailedMessages: { _ in
}, addContact: { _ in
}, rateCall: { _, _, _ in
}, requestSelectMessagePollOptions: { _, _ in
}, requestOpenMessagePollResults: { _, _ in
}, openAppStorePage: {
}, displayMessageTooltip: { _, _, _, _, _ in
}, seekToTimecode: { _, _, _ in
}, scheduleCurrentMessage: { _ in
}, sendScheduledMessagesNow: { _ in
}, editScheduledMessagesTime: { _ in
}, performTextSelectionAction: { _, _, _, _ in
}, displayImportedMessageTooltip: { _ in
}, displaySwipeToReplyHint: {
}, dismissReplyMarkupMessage: { _ in
}, openMessagePollResults: { _, _ in
}, openPollCreation: { _ in
}, displayPollSolution: { _, _ in
}, displayPsa: { _, _ in
}, displayDiceTooltip: { _ in
}, animateDiceSuccess: { _, _ in
}, displayPremiumStickerTooltip: { _, _ in
}, displayEmojiPackTooltip: { _, _ in
}, openPeerContextMenu: { _, _, _, _, _ in
}, openMessageReplies: { _, _, _ in
}, openReplyThreadOriginalMessage: { _ in
}, openMessageStats: { _ in
}, editMessageMedia: { _, _ in
}, copyText: { _ in
}, displayUndo: { _ in
}, isAnimatingMessage: { _ in
return false
}, getMessageTransitionNode: {
return nil
}, updateChoosingSticker: { _ in
}, commitEmojiInteraction: { _, _, _, _ in
}, openLargeEmojiInfo: { _, _, _ in
}, openJoinLink: { _ in
}, openWebView: { _, _, _, _ in
}, activateAdAction: { _, _, _, _ in
}, adContextAction: { _, _, _ in
}, removeAd: { _ in
}, openRequestedPeerSelection: { _, _, _, _ in
}, saveMediaToFiles: { _ in
}, openNoAdsDemo: {
}, openAdsInfo: {
}, displayGiveawayParticipationStatus: { _ in
}, openPremiumStatusInfo: { _, _, _, _ in
}, openRecommendedChannelContextMenu: { _, _, _ in
}, openGroupBoostInfo: { _, _ in
}, openStickerEditor: {
}, openAgeRestrictedMessageMedia: { _, _ in
}, playMessageEffect: { _ in
}, editMessageFactCheck: { _ in
}, sendGift: { _ in
}, openUniqueGift: { _ in
}, openMessageFeeException: {
}, requestMessageUpdate: { _, _ in
}, cancelInteractiveKeyboardGestures: {
}, dismissTextInput: {
}, scrollToMessageId: { _ in
}, navigateToStory: { _, _ in
}, attemptedNavigationToPrivateQuote: { _ in
}, forceUpdateWarpContents: {
}, playShakeAnimation: {
}, displayQuickShare: { _, _ ,_ in
}, updateChatLocationThread: { _, _ in
}, requestToggleTodoMessageItem: { _, _, _ in
}, displayTodoToggleUnavailable: { _ in
}, openStarsPurchase: { _ in
}, automaticMediaDownloadSettings: MediaAutoDownloadSettings.defaultSettings, pollActionState: ChatInterfacePollActionState(), stickerSettings: ChatInterfaceStickerSettings(), presentationContext: ChatPresentationContext(context: context, backgroundNode: nil))
let tagMask: MessageTags = .webPage
let chatLocationContextHolder = Atomic<ChatLocationContextHolder?>(value: nil)
self.historyNode = context.sharedContext.makeChatHistoryListNode(
context: context,
updatedPresentationData: (context.sharedContext.currentPresentationData.with({ $0 }), context.sharedContext.presentationData),
chatLocation: .peer(id: context.account.peerId),
chatLocationContextHolder: chatLocationContextHolder,
tag: .tag(tagMask),
source: .default,
subject: nil,
controllerInteraction: self.controllerInteraction,
selectedMessages: .single(nil),
mode: .list(
search: false,
reversed: false,
reverseGroups: false,
displayHeaders: .none,
hintLinks: true,
isGlobalSearch: false
)
)
var addBookmarkImpl: (() -> Void)?
self.bottomPanelNode = BottomPanelNode(theme: presentationData.theme, strings: presentationData.strings, action: {
addBookmarkImpl?()
})
super.init()
self.backgroundColor = self.presentationData.theme.list.plainBackgroundColor
self.addSubnode(self.historyNode)
self.addSubnode(self.bottomPanelNode)
openMessageImpl = { [weak controller] message in
guard let controller else {
return false
}
if let primaryUrl = getPrimaryUrl(message: message) {
controller.openUrl(primaryUrl)
}
controller.dismiss()
return true
}
addBookmarkImpl = { [weak self] in
guard let self else {
return
}
self.controller?.addBookmark()
self.addedBookmark = true
if let (layout, navigationBarHeight, actualNavigationBarHeight) = self.validLayout {
self.containerLayoutUpdated(layout: layout, navigationBarHeight: navigationBarHeight, actualNavigationBarHeight: actualNavigationBarHeight, transition: .animated(duration: 0.4, curve: .spring))
}
}
openContextMenuImpl = { [weak self] message, sourceNode, rect, gesture in
guard let self, let sourceNode = sourceNode as? ContextExtractedContentContainingNode else {
return
}
let presentationData = self.context.sharedContext.currentPresentationData.with { $0 }
var itemList: [ContextMenuItem] = []
if let webPage = message.media.first(where: { $0 is TelegramMediaWebpage }) as? TelegramMediaWebpage, let url = webPage.content.url {
itemList.append(.action(ContextMenuActionItem(text: presentationData.strings.WebBrowser_CopyLink, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Copy"), color: theme.contextMenu.primaryColor)
}, action: { [weak self] _, f in
f(.default)
UIPasteboard.general.string = url
if let self {
self.controller?.present(UndoOverlayController(presentationData: presentationData, content: .linkCopied(title: nil, text: presentationData.strings.Conversation_LinkCopied), elevatedLayout: false, animateInAsReplacement: false, action: { _ in return false }), in: .window(.root))
}
})))
}
itemList.append(.action(ContextMenuActionItem(text: presentationData.strings.WebBrowser_DeleteBookmark, textColor: .destructive, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Delete"), color: theme.contextMenu.destructiveColor)
}, action: { [weak self] _, f in
f(.dismissWithoutContent)
if let self {
let _ = self.context.engine.messages.deleteMessagesInteractively(messageIds: [message.id], type: .forEveryone).startStandalone()
}
})))
let items = ContextController.Items(content: .list(itemList))
let controller = ContextController(
presentationData: presentationData,
source: .extracted(BrowserBookmarksContextExtractedContentSource(contentNode: sourceNode)),
items: .single(items),
recognizer: nil,
gesture: gesture as? ContextGesture
)
self.controller?.presentInGlobalOverlay(controller)
}
}
func activateSearch(placeholderNode: SearchBarPlaceholderNode) {
guard let (layout, navigationBarHeight, _) = self.validLayout, let navigationBar = self.controller?.navigationBar else {
return
}
let tagMask: MessageTags = .webPage
self.searchDisplayController = SearchDisplayController(presentationData: self.presentationData, mode: .list, placeholder: self.presentationData.strings.Common_Search, hasBackground: true, contentNode: ChatHistorySearchContainerNode(context: self.context, peerId: self.context.account.peerId, threadId: nil, tagMask: tagMask, interfaceInteraction: self.controllerInteraction), cancel: { [weak self] in
self?.controller?.deactivateSearch()
})
self.searchDisplayController?.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: .immediate)
self.searchDisplayController?.activate(insertSubnode: { [weak self, weak placeholderNode] subnode, isSearchBar in
if let strongSelf = self, let placeholderNode {
if isSearchBar {
placeholderNode.supernode?.insertSubnode(subnode, aboveSubnode: placeholderNode)
} else {
strongSelf.insertSubnode(subnode, belowSubnode: navigationBar)
}
}
}, placeholder: placeholderNode)
}
func deactivateSearch(placeholderNode: SearchBarPlaceholderNode) {
guard let searchDisplayController = self.searchDisplayController else {
return
}
self.searchDisplayController = nil
searchDisplayController.deactivate(placeholder: placeholderNode)
}
func scrollToTop() {
self.historyNode.scrollToEndOfHistory()
}
func containerLayoutUpdated(layout: ContainerViewLayout, navigationBarHeight: CGFloat, actualNavigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition) {
self.validLayout = (layout, navigationBarHeight, actualNavigationBarHeight)
let historyFrame = CGRect(origin: .zero, size: layout.size)
transition.updateFrame(node: self.historyNode, frame: historyFrame)
var insets = layout.insets(options: [.input])
insets.top += navigationBarHeight
var headerInsets = layout.insets(options: [.input])
headerInsets.top += actualNavigationBarHeight
let panelHeight = self.bottomPanelNode.updateLayout(width: layout.size.width, sideInset: layout.safeInsets.left, bottomInset: insets.bottom, transition: transition)
var panelOrigin: CGFloat = layout.size.height
if !self.addedBookmark {
panelOrigin -= panelHeight
insets.bottom = panelHeight
}
let panelFrame = CGRect(origin: CGPoint(x: 0.0, y: panelOrigin), size: CGSize(width: layout.size.width, height: panelHeight))
transition.updateFrame(node: self.bottomPanelNode, frame: panelFrame)
let (duration, curve) = listViewAnimationDurationAndCurve(transition: transition)
let updateSizeAndInsets = ListViewUpdateSizeAndInsets(size: historyFrame.size, insets: insets, headerInsets: headerInsets, duration: duration, curve: curve)
self.historyNode.updateLayout(transition: transition, updateSizeAndInsets: updateSizeAndInsets)
if let searchDisplayController = self.searchDisplayController {
searchDisplayController.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: transition)
}
}
}
private let context: AccountContext
private var presentationData: PresentationData
private var presentationDataDisposable: Disposable?
private let url: String
private let openUrl: (String) -> Void
private let addBookmark: () -> Void
private var controllerNode: Node {
return self.displayNode as! Node
}
private var searchContentNode: NavigationBarSearchContentNode?
private var validLayout: ContainerViewLayout?
private var node: Node {
return self.displayNode as! Node
}
public init(context: AccountContext, url: String, openUrl: @escaping (String) -> Void, addBookmark: @escaping () -> Void) {
self.context = context
self.presentationData = context.sharedContext.currentPresentationData.with { $0 }
self.url = url
self.openUrl = openUrl
self.addBookmark = addBookmark
super.init(navigationBarPresentationData: NavigationBarPresentationData(presentationData: self.presentationData))
self.navigationPresentation = .modal
self.supportedOrientations = ViewControllerSupportedOrientations(regularSize: .all, compactSize: .portrait)
self.statusBar.statusBarStyle = self.presentationData.theme.rootController.statusBarStyle.style
self.navigationItem.backBarButtonItem = UIBarButtonItem(title: self.presentationData.strings.Common_Back, style: .plain, target: nil, action: nil)
self.navigationItem.leftBarButtonItem = UIBarButtonItem(title: self.presentationData.strings.Common_Close, style: .plain, target: self, action: #selector(self.cancelPressed))
self.title = self.presentationData.strings.WebBrowser_Bookmarks_Title
self.searchContentNode = NavigationBarSearchContentNode(theme: self.presentationData.theme, placeholder: self.presentationData.strings.Common_Search, activate: { [weak self] in
self?.activateSearch()
})
self.navigationBar?.setContentNode(self.searchContentNode, animated: false)
self.scrollToTop = { [weak self] in
if let self {
if let searchContentNode = self.searchContentNode {
searchContentNode.updateExpansionProgress(1.0, animated: true)
}
self.node.scrollToTop()
}
}
self.presentationDataDisposable = (context.sharedContext.presentationData
|> deliverOnMainQueue).start(next: { [weak self] presentationData in
if let strongSelf = self {
let previousTheme = strongSelf.presentationData.theme
let previousStrings = strongSelf.presentationData.strings
strongSelf.presentationData = presentationData
if previousTheme !== presentationData.theme || previousStrings !== presentationData.strings {
strongSelf.updateThemeAndStrings()
}
}
}).strict()
}
required init(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
self.presentationDataDisposable?.dispose()
}
override public func loadDisplayNode() {
self.displayNode = Node(context: self.context, controller: self, presentationData: self.presentationData)
self.node.historyNode.contentPositionChanged = { [weak self] offset in
if let strongSelf = self, let searchContentNode = strongSelf.searchContentNode {
searchContentNode.updateListVisibleContentOffset(offset)
}
}
//
// self.node.historyNode.didEndScrolling = { [weak self] _ in
// if let strongSelf = self, let searchContentNode = strongSelf.searchContentNode {
// let _ = fixNavigationSearchableListNodeScrolling(strongSelf.node.historyNode, searchNode: searchContentNode)
// }
// }
self.displayNodeDidLoad()
}
private func updateThemeAndStrings() {
self.statusBar.statusBarStyle = self.presentationData.theme.rootController.statusBarStyle.style
self.navigationBar?.updatePresentationData(NavigationBarPresentationData(presentationData: self.presentationData))
self.searchContentNode?.updateThemeAndPlaceholder(theme: self.presentationData.theme, placeholder: self.presentationData.strings.Common_Search)
}
fileprivate func activateSearch() {
if self.displayNavigationBar {
if let scrollToTop = self.scrollToTop {
scrollToTop()
}
if let searchContentNode = self.searchContentNode {
self.node.activateSearch(placeholderNode: searchContentNode.placeholderNode)
}
self.setDisplayNavigationBar(false, transition: .animated(duration: 0.5, curve: .spring))
}
}
fileprivate func deactivateSearch() {
if !self.displayNavigationBar {
self.setDisplayNavigationBar(true, transition: .animated(duration: 0.5, curve: .spring))
if let searchContentNode = self.searchContentNode {
self.node.deactivateSearch(placeholderNode: searchContentNode.placeholderNode)
}
}
}
override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) {
super.containerLayoutUpdated(layout, transition: transition)
self.validLayout = layout
self.controllerNode.containerLayoutUpdated(layout: layout, navigationBarHeight: self.cleanNavigationHeight, actualNavigationBarHeight: self.navigationLayout(layout: layout).navigationFrame.maxY, transition: transition)
}
@objc private func cancelPressed() {
self.dismiss()
}
}
private class BottomPanelNode: ASDisplayNode {
private let theme: PresentationTheme
private let strings: PresentationStrings
private let action: () -> Void
private let separatorNode: ASDisplayNode
private let button: HighlightTrackingButtonNode
private let iconNode: ASImageNode
private let textNode: ImmediateTextNode
private var validLayout: (CGFloat, CGFloat, CGFloat)?
init(theme: PresentationTheme, strings: PresentationStrings, action: @escaping () -> Void) {
self.theme = theme
self.strings = strings
self.action = action
self.separatorNode = ASDisplayNode()
self.separatorNode.backgroundColor = theme.rootController.navigationBar.separatorColor
self.iconNode = ASImageNode()
self.iconNode.displaysAsynchronously = false
self.iconNode.image = generateTintedImage(image: UIImage(bundleImageName: "Chat List/AddIcon"), color: theme.rootController.navigationBar.accentTextColor)
self.iconNode.isUserInteractionEnabled = false
self.textNode = ImmediateTextNode()
self.textNode.displaysAsynchronously = false
self.textNode.attributedText = NSAttributedString(string: strings.WebBrowser_Bookmarks_BookmarkCurrent, font: Font.regular(17.0), textColor: theme.rootController.navigationBar.accentTextColor)
self.textNode.isUserInteractionEnabled = false
self.button = HighlightTrackingButtonNode()
super.init()
self.backgroundColor = theme.rootController.navigationBar.opaqueBackgroundColor
self.addSubnode(self.button)
self.addSubnode(self.separatorNode)
self.addSubnode(self.iconNode)
self.addSubnode(self.textNode)
self.addSubnode(self.button)
self.button.highligthedChanged = { [weak self] highlighted in
if let self {
if highlighted {
self.iconNode.layer.removeAnimation(forKey: "opacity")
self.iconNode.alpha = 0.4
self.textNode.layer.removeAnimation(forKey: "opacity")
self.textNode.alpha = 0.4
} else {
self.iconNode.alpha = 1.0
self.iconNode.layer.animateAlpha(from: 0.4, to: 1.0, duration: 0.2)
self.textNode.alpha = 1.0
self.textNode.layer.animateAlpha(from: 0.4, to: 1.0, duration: 0.2)
}
}
}
self.button.addTarget(self, action: #selector(self.buttonPressed), forControlEvents: .touchUpInside)
}
@objc private func buttonPressed() {
self.action()
}
func updateLayout(width: CGFloat, sideInset: CGFloat, bottomInset: CGFloat, transition: ContainedViewLayoutTransition) -> CGFloat {
self.validLayout = (width, sideInset, bottomInset)
let topInset: CGFloat = 8.0
var bottomInset = bottomInset
bottomInset += topInset - (bottomInset.isZero ? 0.0 : 4.0)
let buttonHeight: CGFloat = 40.0
let textSize = self.textNode.updateLayout(CGSize(width: width, height: 44.0))
let spacing: CGFloat = 8.0
var contentWidth = textSize.width
var contentOriginX = floorToScreenPixels((width - contentWidth) / 2.0)
if let icon = self.iconNode.image {
contentWidth += icon.size.width + spacing
contentOriginX = floorToScreenPixels((width - contentWidth) / 2.0)
transition.updateFrame(node: self.iconNode, frame: CGRect(origin: CGPoint(x: contentOriginX, y: 12.0 + UIScreenPixel), size: icon.size))
contentOriginX += icon.size.width + spacing
}
let textFrame = CGRect(origin: CGPoint(x: contentOriginX, y: 17.0), size: textSize)
transition.updateFrame(node: self.textNode, frame: textFrame)
transition.updateFrame(node: self.button, frame: textFrame.insetBy(dx: -10.0, dy: -10.0))
transition.updateFrame(node: self.separatorNode, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: width, height: UIScreenPixel)))
return topInset + buttonHeight + bottomInset
}
}
final class BrowserBookmarksContextExtractedContentSource: ContextExtractedContentSource {
let keepInPlace: Bool = false
let ignoreContentTouches: Bool = false
let blurBackground: Bool = true
private let contentNode: ContextExtractedContentContainingNode
init(contentNode: ContextExtractedContentContainingNode) {
self.contentNode = contentNode
}
func takeView() -> ContextControllerTakeViewInfo? {
return ContextControllerTakeViewInfo(containingItem: .node(self.contentNode), contentAreaInScreenSpace: UIScreen.main.bounds)
}
func putBack() -> ContextControllerPutBackViewInfo? {
return ContextControllerPutBackViewInfo(contentAreaInScreenSpace: UIScreen.main.bounds)
}
}
@@ -0,0 +1,238 @@
import Foundation
import UIKit
import Display
import ComponentFlow
import SwiftSignalKit
import WebKit
import TelegramPresentationData
final class BrowserContentState: Equatable {
enum ContentType: Equatable {
case webPage
case instantPage
case document
}
struct HistoryItem: Equatable {
let url: String
let title: String
let uuid: UUID?
let webItem: WKBackForwardListItem?
init(url: String, title: String, uuid: UUID) {
self.url = url
self.title = title
self.uuid = uuid
self.webItem = nil
}
init(webItem: WKBackForwardListItem) {
self.url = webItem.url.absoluteString
self.title = webItem.title ?? ""
self.uuid = nil
self.webItem = webItem
}
}
let title: String
let url: String
let estimatedProgress: Double
let readingProgress: Double
let contentType: ContentType
let favicon: UIImage?
let isSecure: Bool
let hasInstantView: Bool
let isInnerInstantViewEnabled: Bool
let canGoBack: Bool
let canGoForward: Bool
let backList: [HistoryItem]
let forwardList: [HistoryItem]
init(
title: String,
url: String,
estimatedProgress: Double,
readingProgress: Double,
contentType: ContentType,
favicon: UIImage? = nil,
isSecure: Bool = false,
hasInstantView: Bool = false,
isInnerInstantViewEnabled: Bool = false,
canGoBack: Bool = false,
canGoForward: Bool = false,
backList: [HistoryItem] = [],
forwardList: [HistoryItem] = []
) {
self.title = title
self.url = url
self.estimatedProgress = estimatedProgress
self.readingProgress = readingProgress
self.contentType = contentType
self.favicon = favicon
self.isSecure = isSecure
self.hasInstantView = hasInstantView
self.isInnerInstantViewEnabled = isInnerInstantViewEnabled
self.canGoBack = canGoBack
self.canGoForward = canGoForward
self.backList = backList
self.forwardList = forwardList
}
static func == (lhs: BrowserContentState, rhs: BrowserContentState) -> Bool {
if lhs.title != rhs.title {
return false
}
if lhs.url != rhs.url {
return false
}
if lhs.estimatedProgress != rhs.estimatedProgress {
return false
}
if lhs.readingProgress != rhs.readingProgress {
return false
}
if lhs.contentType != rhs.contentType {
return false
}
if (lhs.favicon == nil) != (rhs.favicon == nil) {
return false
}
if lhs.isSecure != rhs.isSecure {
return false
}
if lhs.hasInstantView != rhs.hasInstantView {
return false
}
if lhs.canGoBack != rhs.canGoBack {
return false
}
if lhs.canGoForward != rhs.canGoForward {
return false
}
if lhs.backList != rhs.backList {
return false
}
if lhs.forwardList != rhs.forwardList {
return false
}
return true
}
func withUpdatedTitle(_ title: String) -> BrowserContentState {
return BrowserContentState(title: title, url: self.url, estimatedProgress: self.estimatedProgress, readingProgress: self.readingProgress, contentType: self.contentType, favicon: self.favicon, isSecure: self.isSecure, hasInstantView: self.hasInstantView, isInnerInstantViewEnabled: self.isInnerInstantViewEnabled, canGoBack: self.canGoBack, canGoForward: self.canGoForward, backList: self.backList, forwardList: self.forwardList)
}
func withUpdatedUrl(_ url: String) -> BrowserContentState {
return BrowserContentState(title: self.title, url: url, estimatedProgress: self.estimatedProgress, readingProgress: self.readingProgress, contentType: self.contentType, favicon: self.favicon, isSecure: self.isSecure, hasInstantView: self.hasInstantView, isInnerInstantViewEnabled: self.isInnerInstantViewEnabled, canGoBack: self.canGoBack, canGoForward: self.canGoForward, backList: self.backList, forwardList: self.forwardList)
}
func withUpdatedIsSecure(_ isSecure: Bool) -> BrowserContentState {
return BrowserContentState(title: self.title, url: self.url, estimatedProgress: self.estimatedProgress, readingProgress: self.readingProgress, contentType: self.contentType, favicon: self.favicon, isSecure: isSecure, hasInstantView: self.hasInstantView, isInnerInstantViewEnabled: self.isInnerInstantViewEnabled, canGoBack: self.canGoBack, canGoForward: self.canGoForward, backList: self.backList, forwardList: self.forwardList)
}
func withUpdatedHasInstantView(_ hasInstantView: Bool) -> BrowserContentState {
return BrowserContentState(title: self.title, url: self.url, estimatedProgress: self.estimatedProgress, readingProgress: self.readingProgress, contentType: self.contentType, favicon: self.favicon, isSecure: self.isSecure, hasInstantView: hasInstantView, isInnerInstantViewEnabled: self.isInnerInstantViewEnabled, canGoBack: self.canGoBack, canGoForward: self.canGoForward, backList: self.backList, forwardList: self.forwardList)
}
func withUpdatedIsInnerInstantViewEnabled(_ isInnerInstantViewEnabled: Bool) -> BrowserContentState {
return BrowserContentState(title: self.title, url: self.url, estimatedProgress: self.estimatedProgress, readingProgress: self.readingProgress, contentType: self.contentType, favicon: self.favicon, isSecure: self.isSecure, hasInstantView: self.hasInstantView, isInnerInstantViewEnabled: isInnerInstantViewEnabled, canGoBack: self.canGoBack, canGoForward: self.canGoForward, backList: self.backList, forwardList: self.forwardList)
}
func withUpdatedEstimatedProgress(_ estimatedProgress: Double) -> BrowserContentState {
return BrowserContentState(title: self.title, url: self.url, estimatedProgress: estimatedProgress, readingProgress: self.readingProgress, contentType: self.contentType, favicon: self.favicon, isSecure: self.isSecure, hasInstantView: self.hasInstantView, isInnerInstantViewEnabled: self.isInnerInstantViewEnabled, canGoBack: self.canGoBack, canGoForward: self.canGoForward, backList: self.backList, forwardList: self.forwardList)
}
func withUpdatedReadingProgress(_ readingProgress: Double) -> BrowserContentState {
return BrowserContentState(title: self.title, url: self.url, estimatedProgress: self.estimatedProgress, readingProgress: readingProgress, contentType: self.contentType, favicon: self.favicon, isSecure: self.isSecure, hasInstantView: self.hasInstantView, isInnerInstantViewEnabled: self.isInnerInstantViewEnabled, canGoBack: self.canGoBack, canGoForward: self.canGoForward, backList: self.backList, forwardList: self.forwardList)
}
func withUpdatedFavicon(_ favicon: UIImage?) -> BrowserContentState {
return BrowserContentState(title: self.title, url: self.url, estimatedProgress: self.estimatedProgress, readingProgress: self.readingProgress, contentType: self.contentType, favicon: favicon, isSecure: self.isSecure, hasInstantView: self.hasInstantView, isInnerInstantViewEnabled: self.isInnerInstantViewEnabled, canGoBack: self.canGoBack, canGoForward: self.canGoForward, backList: self.backList, forwardList: self.forwardList)
}
func withUpdatedCanGoBack(_ canGoBack: Bool) -> BrowserContentState {
return BrowserContentState(title: self.title, url: self.url, estimatedProgress: self.estimatedProgress, readingProgress: self.readingProgress, contentType: self.contentType, favicon: self.favicon, isSecure: self.isSecure, hasInstantView: self.hasInstantView, isInnerInstantViewEnabled: self.isInnerInstantViewEnabled, canGoBack: canGoBack, canGoForward: self.canGoForward, backList: self.backList, forwardList: self.forwardList)
}
func withUpdatedCanGoForward(_ canGoForward: Bool) -> BrowserContentState {
return BrowserContentState(title: self.title, url: self.url, estimatedProgress: self.estimatedProgress, readingProgress: self.readingProgress, contentType: self.contentType, favicon: self.favicon, isSecure: self.isSecure, hasInstantView: self.hasInstantView, isInnerInstantViewEnabled: self.isInnerInstantViewEnabled, canGoBack: self.canGoBack, canGoForward: canGoForward, backList: self.backList, forwardList: self.forwardList)
}
func withUpdatedBackList(_ backList: [HistoryItem]) -> BrowserContentState {
return BrowserContentState(title: self.title, url: self.url, estimatedProgress: self.estimatedProgress, readingProgress: self.readingProgress, contentType: self.contentType, favicon: self.favicon, isSecure: self.isSecure, hasInstantView: self.hasInstantView, isInnerInstantViewEnabled: self.isInnerInstantViewEnabled, canGoBack: self.canGoBack, canGoForward: self.canGoForward, backList: backList, forwardList: self.forwardList)
}
func withUpdatedForwardList(_ forwardList: [HistoryItem]) -> BrowserContentState {
return BrowserContentState(title: self.title, url: self.url, estimatedProgress: self.estimatedProgress, readingProgress: self.readingProgress, contentType: self.contentType, favicon: self.favicon, isSecure: self.isSecure, hasInstantView: self.hasInstantView, isInnerInstantViewEnabled: self.isInnerInstantViewEnabled, canGoBack: self.canGoBack, canGoForward: self.canGoForward, backList: self.backList, forwardList: forwardList)
}
}
protocol BrowserContent: UIView {
var uuid: UUID { get }
var currentState: BrowserContentState { get }
var state: Signal<BrowserContentState, NoError> { get }
var pushContent: (BrowserScreen.Subject, BrowserContent?) -> Void { get set }
var present: (ViewController, Any?) -> Void { get set }
var presentInGlobalOverlay: (ViewController) -> Void { get set }
var getNavigationController: () -> NavigationController? { get set }
var openAppUrl: (String) -> Void { get set }
var minimize: () -> Void { get set }
var close: () -> Void { get set }
var onScrollingUpdate: (ContentScrollingUpdate) -> Void { get set }
func resetScrolling()
func reload()
func stop()
func navigateBack()
func navigateForward()
func navigateTo(historyItem: BrowserContentState.HistoryItem)
func toggleInstantView(_ enabled: Bool)
func updatePresentationData(_ presentationData: PresentationData)
func updateFontState(_ state: BrowserPresentationState.FontState)
func setSearch(_ query: String?, completion: ((Int) -> Void)?)
func scrollToPreviousSearchResult(completion: ((Int, Int) -> Void)?)
func scrollToNextSearchResult(completion: ((Int, Int) -> Void)?)
func scrollToTop()
func addToRecentlyVisited()
func updateLayout(size: CGSize, insets: UIEdgeInsets, fullInsets: UIEdgeInsets, safeInsets: UIEdgeInsets, transition: ComponentTransition)
func makeContentSnapshotView() -> UIView?
}
struct ContentScrollingUpdate {
public var relativeOffset: CGFloat
public var absoluteOffsetToTopEdge: CGFloat?
public var absoluteOffsetToBottomEdge: CGFloat?
public var isReset: Bool
public var isInteracting: Bool
public var transition: ComponentTransition
public init(
relativeOffset: CGFloat,
absoluteOffsetToTopEdge: CGFloat?,
absoluteOffsetToBottomEdge: CGFloat?,
isReset: Bool,
isInteracting: Bool,
transition: ComponentTransition
) {
self.relativeOffset = relativeOffset
self.absoluteOffsetToTopEdge = absoluteOffsetToTopEdge
self.absoluteOffsetToBottomEdge = absoluteOffsetToBottomEdge
self.isReset = isReset
self.isInteracting = isInteracting
self.transition = transition
}
}
@@ -0,0 +1,445 @@
import Foundation
import UIKit
import Display
import ComponentFlow
import TelegramCore
import Postbox
import SwiftSignalKit
import TelegramPresentationData
import TelegramUIPreferences
import PresentationDataUtils
import AccountContext
@preconcurrency import WebKit
import AppBundle
import PromptUI
import SafariServices
import ShareController
import UndoUI
import UrlEscaping
final class BrowserDocumentContent: UIView, BrowserContent, WKNavigationDelegate, WKUIDelegate, UIScrollViewDelegate {
private let context: AccountContext
private var presentationData: PresentationData
let file: FileMediaReference
private let webView: WKWebView
let uuid: UUID
private var _state: BrowserContentState
private let statePromise: Promise<BrowserContentState>
var currentState: BrowserContentState {
return self._state
}
var state: Signal<BrowserContentState, NoError> {
return self.statePromise.get()
}
var pushContent: (BrowserScreen.Subject, BrowserContent?) -> Void = { _, _ in }
var openAppUrl: (String) -> Void = { _ in }
var onScrollingUpdate: (ContentScrollingUpdate) -> Void = { _ in }
var minimize: () -> Void = { }
var close: () -> Void = { }
var present: (ViewController, Any?) -> Void = { _, _ in }
var presentInGlobalOverlay: (ViewController) -> Void = { _ in }
var getNavigationController: () -> NavigationController? = { return nil }
private var tempFile: TempBoxFile?
init(context: AccountContext, presentationData: PresentationData, file: FileMediaReference) {
self.context = context
self.uuid = UUID()
self.presentationData = presentationData
self.file = file
let configuration = WKWebViewConfiguration()
self.webView = WKWebView(frame: CGRect(), configuration: configuration)
self.webView.allowsLinkPreview = true
if #available(iOS 11.0, *) {
self.webView.scrollView.contentInsetAdjustmentBehavior = .never
}
var title: String = "file"
var url = ""
if let path = self.context.account.postbox.mediaBox.completedResourcePath(file.media.resource) {
var updatedPath = path
if let fileName = file.media.fileName {
let tempFile = TempBox.shared.file(path: path, fileName: fileName)
updatedPath = tempFile.path
self.tempFile = tempFile
title = fileName
url = updatedPath
}
let updatedUrl = URL(fileURLWithPath: updatedPath)
let request = URLRequest(url: updatedUrl)
if updatedPath.lowercased().hasSuffix(".txt"), let data = try? Data(contentsOf: updatedUrl) {
self.webView.load(data, mimeType: "text/plain", characterEncodingName: "UTF-8", baseURL: URL(string: "http://localhost")!)
} else {
self.webView.load(request)
}
}
self._state = BrowserContentState(title: title, url: url, estimatedProgress: 0.0, readingProgress: 0.0, contentType: .document)
self.statePromise = Promise<BrowserContentState>(self._state)
super.init(frame: .zero)
self.webView.allowsBackForwardNavigationGestures = true
self.webView.scrollView.delegate = self
self.webView.scrollView.clipsToBounds = false
self.webView.navigationDelegate = self
self.webView.uiDelegate = self
if #available(iOS 15.0, *) {
self.backgroundColor = presentationData.theme.list.plainBackgroundColor
self.webView.underPageBackgroundColor = presentationData.theme.list.plainBackgroundColor
}
self.addSubview(self.webView)
self.webView.interactiveTransitionGestureRecognizerTest = { [weak self] point in
if let self {
if let result = self.webView.hitTest(point, with: nil), let scrollView = findScrollView(view: result), scrollView.isDescendant(of: self.webView) {
if scrollView.contentSize.width > scrollView.frame.width, scrollView.contentOffset.x > -scrollView.contentInset.left {
return true
}
}
}
return false
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func updatePresentationData(_ presentationData: PresentationData) {
self.presentationData = presentationData
if #available(iOS 15.0, *) {
self.backgroundColor = presentationData.theme.list.plainBackgroundColor
self.webView.underPageBackgroundColor = presentationData.theme.list.plainBackgroundColor
}
if let (size, insets, fullInsets) = self.validLayout {
self.updateLayout(size: size, insets: insets, fullInsets: fullInsets, safeInsets: .zero, transition: .immediate)
}
}
var currentFontState = BrowserPresentationState.FontState(size: 100, isSerif: false)
func updateFontState(_ state: BrowserPresentationState.FontState) {
self.updateFontState(state, force: false)
}
func updateFontState(_ state: BrowserPresentationState.FontState, force: Bool) {
self.currentFontState = state
let fontFamily = state.isSerif ? "'Georgia, serif'" : "null"
let textSizeAdjust = state.size != 100 ? "'\(state.size)%'" : "null"
let js = "\(setupFontFunctions) setTelegramFontOverrides(\(fontFamily), \(textSizeAdjust))";
self.webView.evaluateJavaScript(js) { _, _ in }
}
func toggleInstantView(_ enabled: Bool) {
}
private var didSetupSearch = false
private func setupSearch(completion: @escaping () -> Void) {
guard !self.didSetupSearch else {
completion()
return
}
let bundle = getAppBundle()
guard let scriptPath = bundle.path(forResource: "UIWebViewSearch", ofType: "js") else {
return
}
guard let scriptData = try? Data(contentsOf: URL(fileURLWithPath: scriptPath)) else {
return
}
guard let script = String(data: scriptData, encoding: .utf8) else {
return
}
self.didSetupSearch = true
self.webView.evaluateJavaScript(script, completionHandler: { _, error in
if error != nil {
print()
}
completion()
})
}
private var previousQuery: String?
func setSearch(_ query: String?, completion: ((Int) -> Void)?) {
guard self.previousQuery != query else {
return
}
self.previousQuery = query
self.setupSearch { [weak self] in
if let query = query {
let js = "uiWebview_HighlightAllOccurencesOfString('\(query)')"
self?.webView.evaluateJavaScript(js, completionHandler: { [weak self] _, _ in
let js = "uiWebview_SearchResultCount"
self?.webView.evaluateJavaScript(js, completionHandler: { [weak self] result, _ in
if let result = result as? NSNumber {
self?.searchResultsCount = result.intValue
completion?(result.intValue)
} else {
completion?(0)
}
})
})
} else {
let js = "uiWebview_RemoveAllHighlights()"
self?.webView.evaluateJavaScript(js, completionHandler: nil)
self?.currentSearchResult = 0
self?.searchResultsCount = 0
}
}
}
private var currentSearchResult: Int = 0
private var searchResultsCount: Int = 0
func scrollToPreviousSearchResult(completion: ((Int, Int) -> Void)?) {
let searchResultsCount = self.searchResultsCount
var index = self.currentSearchResult - 1
if index < 0 {
index = searchResultsCount - 1
}
self.currentSearchResult = index
let js = "uiWebview_ScrollTo('\(searchResultsCount - index - 1)')"
self.webView.evaluateJavaScript(js, completionHandler: { _, _ in
completion?(index, searchResultsCount)
})
}
func scrollToNextSearchResult(completion: ((Int, Int) -> Void)?) {
let searchResultsCount = self.searchResultsCount
var index = self.currentSearchResult + 1
if index >= searchResultsCount {
index = 0
}
self.currentSearchResult = index
let js = "uiWebview_ScrollTo('\(searchResultsCount - index - 1)')"
self.webView.evaluateJavaScript(js, completionHandler: { _, _ in
completion?(index, searchResultsCount)
})
}
func stop() {
self.webView.stopLoading()
}
func reload() {
self.webView.reload()
}
func navigateBack() {
self.webView.goBack()
}
func navigateForward() {
self.webView.goForward()
}
func navigateTo(historyItem: BrowserContentState.HistoryItem) {
if let webItem = historyItem.webItem {
self.webView.go(to: webItem)
}
}
func navigateTo(address: String) {
let finalUrl = explicitUrl(address)
guard let url = URL(string: finalUrl) else {
return
}
self.webView.load(URLRequest(url: url))
}
func scrollToTop() {
self.webView.scrollView.setContentOffset(CGPoint(x: 0.0, y: -self.webView.scrollView.contentInset.top), animated: true)
}
private var validLayout: (CGSize, UIEdgeInsets, UIEdgeInsets)?
func updateLayout(size: CGSize, insets: UIEdgeInsets, fullInsets: UIEdgeInsets, safeInsets: UIEdgeInsets, transition: ComponentTransition) {
self.validLayout = (size, insets, fullInsets)
self.previousScrollingOffset = ScrollingOffsetState(value: self.webView.scrollView.contentOffset.y, isDraggingOrDecelerating: self.webView.scrollView.isDragging || self.webView.scrollView.isDecelerating)
let webViewFrame = CGRect(origin: CGPoint(x: insets.left, y: insets.top), size: CGSize(width: size.width - insets.left - insets.right, height: size.height - insets.top - insets.bottom))
var refresh = false
if self.webView.frame.width > 0 && webViewFrame.width != self.webView.frame.width {
refresh = true
}
transition.setFrame(view: self.webView, frame: webViewFrame)
if refresh {
self.webView.reloadInputViews()
}
self.webView.scrollView.verticalScrollIndicatorInsets = UIEdgeInsets(top: 0.0, left: -insets.left, bottom: 0.0, right: -insets.right)
self.webView.scrollView.horizontalScrollIndicatorInsets = UIEdgeInsets(top: 0.0, left: -insets.left, bottom: 0.0, right: -insets.right)
}
private func updateState(_ f: (BrowserContentState) -> BrowserContentState) {
let updated = f(self._state)
self._state = updated
self.statePromise.set(.single(self._state))
}
override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
if keyPath == "title" {
self.updateState { $0.withUpdatedTitle(self.webView.title ?? "") }
} else if keyPath == "URL" {
self.updateState { $0.withUpdatedUrl(self.webView.url?.absoluteString ?? "") }
self.didSetupSearch = false
} else if keyPath == "estimatedProgress" {
self.updateState { $0.withUpdatedEstimatedProgress(self.webView.estimatedProgress) }
} else if keyPath == "canGoBack" {
self.updateState { $0.withUpdatedCanGoBack(self.webView.canGoBack) }
self.webView.disablesInteractiveTransitionGestureRecognizer = self.webView.canGoBack
} else if keyPath == "canGoForward" {
self.updateState { $0.withUpdatedCanGoForward(self.webView.canGoForward) }
}
}
private struct ScrollingOffsetState: Equatable {
var value: CGFloat
var isDraggingOrDecelerating: Bool
}
private var previousScrollingOffset: ScrollingOffsetState?
func scrollViewDidScroll(_ scrollView: UIScrollView) {
self.updateScrollingOffset(isReset: false, transition: .immediate)
}
private func snapScrollingOffsetToInsets() {
let transition = ComponentTransition(animation: .curve(duration: 0.4, curve: .spring))
self.updateScrollingOffset(isReset: false, transition: transition)
}
public func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
if !decelerate {
self.snapScrollingOffsetToInsets()
}
}
public func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
self.snapScrollingOffsetToInsets()
}
private func updateScrollingOffset(isReset: Bool, transition: ComponentTransition) {
let scrollView = self.webView.scrollView
let isInteracting = scrollView.isDragging || scrollView.isDecelerating
if let previousScrollingOffsetValue = self.previousScrollingOffset {
let currentBounds = scrollView.bounds
let offsetToTopEdge = max(0.0, currentBounds.minY - 0.0)
let offsetToBottomEdge = max(0.0, scrollView.contentSize.height - currentBounds.maxY)
let relativeOffset = scrollView.contentOffset.y - previousScrollingOffsetValue.value
self.onScrollingUpdate(ContentScrollingUpdate(
relativeOffset: relativeOffset,
absoluteOffsetToTopEdge: offsetToTopEdge,
absoluteOffsetToBottomEdge: offsetToBottomEdge,
isReset: isReset,
isInteracting: isInteracting,
transition: transition
))
}
self.previousScrollingOffset = ScrollingOffsetState(value: scrollView.contentOffset.y, isDraggingOrDecelerating: isInteracting)
var readingProgress: CGFloat = 0.0
if !scrollView.contentSize.height.isZero {
let value = (scrollView.contentOffset.y + scrollView.contentInset.top) / (scrollView.contentSize.height - scrollView.bounds.size.height + scrollView.contentInset.top)
readingProgress = max(0.0, min(1.0, value))
}
self.updateState {
$0.withUpdatedReadingProgress(readingProgress)
}
}
func resetScrolling() {
self.updateScrollingOffset(isReset: true, transition: .spring(duration: 0.4))
}
func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) {
self.updateFontState(self.currentFontState, force: true)
}
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
self.updateState {
$0
.withUpdatedBackList(webView.backForwardList.backList.map { BrowserContentState.HistoryItem(webItem: $0) })
.withUpdatedForwardList(webView.backForwardList.forwardList.map { BrowserContentState.HistoryItem(webItem: $0) })
}
}
func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? {
if navigationAction.targetFrame == nil {
if let url = navigationAction.request.url?.absoluteString {
self.open(url: url, new: true)
}
}
return nil
}
func webViewDidClose(_ webView: WKWebView) {
self.close()
}
@available(iOSApplicationExtension 15.0, iOS 15.0, *)
func webView(_ webView: WKWebView, requestMediaCapturePermissionFor origin: WKSecurityOrigin, initiatedByFrame frame: WKFrameInfo, type: WKMediaCaptureType, decisionHandler: @escaping (WKPermissionDecision) -> Void) {
decisionHandler(.prompt)
}
private func open(url: String, new: Bool) {
let subject: BrowserScreen.Subject = .webPage(url: url)
if new, let navigationController = self.getNavigationController() {
navigationController._keepModalDismissProgress = true
self.minimize()
let controller = BrowserScreen(context: self.context, subject: subject)
navigationController._keepModalDismissProgress = true
navigationController.pushViewController(controller)
} else {
self.pushContent(subject, nil)
}
}
private func share(url: String) {
let presentationData = self.context.sharedContext.currentPresentationData.with { $0 }
let shareController = ShareController(context: self.context, subject: .url(url))
shareController.actionCompleted = { [weak self] in
self?.present(UndoOverlayController(presentationData: presentationData, content: .linkCopied(title: nil, text: presentationData.strings.Conversation_LinkCopied), elevatedLayout: false, animateInAsReplacement: false, action: { _ in return false }), nil)
}
self.present(shareController, nil)
}
func addToRecentlyVisited() {
}
func makeContentSnapshotView() -> UIView? {
let configuration = WKSnapshotConfiguration()
configuration.rect = CGRect(origin: .zero, size: self.webView.frame.size)
let imageView = UIImageView()
imageView.frame = CGRect(origin: .zero, size: self.webView.frame.size)
self.webView.takeSnapshot(with: configuration, completionHandler: { image, _ in
imageView.image = image
})
return imageView
}
}
private func findScrollView(view: UIView?) -> UIScrollView? {
if let view = view {
if let view = view as? UIScrollView {
return view
}
return findScrollView(view: view.superview)
} else {
return nil
}
}
@@ -0,0 +1,299 @@
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 BrowserExceptionDomainAlertContentNode: AlertContentNode {
private let strings: PresentationStrings
private let domain: String
private let titleNode: ASTextNode
private let textNode: ASTextNode
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, domain: String, requestWriteAccess: Bool, actions: [TextAlertAction]) {
self.strings = strings
self.domain = domain
self.titleNode = ASTextNode()
self.titleNode.maximumNumberOfLines = 0
self.textNode = ASTextNode()
self.textNode.maximumNumberOfLines = 0
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)
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)
}
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.titleNode.attributedText = NSAttributedString(string: "Open in Browser", font: Font.bold(17.0), textColor: theme.primaryColor, paragraphAlignment: .center)
self.textNode.attributedText = NSAttributedString(string: "Do you want to open this link in your default browser?", font: Font.regular(13.0), textColor: theme.primaryColor, paragraphAlignment: .center)
self.allowWriteLabelNode.attributedText = formattedText("Always open links from **\(self.domain)** in browser", 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)
let titleSize = self.titleNode.measure(size)
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 + 13.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: titleSize.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
}
textFrame.origin.x = floorToScreenPixels((resultSize.width - textFrame.width) / 2.0)
transition.updateFrame(node: self.textNode, frame: textFrame)
return resultSize
}
}
public func browserExceptionDomainAlertController(context: AccountContext, domain: String, 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: (() -> BrowserExceptionDomainAlertContentNode?)?
let actions: [TextAlertAction] = [TextAlertAction(type: .genericAction, title: presentationData.strings.Common_Cancel, action: {
dismissImpl?(true)
}), TextAlertAction(type: .defaultAction, title: "Continue", action: {
if let allowWriteAccess = getContentNodeImpl?()?.allowWriteAccess {
completion(allowWriteAccess)
} else {
completion(false)
}
dismissImpl?(true)
})]
let contentNode = BrowserExceptionDomainAlertContentNode(account: context.account, theme: AlertControllerTheme(presentationData: presentationData), ptheme: theme, strings: strings, domain: domain, requestWriteAccess: true, 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
}
@@ -0,0 +1,272 @@
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import TelegramPresentationData
import AppBundle
import ContextUI
final class BrowserFontSizeContextMenuItem: ContextMenuCustomItem {
private let value: Int32
private let decrease: () -> Int32
private let increase: () -> Int32
private let reset: () -> Void
init(value: Int32, decrease: @escaping () -> Int32, increase: @escaping () -> Int32, reset: @escaping () -> Void) {
self.value = value
self.decrease = decrease
self.increase = increase
self.reset = reset
}
func node(presentationData: PresentationData, getController: @escaping () -> ContextControllerProtocol?, actionSelected: @escaping (ContextMenuActionResult) -> Void) -> ContextMenuCustomNode {
return BrowserFontSizeContextMenuItemNode(presentationData: presentationData, getController: getController, value: self.value, decrease: self.decrease, increase: self.increase, reset: self.reset)
}
}
private let textFont = Font.regular(17.0)
private final class BrowserFontSizeContextMenuItemNode: ASDisplayNode, ContextMenuCustomNode {
private var presentationData: PresentationData
private let leftBackgroundNode: ASDisplayNode
private let leftHighlightedBackgroundNode: ASDisplayNode
private let leftIconNode: ASImageNode
private let leftButtonNode: HighlightTrackingButtonNode
private let rightBackgroundNode: ASDisplayNode
private let rightHighlightedBackgroundNode: ASDisplayNode
private let rightIconNode: ASImageNode
private let rightButtonNode: HighlightTrackingButtonNode
private let centerTextNode: ImmediateTextNode
private let centerHighlightedBackgroundNode: ASDisplayNode
private let centerButtonNode: HighlightTrackingButtonNode
private let leftSeparatorNode: ASDisplayNode
private let rightSeparatorNode: ASDisplayNode
var value: Int32 = 100 {
didSet {
self.updateValue()
}
}
private let decrease: () -> Int32
private let increase: () -> Int32
private let reset: () -> Void
init(presentationData: PresentationData, getController: @escaping () -> ContextControllerProtocol?, value: Int32, decrease: @escaping () -> Int32, increase: @escaping () -> Int32, reset: @escaping () -> Void) {
self.presentationData = presentationData
self.value = value
self.decrease = decrease
self.increase = increase
self.reset = reset
self.leftBackgroundNode = ASDisplayNode()
self.leftBackgroundNode.isAccessibilityElement = false
self.leftBackgroundNode.backgroundColor = presentationData.theme.contextMenu.itemBackgroundColor
self.leftHighlightedBackgroundNode = ASDisplayNode()
self.leftHighlightedBackgroundNode.isAccessibilityElement = false
self.leftHighlightedBackgroundNode.backgroundColor = presentationData.theme.contextMenu.itemHighlightedBackgroundColor
self.leftHighlightedBackgroundNode.alpha = 0.0
self.leftIconNode = ASImageNode()
self.leftIconNode.isAccessibilityElement = false
self.leftIconNode.displaysAsynchronously = false
self.leftIconNode.displayWithoutProcessing = true
self.leftIconNode.isUserInteractionEnabled = false
self.leftIconNode.image = generateTintedImage(image: UIImage(bundleImageName: "Instant View/Settings/DecreaseFont"), color: presentationData.theme.contextMenu.primaryColor)
self.leftButtonNode = HighlightTrackingButtonNode()
self.leftButtonNode.isAccessibilityElement = true
self.leftButtonNode.accessibilityLabel = presentationData.strings.InstantPage_VoiceOver_DecreaseFontSize
self.rightBackgroundNode = ASDisplayNode()
self.rightBackgroundNode.isAccessibilityElement = false
self.rightBackgroundNode.backgroundColor = presentationData.theme.contextMenu.itemBackgroundColor
self.rightHighlightedBackgroundNode = ASDisplayNode()
self.rightHighlightedBackgroundNode.isAccessibilityElement = false
self.rightHighlightedBackgroundNode.backgroundColor = presentationData.theme.contextMenu.itemHighlightedBackgroundColor
self.rightHighlightedBackgroundNode.alpha = 0.0
self.rightIconNode = ASImageNode()
self.rightIconNode.isAccessibilityElement = false
self.rightIconNode.displaysAsynchronously = false
self.rightIconNode.displayWithoutProcessing = true
self.rightIconNode.isUserInteractionEnabled = false
self.rightIconNode.image = generateTintedImage(image: UIImage(bundleImageName: "Instant View/Settings/IncreaseFont"), color: presentationData.theme.contextMenu.primaryColor)
self.rightButtonNode = HighlightTrackingButtonNode()
self.rightButtonNode.isAccessibilityElement = true
self.rightButtonNode.accessibilityLabel = presentationData.strings.InstantPage_VoiceOver_IncreaseFontSize
self.centerTextNode = ImmediateTextNode()
self.centerTextNode.isAccessibilityElement = false
self.centerTextNode.isUserInteractionEnabled = false
self.centerTextNode.displaysAsynchronously = false
self.centerTextNode.textAlignment = .center
self.centerHighlightedBackgroundNode = ASDisplayNode()
self.centerHighlightedBackgroundNode.isAccessibilityElement = false
self.centerHighlightedBackgroundNode.backgroundColor = presentationData.theme.contextMenu.itemHighlightedBackgroundColor
self.centerHighlightedBackgroundNode.alpha = 0.0
self.centerButtonNode = HighlightTrackingButtonNode()
self.centerButtonNode.isAccessibilityElement = true
self.centerButtonNode.accessibilityLabel = presentationData.strings.InstantPage_VoiceOver_ResetFontSize
self.leftSeparatorNode = ASDisplayNode()
self.leftSeparatorNode.backgroundColor = presentationData.theme.contextMenu.itemSeparatorColor
self.rightSeparatorNode = ASDisplayNode()
self.rightSeparatorNode.backgroundColor = presentationData.theme.contextMenu.itemSeparatorColor
super.init()
self.isUserInteractionEnabled = true
self.addSubnode(self.leftBackgroundNode)
self.addSubnode(self.leftHighlightedBackgroundNode)
self.addSubnode(self.leftIconNode)
self.addSubnode(self.leftButtonNode)
self.addSubnode(self.rightBackgroundNode)
self.addSubnode(self.rightHighlightedBackgroundNode)
self.addSubnode(self.rightIconNode)
self.addSubnode(self.rightButtonNode)
self.addSubnode(self.centerHighlightedBackgroundNode)
self.addSubnode(self.centerTextNode)
self.addSubnode(self.centerButtonNode)
self.addSubnode(self.leftSeparatorNode)
self.addSubnode(self.rightSeparatorNode)
self.leftButtonNode.highligthedChanged = { [weak self] highligted in
guard let strongSelf = self else {
return
}
if highligted {
strongSelf.leftHighlightedBackgroundNode.alpha = 1.0
} else {
strongSelf.leftHighlightedBackgroundNode.alpha = 0.0
strongSelf.leftHighlightedBackgroundNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3)
}
}
self.leftButtonNode.addTarget(self, action: #selector(self.leftPressed), forControlEvents: .touchUpInside)
self.rightButtonNode.highligthedChanged = { [weak self] highligted in
guard let strongSelf = self else {
return
}
if highligted {
strongSelf.rightHighlightedBackgroundNode.alpha = 1.0
} else {
strongSelf.rightHighlightedBackgroundNode.alpha = 0.0
strongSelf.rightHighlightedBackgroundNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3)
}
}
self.rightButtonNode.addTarget(self, action: #selector(self.rightPressed), forControlEvents: .touchUpInside)
self.centerButtonNode.highligthedChanged = { [weak self] highligted in
guard let strongSelf = self else {
return
}
if highligted {
strongSelf.centerHighlightedBackgroundNode.alpha = 1.0
} else {
strongSelf.centerHighlightedBackgroundNode.alpha = 0.0
strongSelf.centerHighlightedBackgroundNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3)
}
}
self.centerButtonNode.addTarget(self, action: #selector(self.centerPressed), forControlEvents: .touchUpInside)
self.updateValue()
}
func updateTheme(presentationData: PresentationData) {
self.presentationData = presentationData
self.leftBackgroundNode.backgroundColor = self.presentationData.theme.contextMenu.itemBackgroundColor
self.leftHighlightedBackgroundNode.backgroundColor = self.presentationData.theme.contextMenu.itemHighlightedBackgroundColor
self.leftIconNode.image = generateTintedImage(image: UIImage(bundleImageName: "Instant View/Settings/DecreaseFont"), color: self.presentationData.theme.contextMenu.primaryColor)
self.rightBackgroundNode.backgroundColor = self.presentationData.theme.contextMenu.itemBackgroundColor
self.rightHighlightedBackgroundNode.backgroundColor = self.presentationData.theme.contextMenu.itemHighlightedBackgroundColor
self.rightIconNode.image = generateTintedImage(image: UIImage(bundleImageName: "Instant View/Settings/IncreaseFont"), color: self.presentationData.theme.contextMenu.primaryColor)
self.updateValue()
self.leftSeparatorNode.backgroundColor = self.presentationData.theme.contextMenu.itemSeparatorColor
self.rightSeparatorNode.backgroundColor = self.presentationData.theme.contextMenu.itemSeparatorColor
}
private func updateValue() {
self.centerTextNode.attributedText = NSAttributedString(string: "\(self.value)%", font: textFont, textColor: self.presentationData.theme.contextMenu.primaryColor)
let _ = self.centerTextNode.updateLayout(CGSize(width: 70.0, height: .greatestFiniteMagnitude))
self.leftButtonNode.isEnabled = self.value > 50
self.leftIconNode.alpha = self.leftButtonNode.isEnabled ? 1.0 : 0.3
self.rightButtonNode.isEnabled = self.value < 150
self.rightIconNode.alpha = self.rightButtonNode.isEnabled ? 1.0 : 0.3
self.centerButtonNode.isEnabled = self.value != 100
self.centerTextNode.alpha = self.centerButtonNode.isEnabled ? 1.0 : 0.4
}
func updateLayout(constrainedWidth: CGFloat, constrainedHeight: CGFloat) -> (CGSize, (CGSize, ContainedViewLayoutTransition) -> Void) {
let buttonWidth: CGFloat = 90.0
let valueWidth: CGFloat = 70.0
let height: CGFloat = 45.0
var textSize = self.centerTextNode.updateLayout(CGSize(width: valueWidth, height: .greatestFiniteMagnitude))
textSize.width = valueWidth
return (CGSize(width: buttonWidth * 2.0 + valueWidth, height: height), { size, transition in
let verticalOrigin = floor((size.height - textSize.height) / 2.0)
transition.updateFrameAdditive(node: self.centerTextNode, frame: CGRect(origin: CGPoint(x: floor((size.width - textSize.width) / 2.0), y: verticalOrigin), size: textSize))
transition.updateFrame(node: self.centerHighlightedBackgroundNode, frame: CGRect(origin: CGPoint(x: buttonWidth, y: 0.0), size: CGSize(width: valueWidth, height: size.height)))
transition.updateFrame(node: self.centerButtonNode, frame: CGRect(origin: CGPoint(x: buttonWidth, y: 0.0), size: CGSize(width: valueWidth, height: size.height)))
let leftIconSize = self.leftIconNode.image!.size
transition.updateFrameAdditive(node: self.leftIconNode, frame: CGRect(origin: CGPoint(x: floor((buttonWidth - leftIconSize.width) / 2.0), y: floor((size.height - leftIconSize.height) / 2.0)), size: leftIconSize))
let rightIconSize = self.leftIconNode.image!.size
transition.updateFrameAdditive(node: self.rightIconNode, frame: CGRect(origin: CGPoint(x: size.width - buttonWidth + floor((buttonWidth - rightIconSize.width) / 2.0), y: floor((size.height - rightIconSize.height) / 2.0)), size: rightIconSize))
transition.updateFrame(node: self.leftBackgroundNode, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: buttonWidth, height: size.height)))
transition.updateFrame(node: self.leftHighlightedBackgroundNode, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: buttonWidth, height: size.height)))
transition.updateFrame(node: self.leftButtonNode, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: buttonWidth, height: size.height)))
transition.updateFrame(node: self.rightBackgroundNode, frame: CGRect(origin: CGPoint(x: size.width - buttonWidth, y: 0.0), size: CGSize(width: buttonWidth, height: size.height)))
transition.updateFrame(node: self.rightHighlightedBackgroundNode, frame: CGRect(origin: CGPoint(x: size.width - buttonWidth, y: 0.0), size: CGSize(width: buttonWidth, height: size.height)))
transition.updateFrame(node: self.rightButtonNode, frame: CGRect(origin: CGPoint(x: size.width - buttonWidth, y: 0.0), size: CGSize(width: buttonWidth, height: size.height)))
transition.updateFrame(node: self.leftSeparatorNode, frame: CGRect(origin: CGPoint(x: buttonWidth, y: 0.0), size: CGSize(width: UIScreenPixel, height: size.height)))
transition.updateFrame(node: self.rightSeparatorNode, frame: CGRect(origin: CGPoint(x: size.width - buttonWidth, y: 0.0), size: CGSize(width: UIScreenPixel, height: size.height)))
})
}
@objc private func leftPressed() {
let newValue = self.decrease()
self.value = newValue
}
@objc private func rightPressed() {
let newValue = self.increase()
self.value = newValue
}
@objc private func centerPressed() {
self.reset()
self.value = 100
}
func canBeHighlighted() -> Bool {
return false
}
func updateIsHighlighted(isHighlighted: Bool) {
}
func performAction() {
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,524 @@
import Foundation
import UIKit
import Display
import ComponentFlow
import BlurredBackgroundComponent
import ContextUI
final class BrowserNavigationBarEnvironment: Equatable {
public let fraction: CGFloat
public init(fraction: CGFloat) {
self.fraction = fraction
}
public static func ==(lhs: BrowserNavigationBarEnvironment, rhs: BrowserNavigationBarEnvironment) -> Bool {
if lhs.fraction != rhs.fraction {
return false
}
return true
}
}
final class BrowserNavigationBarComponent: CombinedComponent {
public class ExternalState {
public fileprivate(set) var centerItemFrame: CGRect
public init() {
self.centerItemFrame = .zero
}
}
let backgroundColor: UIColor
let separatorColor: UIColor
let textColor: UIColor
let progressColor: UIColor
let accentColor: UIColor
let topInset: CGFloat
let height: CGFloat
let sideInset: CGFloat
let metrics: LayoutMetrics
let externalState: ExternalState?
let leftItems: [AnyComponentWithIdentity<Empty>]
let rightItems: [AnyComponentWithIdentity<Empty>]
let centerItem: AnyComponentWithIdentity<BrowserNavigationBarEnvironment>?
let readingProgress: CGFloat
let loadingProgress: Double?
let collapseFraction: CGFloat
let activate: () -> Void
init(
backgroundColor: UIColor,
separatorColor: UIColor,
textColor: UIColor,
progressColor: UIColor,
accentColor: UIColor,
topInset: CGFloat,
height: CGFloat,
sideInset: CGFloat,
metrics: LayoutMetrics,
externalState: ExternalState?,
leftItems: [AnyComponentWithIdentity<Empty>],
rightItems: [AnyComponentWithIdentity<Empty>],
centerItem: AnyComponentWithIdentity<BrowserNavigationBarEnvironment>?,
readingProgress: CGFloat,
loadingProgress: Double?,
collapseFraction: CGFloat,
activate: @escaping () -> Void
) {
self.backgroundColor = backgroundColor
self.separatorColor = separatorColor
self.textColor = textColor
self.progressColor = progressColor
self.accentColor = accentColor
self.topInset = topInset
self.height = height
self.sideInset = sideInset
self.metrics = metrics
self.externalState = externalState
self.leftItems = leftItems
self.rightItems = rightItems
self.centerItem = centerItem
self.readingProgress = readingProgress
self.loadingProgress = loadingProgress
self.collapseFraction = collapseFraction
self.activate = activate
}
static func ==(lhs: BrowserNavigationBarComponent, rhs: BrowserNavigationBarComponent) -> Bool {
if lhs.backgroundColor != rhs.backgroundColor {
return false
}
if lhs.separatorColor != rhs.separatorColor {
return false
}
if lhs.textColor != rhs.textColor {
return false
}
if lhs.progressColor != rhs.progressColor {
return false
}
if lhs.accentColor != rhs.accentColor {
return false
}
if lhs.topInset != rhs.topInset {
return false
}
if lhs.height != rhs.height {
return false
}
if lhs.sideInset != rhs.sideInset {
return false
}
if lhs.metrics != rhs.metrics {
return false
}
if lhs.leftItems != rhs.leftItems {
return false
}
if lhs.rightItems != rhs.rightItems {
return false
}
if lhs.centerItem != rhs.centerItem {
return false
}
if lhs.readingProgress != rhs.readingProgress {
return false
}
if lhs.loadingProgress != rhs.loadingProgress {
return false
}
if lhs.collapseFraction != rhs.collapseFraction {
return false
}
return true
}
static var body: Body {
let background = Child(Rectangle.self)
let readingProgress = Child(Rectangle.self)
let separator = Child(Rectangle.self)
let loadingProgress = Child(LoadingProgressComponent.self)
let leftItems = ChildMap(environment: Empty.self, keyedBy: AnyHashable.self)
let rightItems = ChildMap(environment: Empty.self, keyedBy: AnyHashable.self)
let centerItems = ChildMap(environment: BrowserNavigationBarEnvironment.self, keyedBy: AnyHashable.self)
let activate = Child(Button.self)
return { context in
var availableWidth = context.availableSize.width
let sideInset: CGFloat = (context.component.metrics.isTablet ? 20.0 : 16.0) + context.component.sideInset
let collapsedHeight: CGFloat = 24.0
let expandedHeight = context.component.height
let contentHeight: CGFloat = expandedHeight * (1.0 - context.component.collapseFraction) + collapsedHeight * context.component.collapseFraction
let size = CGSize(width: context.availableSize.width, height: context.component.topInset + contentHeight)
let verticalOffset: CGFloat = context.component.metrics.isTablet ? -2.0 : 0.0
let itemSpacing: CGFloat = context.component.metrics.isTablet ? 26.0 : 8.0
let background = background.update(
component: Rectangle(color: context.component.backgroundColor.withAlphaComponent(1.0)),
availableSize: CGSize(width: size.width, height: size.height),
transition: context.transition
)
let readingProgress = readingProgress.update(
component: Rectangle(color: context.component.progressColor),
availableSize: CGSize(width: size.width * context.component.readingProgress, height: size.height),
transition: context.transition
)
let separator = separator.update(
component: Rectangle(color: context.component.separatorColor, height: UIScreenPixel),
availableSize: CGSize(width: size.width, height: size.height),
transition: context.transition
)
let loadingProgressHeight: CGFloat = 2.0
let loadingProgress = loadingProgress.update(
component: LoadingProgressComponent(
color: context.component.accentColor,
height: loadingProgressHeight,
value: context.component.loadingProgress ?? 0.0
),
availableSize: CGSize(width: size.width, height: size.height),
transition: context.transition
)
var leftItemList: [_UpdatedChildComponent] = []
for item in context.component.leftItems {
let item = leftItems[item.id].update(
component: item.component,
availableSize: CGSize(width: availableWidth, height: expandedHeight),
transition: context.transition
)
leftItemList.append(item)
availableWidth -= item.size.width
}
var rightItemList: [_UpdatedChildComponent] = []
for item in context.component.rightItems {
let item = rightItems[item.id].update(
component: item.component,
availableSize: CGSize(width: availableWidth, height: expandedHeight),
transition: context.transition
)
rightItemList.append(item)
availableWidth -= item.size.width
}
context.add(background
.position(CGPoint(x: size.width / 2.0, y: size.height / 2.0))
)
var readingProgressAlpha = context.component.collapseFraction
if leftItemList.isEmpty && rightItemList.isEmpty {
readingProgressAlpha = 0.0
}
context.add(readingProgress
.position(CGPoint(x: readingProgress.size.width / 2.0, y: size.height / 2.0))
.opacity(readingProgressAlpha)
)
context.add(separator
.position(CGPoint(x: size.width / 2.0, y: size.height))
)
context.add(loadingProgress
.position(CGPoint(x: size.width / 2.0, y: size.height - loadingProgressHeight / 2.0))
)
var centerLeftInset = sideInset
var leftItemX = sideInset
for item in leftItemList {
context.add(item
.position(CGPoint(x: leftItemX + item.size.width / 2.0 - (item.size.width / 2.0 * 0.35 * context.component.collapseFraction), y: context.component.topInset + contentHeight / 2.0 + verticalOffset))
.scale(1.0 - 0.35 * context.component.collapseFraction)
.opacity(1.0 - context.component.collapseFraction)
.appear(.default(scale: true, alpha: true))
.disappear(.default(scale: true, alpha: true))
)
leftItemX += item.size.width + itemSpacing
centerLeftInset += item.size.width + itemSpacing
}
var centerRightInset = sideInset - 5.0
var rightItemX = context.availableSize.width - (sideInset - 5.0)
for item in rightItemList.reversed() {
context.add(item
.position(CGPoint(x: rightItemX - item.size.width / 2.0 + (item.size.width / 2.0 * 0.35 * context.component.collapseFraction), y: context.component.topInset + contentHeight / 2.0 + verticalOffset))
.scale(1.0 - 0.35 * context.component.collapseFraction)
.opacity(1.0 - context.component.collapseFraction)
.appear(.default(scale: true, alpha: true))
.disappear(.default(scale: true, alpha: true))
)
rightItemX -= item.size.width + itemSpacing
centerRightInset += item.size.width + itemSpacing
}
let maxCenterInset = max(centerLeftInset, centerRightInset)
if !leftItemList.isEmpty || !rightItemList.isEmpty {
availableWidth -= itemSpacing * CGFloat(max(0, leftItemList.count - 1)) + itemSpacing * CGFloat(max(0, rightItemList.count - 1)) + 30.0
}
availableWidth -= context.component.sideInset * 2.0
let canCenter = availableWidth > 660.0
availableWidth = min(660.0, availableWidth)
let environment = BrowserNavigationBarEnvironment(fraction: context.component.collapseFraction)
let centerItem = context.component.centerItem.flatMap { item in
centerItems[item.id].update(
component: item.component,
environment: { environment },
availableSize: CGSize(width: availableWidth, height: expandedHeight),
transition: context.transition
)
}
var centerX = maxCenterInset + (context.availableSize.width - maxCenterInset * 2.0) / 2.0
if "".isEmpty {
if canCenter {
centerX = context.availableSize.width / 2.0
} else {
centerX = centerLeftInset + (context.availableSize.width - centerLeftInset - centerRightInset) / 2.0
}
}
if let centerItem = centerItem {
let centerItemPosition = CGPoint(x: centerX, y: context.component.topInset + contentHeight / 2.0 + verticalOffset)
context.add(centerItem
.position(centerItemPosition)
.scale(1.0 - 0.35 * context.component.collapseFraction)
.appear(.default(scale: false, alpha: true))
.disappear(.default(scale: false, alpha: true))
)
context.component.externalState?.centerItemFrame = centerItem.size.centered(around: centerItemPosition)
}
if context.component.collapseFraction == 1.0 {
let activateAction = context.component.activate
let activate = activate.update(
component: Button(
content: AnyComponent(Rectangle(color: UIColor(rgb: 0x000000, alpha: 0.001))),
action: {
activateAction()
}
),
availableSize: size,
transition: .immediate
)
context.add(activate
.position(CGPoint(x: size.width / 2.0, y: size.height / 2.0))
)
}
return size
}
}
}
private final class LoadingProgressComponent: Component {
let color: UIColor
let height: CGFloat
let value: CGFloat
init(
color: UIColor,
height: CGFloat,
value: CGFloat
) {
self.color = color
self.height = height
self.value = value
}
static func ==(lhs: LoadingProgressComponent, rhs: LoadingProgressComponent) -> Bool {
if lhs.color != rhs.color {
return false
}
if lhs.height != rhs.height {
return false
}
if lhs.value != rhs.value {
return false
}
return true
}
final class View: UIView {
private var lineView: UIView
private var currentValue: Double = 0.0
init() {
self.lineView = UIView()
self.lineView.clipsToBounds = true
self.lineView.layer.cornerRadius = 1.0
self.lineView.alpha = 0.0
super.init(frame: CGRect())
self.addSubview(self.lineView)
}
required init?(coder aDecoder: NSCoder) {
preconditionFailure()
}
func update(component: LoadingProgressComponent, availableSize: CGSize, transition: ComponentTransition) -> CGSize {
self.lineView.backgroundColor = component.color
let value = component.value
let frame = CGRect(origin: .zero, size: CGSize(width: availableSize.width * component.value, height: component.height))
var animated = true
if value < self.currentValue {
if self.currentValue == 1.0 {
self.lineView.frame = CGRect(origin: .zero, size: CGSize(width: 0.0, height: component.height))
} else {
animated = false
}
}
self.currentValue = value
let transition: ComponentTransition
if animated && value > 0.0 {
transition = .spring(duration: 0.7)
} else {
transition = .immediate
}
let alphaTransition: ComponentTransition
if animated {
alphaTransition = .easeInOut(duration: 0.3)
} else {
alphaTransition = .immediate
}
transition.setFrame(view: self.lineView, frame: frame)
let alpha: CGFloat = value < 0.01 || value > 0.99 ? 0.0 : 1.0
alphaTransition.setAlpha(view: self.lineView, alpha: alpha)
return CGSize(width: availableSize.width, height: component.height)
}
}
func makeView() -> View {
return View()
}
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, transition: transition)
}
}
final class ReferenceButtonComponent: Component {
let content: AnyComponent<Empty>
let tag: AnyObject?
let action: () -> Void
init(
content: AnyComponent<Empty>,
tag: AnyObject? = nil,
action: @escaping () -> Void
) {
self.content = content
self.tag = tag
self.action = action
}
static func ==(lhs: ReferenceButtonComponent, rhs: ReferenceButtonComponent) -> Bool {
if lhs.content != rhs.content {
return false
}
if lhs.tag !== rhs.tag {
return false
}
return true
}
final class View: HighlightTrackingButton, ComponentTaggedView {
private let sourceView: ContextControllerSourceView
let referenceNode: ContextReferenceContentNode
let componentView: ComponentView<Empty>
private var component: ReferenceButtonComponent?
public func matches(tag: Any) -> Bool {
if let component = self.component, let componentTag = component.tag {
let tag = tag as AnyObject
if componentTag === tag {
return true
}
}
return false
}
init() {
self.componentView = ComponentView()
self.sourceView = ContextControllerSourceView()
self.sourceView.animateScale = false
self.referenceNode = ContextReferenceContentNode()
super.init(frame: CGRect())
self.sourceView.isUserInteractionEnabled = false
self.addSubview(self.sourceView)
self.sourceView.addSubnode(self.referenceNode)
self.highligthedChanged = { [weak self] highlighted in
if let strongSelf = self, let contentView = strongSelf.componentView.view {
if highlighted {
contentView.layer.removeAnimation(forKey: "opacity")
contentView.alpha = 0.4
} else {
contentView.alpha = 1.0
contentView.layer.animateAlpha(from: 0.4, to: 1.0, duration: 0.2)
}
}
}
self.addTarget(self, action: #selector(self.pressed), for: .touchUpInside)
}
required init?(coder aDecoder: NSCoder) {
preconditionFailure()
}
@objc private func pressed() {
self.component?.action()
}
func update(component: ReferenceButtonComponent, availableSize: CGSize, transition: ComponentTransition) -> CGSize {
self.component = component
let componentSize = self.componentView.update(
transition: transition,
component: component.content,
environment: {},
containerSize: availableSize
)
if let componentView = self.componentView.view {
if componentView.superview == nil {
self.referenceNode.view.addSubview(componentView)
}
transition.setFrame(view: componentView, frame: CGRect(origin: .zero, size: componentSize))
}
transition.setFrame(view: self.sourceView, frame: CGRect(origin: .zero, size: componentSize))
self.referenceNode.frame = CGRect(origin: .zero, size: componentSize)
return componentSize
}
}
func makeView() -> View {
return View()
}
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, transition: transition)
}
}
@@ -0,0 +1,578 @@
import Foundation
import UIKit
import Display
import ComponentFlow
import TelegramCore
import Postbox
import SwiftSignalKit
import TelegramPresentationData
import TelegramUIPreferences
import PresentationDataUtils
import AccountContext
import AppBundle
import PromptUI
import SafariServices
import ShareController
import UndoUI
import UrlEscaping
import PDFKit
final class BrowserPdfContent: UIView, BrowserContent, UIScrollViewDelegate, PDFDocumentDelegate {
private let context: AccountContext
private var presentationData: PresentationData
let file: FileMediaReference
private let pdfView: PDFView
private let scrollView: UIScrollView!
private let pageIndicatorBackgorund: UIVisualEffectView
private let pageIndicator = ComponentView<Empty>()
private var pageNumber: (Int, Int)?
private var pageTimer: SwiftSignalKit.Timer?
let uuid: UUID
private var _state: BrowserContentState
private let statePromise: Promise<BrowserContentState>
var currentState: BrowserContentState {
return self._state
}
var state: Signal<BrowserContentState, NoError> {
return self.statePromise.get()
}
var pushContent: (BrowserScreen.Subject, BrowserContent?) -> Void = { _, _ in }
var openAppUrl: (String) -> Void = { _ in }
var onScrollingUpdate: (ContentScrollingUpdate) -> Void = { _ in }
var minimize: () -> Void = { }
var close: () -> Void = { }
var present: (ViewController, Any?) -> Void = { _, _ in }
var presentInGlobalOverlay: (ViewController) -> Void = { _ in }
var getNavigationController: () -> NavigationController? = { return nil }
private var tempFile: TempBoxFile?
init(context: AccountContext, presentationData: PresentationData, file: FileMediaReference) {
self.context = context
self.uuid = UUID()
self.presentationData = presentationData
self.file = file
self.pdfView = PDFView()
self.pdfView.clipsToBounds = false
self.pageIndicatorBackgorund = UIVisualEffectView(effect: UIBlurEffect(style: .light))
self.pageIndicatorBackgorund.clipsToBounds = true
self.pageIndicatorBackgorund.layer.cornerRadius = 10.0
var scrollView: UIScrollView?
for view in self.pdfView.subviews {
if let view = view as? UIScrollView {
scrollView = view
} else {
for subview in view.subviews {
if let subview = subview as? UIScrollView {
scrollView = subview
}
}
}
}
self.scrollView = scrollView
scrollView?.clipsToBounds = false
self.pdfView.displayDirection = .vertical
self.pdfView.autoScales = true
var title = "file"
var url = ""
if let path = self.context.account.postbox.mediaBox.completedResourcePath(file.media.resource) {
var updatedPath = path
if let fileName = file.media.fileName {
let tempFile = TempBox.shared.file(path: path, fileName: fileName)
updatedPath = tempFile.path
self.tempFile = tempFile
title = fileName
url = updatedPath
}
self.pdfView.document = PDFDocument(url: URL(fileURLWithPath: updatedPath))
}
self._state = BrowserContentState(title: title, url: url, estimatedProgress: 0.0, readingProgress: 0.0, contentType: .document)
self.statePromise = Promise<BrowserContentState>(self._state)
super.init(frame: .zero)
if #available(iOS 15.0, *) {
self.backgroundColor = presentationData.theme.list.plainBackgroundColor
}
self.addSubview(self.pdfView)
Queue.mainQueue().after(1.0) {
if let scrollView = self.scrollView {
scrollView.delegate = self
}
}
self.pageNumber = (1, self.pdfView.document?.pageCount ?? 1)
self.startPageIndicatorTimer()
self.pdfView.interactiveTransitionGestureRecognizerTest = { [weak self] point in
if let self {
if let result = self.pdfView.hitTest(point, with: nil), let scrollView = findScrollView(view: result), scrollView.isDescendant(of: self.pdfView) {
if scrollView.contentSize.width > scrollView.frame.width, scrollView.contentOffset.x > -scrollView.contentInset.left {
return true
}
}
}
return false
}
NotificationCenter.default.addObserver(self, selector: #selector(self.pageChangeHandler(_:)), name: .PDFViewPageChanged, object: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
NotificationCenter.default.removeObserver(self, name: .PDFViewPageChanged, object: nil)
}
@objc func pageChangeHandler(_ notification: Notification) {
if let document = self.pdfView.document, let page = self.pdfView.currentPage {
let number = document.index(for: page) + 1
if number != self.pageNumber?.0 {
self.pageNumber = (number, document.pageCount)
if let (size, insets, fullInsets) = self.validLayout {
self.updateLayout(size: size, insets: insets, fullInsets: fullInsets, safeInsets: .zero, transition: .immediate)
}
}
}
}
func updatePresentationData(_ presentationData: PresentationData) {
self.presentationData = presentationData
if #available(iOS 15.0, *) {
self.backgroundColor = presentationData.theme.list.plainBackgroundColor
}
if let (size, insets, fullInsets) = self.validLayout {
self.updateLayout(size: size, insets: insets, fullInsets: fullInsets, safeInsets: .zero, transition: .immediate)
}
}
func startPageIndicatorTimer() {
self.pageTimer?.invalidate()
self.pageTimer = SwiftSignalKit.Timer(timeout: 2.0, repeat: false, completion: { [weak self] in
guard let self else {
return
}
let transition = ComponentTransition.easeInOut(duration: 0.25)
transition.setAlpha(view: self.pageIndicatorBackgorund, alpha: 0.0)
}, queue: Queue.mainQueue())
self.pageTimer?.start()
}
func updateFontState(_ state: BrowserPresentationState.FontState) {
}
func updateFontState(_ state: BrowserPresentationState.FontState, force: Bool) {
}
func toggleInstantView(_ enabled: Bool) {
}
private var findSession: Any?
private var previousQuery: String?
private var currentSearchResult: Int = 0
private var searchResultsCount: Int = 0
private var searchResults: [PDFSelection] = []
private var searchCompletion: ((Int) -> Void)?
private let matchColor = UIColor(rgb: 0xd4d4d, alpha: 0.2)
private let selectedColor = UIColor(rgb: 0xffe438)
func didMatchString(_ instance: PDFSelection) {
instance.color = self.matchColor
self.searchResults.append(instance)
}
func documentDidEndDocumentFind(_ notification: Notification) {
self.searchResultsCount = self.searchResults.count
if let searchCompletion = self.searchCompletion {
self.searchCompletion = nil
searchCompletion(self.searchResultsCount)
}
self.updateSearchHighlights(highlightedSelection: self.searchResults.first)
}
func updateSearchHighlights(highlightedSelection: PDFSelection?) {
self.pdfView.highlightedSelections = nil
if let highlightedSelection {
for selection in self.searchResults {
if selection === highlightedSelection {
selection.color = self.selectedColor
} else {
selection.color = self.matchColor
}
}
self.pdfView.highlightedSelections = self.searchResults
}
}
func setSearch(_ query: String?, completion: ((Int) -> Void)?) {
guard let document = self.pdfView.document, self.previousQuery != query else {
return
}
self.previousQuery = query
if #available(iOS 16.0, *), !"".isEmpty {
if let query {
var findSession: UIFindSession?
if let current = self.findSession as? UIFindSession {
findSession = current
} else {
self.pdfView.isFindInteractionEnabled = true
if let session = self.pdfView.findInteraction(self.pdfView.findInteraction, sessionFor: self.pdfView) {
findSession = session
self.findSession = session
self.pdfView.findInteraction(self.pdfView.findInteraction, didBegin: session)
}
}
if let findSession {
findSession.performSearch(query: query, options: BrowserSearchOptions())
self.pdfView.findInteraction.updateResultCount()
completion?(findSession.resultCount)
}
} else {
if let session = self.findSession as? UIFindSession {
self.pdfView.findInteraction(self.pdfView.findInteraction, didEnd: session)
self.findSession = nil
self.pdfView.isFindInteractionEnabled = false
}
}
} else {
if let query {
self.currentSearchResult = 0
self.searchCompletion = completion
document.cancelFindString()
document.delegate = self
document.beginFindString(query, withOptions: .caseInsensitive)
} else {
self.searchResults = []
self.currentSearchResult = 0
self.searchResultsCount = 0
self.updateSearchHighlights(highlightedSelection: nil)
document.cancelFindString()
document.delegate = nil
completion?(0)
}
}
}
func scrollToPreviousSearchResult(completion: ((Int, Int) -> Void)?) {
if #available(iOS 16.0, *), !"".isEmpty {
if let session = self.findSession as? UIFindSession {
session.highlightNextResult(in: .backward)
completion?(session.highlightedResultIndex, session.resultCount)
}
} else {
let searchResultsCount = self.searchResultsCount
var index = self.currentSearchResult - 1
if index < 0 {
index = searchResultsCount - 1
}
self.currentSearchResult = index
if index >= 0 && index < self.searchResults.count {
self.updateSearchHighlights(highlightedSelection: self.searchResults[index])
self.pdfView.go(to: self.searchResults[index])
completion?(index, searchResultsCount)
}
}
}
func scrollToNextSearchResult(completion: ((Int, Int) -> Void)?) {
if #available(iOS 16.0, *), !"".isEmpty {
if let session = self.findSession as? UIFindSession {
session.highlightNextResult(in: .forward)
completion?(session.highlightedResultIndex, session.resultCount)
}
} else {
let searchResultsCount = self.searchResultsCount
var index = self.currentSearchResult + 1
if index >= searchResultsCount {
index = 0
}
self.currentSearchResult = index
if index >= 0 && index < self.searchResults.count {
self.updateSearchHighlights(highlightedSelection: self.searchResults[index])
self.pdfView.go(to: self.searchResults[index])
completion?(index, searchResultsCount)
}
}
}
func stop() {
}
func reload() {
}
func navigateBack() {
}
func navigateForward() {
}
func navigateTo(historyItem: BrowserContentState.HistoryItem) {
}
func navigateTo(address: String) {
}
func scrollToTop() {
self.scrollView.setContentOffset(CGPoint(x: 0.0, y: -self.scrollView.contentInset.top), animated: true)
}
private var validLayout: (CGSize, UIEdgeInsets, UIEdgeInsets)?
func updateLayout(size: CGSize, insets: UIEdgeInsets, fullInsets: UIEdgeInsets, safeInsets: UIEdgeInsets, transition: ComponentTransition) {
let isFirstTime = self.validLayout == nil
self.validLayout = (size, insets, fullInsets)
self.previousScrollingOffset = ScrollingOffsetState(value: self.scrollView.contentOffset.y, isDraggingOrDecelerating: self.scrollView.isDragging || self.scrollView.isDecelerating)
let currentBounds = self.scrollView.bounds
let offsetToBottomEdge = max(0.0, self.scrollView.contentSize.height - currentBounds.maxY)
var bottomInset = insets.bottom
if offsetToBottomEdge < 128.0 {
bottomInset = fullInsets.bottom
}
let pdfViewFrame = CGRect(origin: CGPoint(x: insets.left, y: insets.top), size: CGSize(width: size.width - insets.left - insets.right, height: size.height - insets.top - bottomInset))
transition.setFrame(view: self.pdfView, frame: pdfViewFrame)
let pageIndicatorSize = self.pageIndicator.update(
transition: .immediate,
component: AnyComponent(
Text(text: "\(self.pageNumber?.0 ?? 1) of \(self.pageNumber?.1 ?? 1)", font: Font.with(size: 15.0, weight: .semibold, traits: .monospacedNumbers), color: self.presentationData.theme.list.itemSecondaryTextColor)
),
environment: {},
containerSize: size
)
if let view = self.pageIndicator.view {
if view.superview == nil {
self.addSubview(self.pageIndicatorBackgorund)
self.pageIndicatorBackgorund.contentView.addSubview(view)
}
let horizontalPadding: CGFloat = 10.0
let verticalPadding: CGFloat = 8.0
let pageBackgroundFrame = CGRect(origin: CGPoint(x: insets.left + 20.0, y: insets.top + 16.0), size: CGSize(width: horizontalPadding * 2.0 + pageIndicatorSize.width, height: verticalPadding * 2.0 + pageIndicatorSize.height))
self.pageIndicatorBackgorund.bounds = CGRect(origin: .zero, size: pageBackgroundFrame.size)
transition.setPosition(view: self.pageIndicatorBackgorund, position: pageBackgroundFrame.center)
view.frame = CGRect(origin: CGPoint(x: horizontalPadding, y: verticalPadding), size: pageIndicatorSize)
}
if isFirstTime {
self.pdfView.setNeedsLayout()
self.pdfView.layoutIfNeeded()
self.pdfView.minScaleFactor = self.pdfView.scaleFactorForSizeToFit
}
}
private func updateState(_ f: (BrowserContentState) -> BrowserContentState) {
let updated = f(self._state)
self._state = updated
self.statePromise.set(.single(self._state))
}
private struct ScrollingOffsetState: Equatable {
var value: CGFloat
var isDraggingOrDecelerating: Bool
}
private var previousScrollingOffset: ScrollingOffsetState?
private func snapScrollingOffsetToInsets() {
let transition = ComponentTransition(animation: .curve(duration: 0.4, curve: .spring))
self.updateScrollingOffset(isReset: false, transition: transition)
}
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
if let scrollViewDelegate = scrollView as? UIScrollViewDelegate {
return scrollViewDelegate.viewForZooming?(in: scrollView)
}
return nil
}
func scrollViewWillBeginZooming(_ scrollView: UIScrollView, with view: UIView?) {
if let scrollViewDelegate = scrollView as? UIScrollViewDelegate {
scrollViewDelegate.scrollViewWillBeginZooming?(scrollView, with: view)
}
self.resetScrolling()
self.wasZooming = true
}
private var wasZooming = false
func scrollViewDidEndZooming(_ scrollView: UIScrollView, with view: UIView?, atScale scale: CGFloat) {
if let scrollViewDelegate = scrollView as? UIScrollViewDelegate {
scrollViewDelegate.scrollViewDidEndZooming?(scrollView, with: view, atScale: scale)
}
Queue.mainQueue().after(0.1, {
self.wasZooming = false
})
}
func scrollViewDidZoom(_ scrollView: UIScrollView) {
if let scrollViewDelegate = scrollView as? UIScrollViewDelegate {
scrollViewDelegate.scrollViewDidZoom?(scrollView)
}
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
if let scrollViewDelegate = scrollView as? UIScrollViewDelegate {
scrollViewDelegate.scrollViewDidScroll?(scrollView)
}
if !scrollView.isZooming && !self.wasZooming {
self.updateScrollingOffset(isReset: false, transition: .immediate)
}
}
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
if let scrollViewDelegate = scrollView as? UIScrollViewDelegate {
scrollViewDelegate.scrollViewWillBeginDragging?(scrollView)
}
let transition = ComponentTransition.easeInOut(duration: 0.1)
transition.setAlpha(view: self.pageIndicatorBackgorund, alpha: 1.0)
self.pageTimer?.invalidate()
self.pageTimer = nil
}
public func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
if let scrollViewDelegate = scrollView as? UIScrollViewDelegate {
scrollViewDelegate.scrollViewDidEndDragging?(scrollView, willDecelerate: decelerate)
}
if !decelerate {
self.snapScrollingOffsetToInsets()
if self.ignoreUpdatesUntilScrollingStopped {
self.ignoreUpdatesUntilScrollingStopped = false
}
self.startPageIndicatorTimer()
}
}
public func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
if let scrollViewDelegate = scrollView as? UIScrollViewDelegate {
scrollViewDelegate.scrollViewDidEndDecelerating?(scrollView)
}
self.snapScrollingOffsetToInsets()
if self.ignoreUpdatesUntilScrollingStopped {
self.ignoreUpdatesUntilScrollingStopped = false
}
self.startPageIndicatorTimer()
}
private func updateScrollingOffset(isReset: Bool, transition: ComponentTransition) {
guard !self.ignoreUpdatesUntilScrollingStopped else {
return
}
guard let scrollView = self.scrollView else {
return
}
let isInteracting = scrollView.isDragging || scrollView.isDecelerating
if let previousScrollingOffsetValue = self.previousScrollingOffset {
let currentBounds = scrollView.bounds
let offsetToTopEdge = max(0.0, currentBounds.minY - 0.0)
let offsetToBottomEdge = max(0.0, scrollView.contentSize.height - currentBounds.maxY)
let relativeOffset = scrollView.contentOffset.y - previousScrollingOffsetValue.value
self.onScrollingUpdate(ContentScrollingUpdate(
relativeOffset: relativeOffset,
absoluteOffsetToTopEdge: offsetToTopEdge,
absoluteOffsetToBottomEdge: offsetToBottomEdge,
isReset: isReset,
isInteracting: isInteracting,
transition: transition
))
}
self.previousScrollingOffset = ScrollingOffsetState(value: scrollView.contentOffset.y, isDraggingOrDecelerating: isInteracting)
var readingProgress: CGFloat = 0.0
if !scrollView.contentSize.height.isZero {
let value = (scrollView.contentOffset.y + scrollView.contentInset.top) / (scrollView.contentSize.height - scrollView.bounds.size.height + scrollView.contentInset.top)
readingProgress = max(0.0, min(1.0, value))
}
self.updateState {
$0.withUpdatedReadingProgress(readingProgress)
}
}
private var ignoreUpdatesUntilScrollingStopped = false
func resetScrolling() {
self.updateScrollingOffset(isReset: true, transition: .spring(duration: 0.4))
if self.scrollView.isDecelerating {
self.ignoreUpdatesUntilScrollingStopped = true
}
}
private func open(url: String, new: Bool) {
let subject: BrowserScreen.Subject = .webPage(url: url)
if new, let navigationController = self.getNavigationController() {
navigationController._keepModalDismissProgress = true
self.minimize()
let controller = BrowserScreen(context: self.context, subject: subject)
navigationController._keepModalDismissProgress = true
navigationController.pushViewController(controller)
} else {
self.pushContent(subject, nil)
}
}
private func share(url: String) {
let presentationData = self.context.sharedContext.currentPresentationData.with { $0 }
let shareController = ShareController(context: self.context, subject: .url(url))
shareController.actionCompleted = { [weak self] in
self?.present(UndoOverlayController(presentationData: presentationData, content: .linkCopied(title: nil, text: presentationData.strings.Conversation_LinkCopied), elevatedLayout: false, animateInAsReplacement: false, action: { _ in return false }), nil)
}
self.present(shareController, nil)
}
func addToRecentlyVisited() {
}
func makeContentSnapshotView() -> UIView? {
return nil
}
}
private func findScrollView(view: UIView?) -> UIScrollView? {
if let view = view {
if let view = view as? UIScrollView {
return view
}
return findScrollView(view: view.superview)
} else {
return nil
}
}
@@ -0,0 +1,788 @@
import Foundation
import WebKit
import AppBundle
import Postbox
import TelegramCore
import InstantPageUI
public class Readability: NSObject, WKNavigationDelegate {
private let url: URL
let webView: WKWebView
private let completionHandler: ((_ webPage: (TelegramMediaWebpage, [Any]?)?, _ error: Error?) -> Void)
private var hasRenderedReadabilityHTML = false
private var subresources: [Any]?
init(url: URL, archiveData: Data, completionHandler: @escaping (_ webPage: (TelegramMediaWebpage, [Any]?)?, _ error: Error?) -> Void) {
self.url = url
self.completionHandler = completionHandler
let preferences = WKPreferences()
let configuration = WKWebViewConfiguration()
configuration.preferences = preferences
configuration.userContentController.addUserScript(ReadabilityUserScript())
self.webView = WKWebView(frame: CGRect.zero, configuration: configuration)
super.init()
self.webView.configuration.suppressesIncrementalRendering = true
self.webView.navigationDelegate = self
if #available(iOS 16.4, *) {
self.webView.isInspectable = true
}
if let (html, subresources) = extractHtmlString(from: archiveData) {
self.subresources = subresources
self.webView.loadHTMLString(html, baseURL: url.baseURL)
}
}
private func initializeReadability(completion: @escaping (_ result: TelegramMediaWebpage?, _ error: Error?) -> Void) {
guard let readabilityInitializationJS = loadFile(name: "ReaderMode", type: "js") else {
return
}
self.webView.evaluateJavaScript(readabilityInitializationJS) { (result, error) in
guard let result = result as? [String: Any] else {
completion(nil, error)
return
}
guard let page = parseJson(result, url: self.url.absoluteString) else {
return
}
completion(page, nil)
}
}
public func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
if !self.hasRenderedReadabilityHTML {
self.initializeReadability() { [weak self] (webPage: TelegramMediaWebpage?, error: Error?) in
guard let self else {
return
}
self.hasRenderedReadabilityHTML = true
guard let webPage else {
self.completionHandler(nil, error)
return
}
self.completionHandler((webPage, self.subresources), error)
}
}
}
}
class ReadabilityUserScript: WKUserScript {
convenience override init() {
guard let js = loadFile(name: "Readability", type: "js") else {
fatalError()
}
self.init(source: js, injectionTime: .atDocumentEnd, forMainFrameOnly: true)
}
}
func loadFile(name: String, type: String) -> String? {
let bundle = getAppBundle()
guard let userScriptPath = bundle.path(forResource: name, ofType: type) else {
return nil
}
guard let userScriptData = try? Data(contentsOf: URL(fileURLWithPath: userScriptPath)) else {
return nil
}
guard let userScript = String(data: userScriptData, encoding: .utf8) else {
return nil
}
return userScript
}
private func extractHtmlString(from webArchiveData: Data) -> (String, [Any]?)? {
if let webArchiveDict = try? PropertyListSerialization.propertyList(from: webArchiveData, format: nil) as? [String: Any],
let mainResource = webArchiveDict["WebMainResource"] as? [String: Any],
let htmlData = mainResource["WebResourceData"] as? Data {
guard let htmlString = String(data: htmlData, encoding: .utf8) else {
return nil
}
return (htmlString, webArchiveDict["WebSubresources"] as? [Any])
}
return nil
}
private func parseJson(_ input: [String: Any], url: String) -> TelegramMediaWebpage? {
let siteName = input["siteName"] as? String
let title = input["title"] as? String
let byline = input["byline"] as? String
let excerpt = input["excerpt"] as? String
var media: [MediaId: Media] = [:]
let blocks = parseContent(input, url, &media)
guard !blocks.isEmpty else {
return nil
}
return TelegramMediaWebpage(
webpageId: MediaId(namespace: 0, id: 0),
content: .Loaded(
TelegramMediaWebpageLoadedContent(
url: url,
displayUrl: url,
hash: 0,
type: "article",
websiteName: siteName,
title: title,
text: excerpt,
embedUrl: nil,
embedType: nil,
embedSize: nil,
duration: nil,
author: byline,
isMediaLargeByDefault: nil,
imageIsVideoCover: false,
image: nil,
file: nil,
story: nil,
attributes: [],
instantPage: InstantPage(
blocks: blocks,
media: media,
isComplete: true,
rtl: false,
url: url,
views: nil
)
)
)
)
}
private func parseContent(_ input: [String: Any], _ url: String, _ media: inout [MediaId: Media]) -> [InstantPageBlock] {
let title = input["title"] as? String
let byline = input["byline"] as? String
let date = input["publishedTime"] as? String
let _ = date
guard let content = input["content"] as? [Any] else {
return []
}
var blocks = parsePageBlocks(content, url, &media)
if case .header = blocks.first {
} else {
if var byline {
byline = byline.replacingOccurrences(of: "[\n\t]+", with: " ", options: .regularExpression, range: nil)
blocks.insert(.authorDate(author: trim(parseRichText(byline)), date: 0), at: 0)
}
if let title {
blocks.insert(.title(trim(parseRichText(title))), at: 0)
}
}
return blocks
}
private func parseRichText(_ input: String) -> RichText {
return .plain(input)
}
private func parseRichText(_ input: [String: Any], _ media: inout [MediaId: Media]) -> RichText {
var text: RichText
if let string = input["content"] as? String {
text = parseRichText(string)
} else if let array = input["content"] as? [Any] {
text = parseRichText(array, &media)
} else {
text = .empty
}
text = applyAnchor(text, item: input)
if let _ = input["bold"] {
text = .bold(text)
}
if let _ = input["italic"] {
text = .italic(text)
}
return text
}
private func parseRichText(_ input: [Any], _ media: inout [MediaId: Media]) -> RichText {
var result: [RichText] = []
for item in input {
if let string = item as? String {
result.append(parseRichText(string))
} else if let item = item as? [String: Any], let tag = item["tag"] as? String {
var text: RichText?
var addLineBreak = false
switch tag {
case "b", "strong":
text = .bold(parseRichText(item, &media))
case "i":
text = .italic(parseRichText(item, &media))
case "s":
text = .strikethrough(parseRichText(item, &media))
case "p":
text = parseRichText(item, &media)
case "a":
if let href = item["href"] as? String {
let telString = "tel:"
let mailtoString = "mailto:"
if href.hasPrefix("tel:") {
text = .phone(text: parseRichText(item, &media), phone: String(href[href.index(href.startIndex, offsetBy: telString.distance(from: telString.startIndex, to: telString.endIndex))...]))
} else if href.hasPrefix(mailtoString) {
text = .email(text: parseRichText(item, &media), email: String(href[href.index(href.startIndex, offsetBy: mailtoString.distance(from: mailtoString.startIndex, to: mailtoString.endIndex))...]))
} else {
text = .url(text: parseRichText(item, &media), url: href, webpageId: nil)
}
} else {
text = parseRichText(item, &media)
}
case "pre", "code":
text = .fixed(parseRichText(item, &media))
case "mark":
text = .marked(parseRichText(item, &media))
case "sub":
text = .subscript(parseRichText(item, &media))
case "sup":
text = .superscript(parseRichText(item, &media))
case "img":
if let src = item["src"] as? String, !src.isEmpty {
let width: Int32
if let value = item["width"] as? String, let intValue = Int32(value) {
width = intValue
} else {
width = 0
}
let height: Int32
if let value = item["height"] as? String, let intValue = Int32(value) {
height = intValue
} else {
height = 0
}
let id = MediaId(namespace: Namespaces.Media.CloudFile, id: Int64(media.count))
media[id] = TelegramMediaImage(
imageId: id,
representations: [
TelegramMediaImageRepresentation(
dimensions: PixelDimensions(width: width, height: height),
resource: InstantPageExternalMediaResource(url: src),
progressiveSizes: [],
immediateThumbnailData: nil
)
],
immediateThumbnailData: nil,
reference: nil,
partialReference: nil,
flags: []
)
text = .image(id: id, dimensions: PixelDimensions(width: width, height: height))
if width > 100 {
addLineBreak = true
}
}
case "br":
if let last = result.last {
result[result.count - 1] = addNewLine(last)
}
default:
text = parseRichText(item, &media)
}
if var text {
text = applyAnchor(text, item: item)
result.append(text)
if addLineBreak {
result.append(.plain("\n"))
}
}
}
}
if !result.isEmpty {
return .concat(result)
} else if result.count == 1, let text = result.first {
return text
} else {
return .empty
}
}
private func trimStart(_ input: RichText) -> RichText {
var text = input
switch input {
case .empty:
text = .empty
case let .plain(string):
text = .plain(string.replacingOccurrences(of: "^[ \t\r\n]+", with: "", options: .regularExpression, range: nil))
case let .bold(richText):
text = .bold(trimStart(richText))
case let .italic(richText):
text = .italic(trimStart(richText))
case let .underline(richText):
text = .underline(trimStart(richText))
case let .strikethrough(richText):
text = .strikethrough(trimStart(richText))
case let .fixed(richText):
text = .fixed(trimStart(richText))
case let .url(richText, url, webpageId):
text = .url(text: trimStart(richText), url: url, webpageId: webpageId)
case let .email(richText, email):
text = .email(text: trimStart(richText), email: email)
case let .subscript(richText):
text = .subscript(trimStart(richText))
case let .superscript(richText):
text = .superscript(trimStart(richText))
case let .marked(richText):
text = .marked(trimStart(richText))
case let .phone(richText, phone):
text = .phone(text: trimStart(richText), phone: phone)
case let .anchor(richText, name):
text = .anchor(text: trimStart(richText), name: name)
case var .concat(array):
if !array.isEmpty {
array[0] = trimStart(array[0])
text = .concat(array)
}
case .image:
break
}
return text
}
private func trimEnd(_ input: RichText) -> RichText {
var text = input
switch input {
case .empty:
text = .empty
case let .plain(string):
text = .plain(string.replacingOccurrences(of: "[ \t\r\n]+$", with: "", options: .regularExpression, range: nil))
case let .bold(richText):
text = .bold(trimStart(richText))
case let .italic(richText):
text = .italic(trimStart(richText))
case let .underline(richText):
text = .underline(trimStart(richText))
case let .strikethrough(richText):
text = .strikethrough(trimStart(richText))
case let .fixed(richText):
text = .fixed(trimStart(richText))
case let .url(richText, url, webpageId):
text = .url(text: trimStart(richText), url: url, webpageId: webpageId)
case let .email(richText, email):
text = .email(text: trimStart(richText), email: email)
case let .subscript(richText):
text = .subscript(trimStart(richText))
case let .superscript(richText):
text = .superscript(trimStart(richText))
case let .marked(richText):
text = .marked(trimStart(richText))
case let .phone(richText, phone):
text = .phone(text: trimStart(richText), phone: phone)
case let .anchor(richText, name):
text = .anchor(text: trimStart(richText), name: name)
case var .concat(array):
if !array.isEmpty {
array[array.count - 1] = trimStart(array[array.count - 1])
text = .concat(array)
}
case .image:
break
}
return text
}
private func trim(_ input: RichText) -> RichText {
var text = input
switch input {
case .empty:
text = .empty
case let .plain(string):
text = .plain(string.trimmingCharacters(in: .whitespacesAndNewlines))
case let .bold(richText):
text = .bold(trimStart(richText))
case let .italic(richText):
text = .italic(trimStart(richText))
case let .underline(richText):
text = .underline(trimStart(richText))
case let .strikethrough(richText):
text = .strikethrough(trimStart(richText))
case let .fixed(richText):
text = .fixed(trimStart(richText))
case let .url(richText, url, webpageId):
text = .url(text: trimStart(richText), url: url, webpageId: webpageId)
case let .email(richText, email):
text = .email(text: trimStart(richText), email: email)
case let .subscript(richText):
text = .subscript(trimStart(richText))
case let .superscript(richText):
text = .superscript(trimStart(richText))
case let .marked(richText):
text = .marked(trimStart(richText))
case let .phone(richText, phone):
text = .phone(text: trimStart(richText), phone: phone)
case let .anchor(richText, name):
text = .anchor(text: trimStart(richText), name: name)
case var .concat(array):
if !array.isEmpty {
array[0] = trimStart(array[0])
array[array.count - 1] = trimEnd(array[array.count - 1])
text = .concat(array)
}
case .image:
break
}
return text
}
private func addNewLine(_ input: RichText) -> RichText {
var text = input
switch input {
case .empty:
text = .empty
case let .plain(string):
text = .plain(string + "\n")
case let .bold(richText):
text = .bold(addNewLine(richText))
case let .italic(richText):
text = .italic(addNewLine(richText))
case let .underline(richText):
text = .underline(addNewLine(richText))
case let .strikethrough(richText):
text = .strikethrough(addNewLine(richText))
case let .fixed(richText):
text = .fixed(addNewLine(richText))
case let .url(richText, url, webpageId):
text = .url(text: addNewLine(richText), url: url, webpageId: webpageId)
case let .email(richText, email):
text = .email(text: addNewLine(richText), email: email)
case let .subscript(richText):
text = .subscript(addNewLine(richText))
case let .superscript(richText):
text = .superscript(addNewLine(richText))
case let .marked(richText):
text = .marked(addNewLine(richText))
case let .phone(richText, phone):
text = .phone(text: addNewLine(richText), phone: phone)
case let .anchor(richText, name):
text = .anchor(text: addNewLine(richText), name: name)
case var .concat(array):
if !array.isEmpty {
array[array.count - 1] = addNewLine(array[array.count - 1])
text = .concat(array)
}
case .image:
break
}
return text
}
private func applyAnchor(_ input: RichText, item: [String: Any]) -> RichText {
guard let id = item["id"] as? String, !id.isEmpty else {
return input
}
return .anchor(text: input, name: id)
}
private func parseTable(_ input: [String: Any], _ media: inout [MediaId: Media]) -> InstantPageBlock {
let title = (input["title"] as? String) ?? ""
return .table(
title: trim(applyAnchor(parseRichText(title), item: input)),
rows: parseTableRows((input["content"] as? [Any]) ?? [], &media),
bordered: true,
striped: true
)
}
private func parseTableRows(_ input: [Any], _ media: inout [MediaId: Media]) -> [InstantPageTableRow] {
var result: [InstantPageTableRow] = []
for item in input {
if let item = item as? [String: Any] {
let tag = item["tag"] as? String
if tag == "tr" {
result.append(parseTableRow(item, &media))
} else if let content = item["content"] as? [Any] {
result.append(contentsOf: parseTableRows(content, &media))
}
}
}
return result
}
private func parseTableRow(_ input: [String: Any], _ media: inout [MediaId: Media]) -> InstantPageTableRow {
var cells: [InstantPageTableCell] = []
if let content = input["content"] as? [Any] {
for item in content {
guard let item = item as? [String: Any] else {
continue
}
let tag = item["tag"] as? String
guard ["td", "th"].contains(tag) else {
continue
}
var text: RichText?
if let content = item["content"] as? [Any] {
text = trim(parseRichText(content, &media))
if let currentText = text {
if let _ = item["bold"] {
text = .bold(currentText)
}
if let _ = item["italic"] {
text = .italic(currentText)
}
}
}
cells.append(InstantPageTableCell(
text: text,
header: tag == "th",
alignment: item["xcenter"] != nil ? .center : .left,
verticalAlignment: .middle,
colspan: ((item["colspan"] as? String).flatMap { Int32($0) }) ?? 0,
rowspan: ((item["rowspan"] as? String).flatMap { Int32($0) }) ?? 0
))
}
}
return InstantPageTableRow(cells: cells)
}
private func parseDetails(_ item: [String: Any], _ url: String, _ media: inout [MediaId: Media]) -> InstantPageBlock? {
guard var content = item["contant"] as? [Any] else {
return nil
}
var title: RichText = .empty
var titleIndex: Int?
for i in 0 ..< content.count {
if let subitem = content[i] as? [String: Any], let tag = subitem["tag"] as? String, tag == "summary" {
title = trim(parseRichText(subitem, &media))
titleIndex = i
break
}
}
if let titleIndex {
content.remove(at: titleIndex)
}
return .details(
title: title,
blocks: parsePageBlocks(content, url, &media),
expanded: item["open"] != nil
)
}
private let nonListCharacters = CharacterSet(charactersIn: "0123456789").inverted
private func parseList(_ input: [String: Any], _ url: String, _ media: inout [MediaId: Media]) -> InstantPageBlock? {
guard let content = input["content"] as? [Any], let tag = input["tag"] as? String else {
return nil
}
var items: [InstantPageListItem] = []
for item in content {
guard let item = item as? [String: Any], let tag = item["tag"] as? String, tag == "li" else {
continue
}
var parseAsBlocks = false
if let subcontent = item["content"] as? [Any] {
for item in subcontent {
if let item = item as? [String: Any], let tag = item["tag"] as? String, ["ul", "ol"].contains(tag) {
parseAsBlocks = true
}
}
if parseAsBlocks {
let blocks = parsePageBlocks(subcontent, url, &media)
if !blocks.isEmpty {
items.append(.blocks(blocks, nil))
}
} else {
items.append(.text(trim(parseRichText(item, &media)), nil))
}
}
}
let ordered = tag == "ol"
var allEmpty = true
for item in items {
if case let .text(text, _) = item {
if case .empty = text {
} else {
let plainText = text.plainText
if !plainText.isEmpty && plainText.rangeOfCharacter(from: nonListCharacters) != nil {
allEmpty = false
}
break
}
} else {
allEmpty = false
break
}
}
guard !allEmpty else {
return nil
}
return .list(items: items, ordered: ordered)
}
private func parseImage(_ input: [String: Any], _ media: inout [MediaId: Media]) -> InstantPageBlock? {
guard let src = input["src"] as? String else {
return nil
}
let caption: InstantPageCaption
if let alt = input["alt"] as? String {
caption = InstantPageCaption(
text: trim(parseRichText(alt)),
credit: .empty
)
} else {
caption = InstantPageCaption(text: .empty, credit: .empty)
}
let width: Int32
if let value = input["width"] as? String, let intValue = Int32(value) {
width = intValue
} else {
width = 0
}
let height: Int32
if let value = input["height"] as? String, let intValue = Int32(value) {
height = intValue
} else {
height = 0
}
let id = MediaId(namespace: Namespaces.Media.CloudImage, id: Int64(media.count))
media[id] = TelegramMediaImage(
imageId: id,
representations: [
TelegramMediaImageRepresentation(
dimensions: PixelDimensions(width: width, height: height),
resource: InstantPageExternalMediaResource(url: src),
progressiveSizes: [],
immediateThumbnailData: nil
)
],
immediateThumbnailData: nil,
reference: nil,
partialReference: nil,
flags: []
)
return .image(
id: id,
caption: caption,
url: nil,
webpageId: nil
)
}
private func parseVideo(_ input: [String: Any], _ media: inout [MediaId: Media]) -> InstantPageBlock? {
guard let src = input["src"] as? String else {
return nil
}
let width: Int32
if let value = input["width"] as? String, let intValue = Int32(value) {
width = intValue
} else {
width = 0
}
let height: Int32
if let value = input["height"] as? String, let intValue = Int32(value) {
height = intValue
} else {
height = 0
}
return .webEmbed(
url: src,
html: nil,
dimensions: PixelDimensions(width: width, height: height),
caption: InstantPageCaption(text: .empty, credit: .empty),
stretchToWidth: true,
allowScrolling: false,
coverId: nil
)
}
private func parseFigure(_ input: [String: Any], _ media: inout [MediaId: Media]) -> InstantPageBlock? {
guard let content = input["content"] as? [Any] else {
return nil
}
var block: InstantPageBlock?
var caption: RichText?
for item in content {
if let item = item as? [String: Any], let tag = item["tag"] as? String {
if tag == "p", let content = item["content"] as? [Any] {
for item in content {
if let item = item as? [String: Any], let tag = item["tag"] as? String {
if tag == "iframe" {
block = parseVideo(item, &media)
}
}
}
} else if tag == "iframe" {
block = parseVideo(item, &media)
} else if tag == "img" {
block = parseImage(item, &media)
} else if tag == "figcaption" {
caption = trim(parseRichText(item, &media))
}
}
}
guard var block else {
return nil
}
if let caption, case let .image(id, _, url, webpageId) = block {
block = .image(id: id, caption: InstantPageCaption(text: caption, credit: .empty), url: url, webpageId: webpageId)
}
return block
}
private func parsePageBlocks(_ input: [Any], _ url: String, _ media: inout [MediaId: Media]) -> [InstantPageBlock] {
var result: [InstantPageBlock] = []
for item in input {
if let string = item as? String {
result.append(.paragraph(trim(parseRichText(string))))
} else if let item = item as? [String: Any], let tag = item["tag"] as? String {
let content = item["content"] as? [Any]
switch tag {
case "p":
result.append(.paragraph(trim(parseRichText(item, &media))))
case "h1", "h2":
result.append(.header(trim(parseRichText(item, &media))))
case "h3", "h4", "h5", "h6":
result.append(.subheader(trim(parseRichText(item, &media))))
case "pre":
result.append(.preformatted(.fixed(trim(parseRichText(item, &media)))))
case "blockquote":
result.append(.blockQuote(text: .italic(trim(parseRichText(item, &media))), caption: .empty))
case "img":
if let image = parseImage(item, &media) {
result.append(image)
}
case "iframe":
if let video = parseVideo(item, &media) {
result.append(video)
}
case "figure":
if let figure = parseFigure(item, &media) {
result.append(figure)
}
case "table":
result.append(parseTable(item, &media))
case "ul", "ol":
if let list = parseList(item, url, &media) {
result.append(list)
}
case "hr":
result.append(.divider)
case "details":
if let details = parseDetails(item, url, &media) {
result.append(details)
}
default:
if let content {
result.append(contentsOf: parsePageBlocks(content, url, &media))
}
}
}
}
return result
}
@@ -0,0 +1,88 @@
import Foundation
import Postbox
import TelegramCore
import SwiftSignalKit
import TelegramUIPreferences
private struct RecentlyVisitedLinkItemId {
public let rawValue: MemoryBuffer
var value: String {
return String(data: self.rawValue.makeData(), encoding: .utf8) ?? ""
}
init(_ rawValue: MemoryBuffer) {
self.rawValue = rawValue
}
init?(_ value: String) {
if let data = value.data(using: .utf8) {
self.rawValue = MemoryBuffer(data: data)
} else {
return nil
}
}
}
public final class RecentVisitedLinkItem: Codable {
private enum CodingKeys: String, CodingKey {
case webPage
}
public let webPage: TelegramMediaWebpage
public init(webPage: TelegramMediaWebpage) {
self.webPage = webPage
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
if let webPageData = try container.decodeIfPresent(Data.self, forKey: .webPage) {
self.webPage = PostboxDecoder(buffer: MemoryBuffer(data: webPageData)).decodeRootObject() as! TelegramMediaWebpage
} else {
fatalError()
}
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
let encoder = PostboxEncoder()
encoder.encodeRootObject(self.webPage)
let webPageData = encoder.makeData()
try container.encode(webPageData, forKey: .webPage)
}
}
func addRecentlyVisitedLink(engine: TelegramEngine, webPage: TelegramMediaWebpage) -> Signal<Never, NoError> {
if let url = webPage.content.url, let itemId = RecentlyVisitedLinkItemId(url) {
return engine.orderedLists.addOrMoveToFirstPosition(collectionId: ApplicationSpecificOrderedItemListCollectionId.browserRecentlyVisited, id: itemId.rawValue, item: RecentVisitedLinkItem(webPage: webPage), removeTailIfCountExceeds: 10)
} else {
return .complete()
}
}
func removeRecentlyVisitedLink(engine: TelegramEngine, url: String) -> Signal<Never, NoError> {
if let itemId = RecentlyVisitedLinkItemId(url) {
return engine.orderedLists.removeItem(collectionId: ApplicationSpecificOrderedItemListCollectionId.browserRecentlyVisited, id: itemId.rawValue)
} else {
return .complete()
}
}
func clearRecentlyVisitedLinks(engine: TelegramEngine) -> Signal<Never, NoError> {
return engine.orderedLists.clear(collectionId: ApplicationSpecificOrderedItemListCollectionId.browserRecentlyVisited)
}
func recentlyVisitedLinks(engine: TelegramEngine) -> Signal<[TelegramMediaWebpage], NoError> {
return engine.data.subscribe(TelegramEngine.EngineData.Item.OrderedLists.ListItems(collectionId: ApplicationSpecificOrderedItemListCollectionId.browserRecentlyVisited))
|> map { items -> [TelegramMediaWebpage] in
var result: [TelegramMediaWebpage] = []
for item in items {
if let link = item.contents.get(RecentVisitedLinkItem.self) {
result.append(link.webPage)
}
}
return result
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,365 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import SwiftSignalKit
import ComponentFlow
import TelegramPresentationData
import AccountContext
import BundleIconComponent
final class SearchBarContentComponent: Component {
public typealias EnvironmentType = BrowserNavigationBarEnvironment
let theme: PresentationTheme
let strings: PresentationStrings
let performAction: ActionSlot<BrowserScreen.Action>
init(
theme: PresentationTheme,
strings: PresentationStrings,
performAction: ActionSlot<BrowserScreen.Action>
) {
self.theme = theme
self.strings = strings
self.performAction = performAction
}
static func ==(lhs: SearchBarContentComponent, rhs: SearchBarContentComponent) -> Bool {
if lhs.theme !== rhs.theme {
return false
}
if lhs.strings !== rhs.strings {
return false
}
return true
}
final class View: UIView, UITextFieldDelegate {
private final class SearchTextField: UITextField {
override func textRect(forBounds bounds: CGRect) -> CGRect {
return bounds.integral
}
}
private struct Params: Equatable {
var theme: PresentationTheme
var strings: PresentationStrings
var size: CGSize
static func ==(lhs: Params, rhs: Params) -> Bool {
if lhs.theme !== rhs.theme {
return false
}
if lhs.strings !== rhs.strings {
return false
}
if lhs.size != rhs.size {
return false
}
return true
}
}
private let queryPromise = ValuePromise<String>()
private var queryDisposable: Disposable?
private let backgroundLayer: SimpleLayer
private let iconView: UIImageView
private let clearIconView: UIImageView
private let clearIconButton: HighlightTrackingButton
private let cancelButtonTitle: ComponentView<Empty>
private let cancelButton: HighlightTrackingButton
private var placeholderContent = ComponentView<Empty>()
private var textFrame: CGRect?
private var textField: SearchTextField?
private var tapRecognizer: UITapGestureRecognizer?
private var params: Params?
private var component: SearchBarContentComponent?
init() {
self.backgroundLayer = SimpleLayer()
self.iconView = UIImageView()
self.clearIconView = UIImageView()
self.clearIconButton = HighlightableButton()
self.clearIconView.isHidden = true
self.clearIconButton.isHidden = true
self.cancelButtonTitle = ComponentView()
self.cancelButton = HighlightTrackingButton()
super.init(frame: CGRect())
self.layer.addSublayer(self.backgroundLayer)
self.addSubview(self.iconView)
self.addSubview(self.clearIconView)
self.addSubview(self.clearIconButton)
self.addSubview(self.cancelButton)
self.clipsToBounds = true
let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:)))
self.tapRecognizer = tapRecognizer
self.addGestureRecognizer(tapRecognizer)
self.cancelButton.highligthedChanged = { [weak self] highlighted in
if let strongSelf = self {
if highlighted {
if let cancelButtonTitleView = strongSelf.cancelButtonTitle.view {
cancelButtonTitleView.layer.removeAnimation(forKey: "opacity")
cancelButtonTitleView.alpha = 0.4
}
} else {
if let cancelButtonTitleView = strongSelf.cancelButtonTitle.view {
cancelButtonTitleView.alpha = 1.0
cancelButtonTitleView.layer.animateAlpha(from: 0.4, to: 1.0, duration: 0.2)
}
}
}
}
self.cancelButton.addTarget(self, action: #selector(self.cancelPressed), for: .touchUpInside)
self.clearIconButton.highligthedChanged = { [weak self] highlighted in
if let strongSelf = self {
if highlighted {
strongSelf.clearIconView.layer.removeAnimation(forKey: "opacity")
strongSelf.clearIconView.alpha = 0.4
} else {
strongSelf.clearIconView.alpha = 1.0
strongSelf.clearIconView.layer.animateAlpha(from: 0.4, to: 1.0, duration: 0.2)
}
}
}
self.clearIconButton.addTarget(self, action: #selector(self.clearPressed), for: .touchUpInside)
let throttledSearchQuery = self.queryPromise.get()
|> mapToSignal { query -> Signal<String, NoError> in
if !query.isEmpty {
return (.complete() |> delay(0.6, queue: Queue.mainQueue()))
|> then(.single(query))
} else {
return .single(query)
}
}
self.queryDisposable = (throttledSearchQuery
|> deliverOnMainQueue).start(next: { [weak self] query in
if let self {
self.component?.performAction.invoke(.updateSearchQuery(query))
}
})
}
required public init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@objc private func tapGesture(_ recognizer: UITapGestureRecognizer) {
if case .ended = recognizer.state {
self.activateTextInput()
}
}
private func activateTextInput() {
if self.textField == nil, let textFrame = self.textFrame {
let backgroundFrame = self.backgroundLayer.frame
let textFieldFrame = CGRect(origin: CGPoint(x: textFrame.minX, y: backgroundFrame.minY), size: CGSize(width: backgroundFrame.maxX - textFrame.minX - 32.0, height: backgroundFrame.height))
let textField = SearchTextField(frame: textFieldFrame)
textField.clipsToBounds = true
textField.autocorrectionType = .no
textField.returnKeyType = .search
self.textField = textField
self.insertSubview(textField, belowSubview: self.clearIconView)
textField.delegate = self
textField.addTarget(self, action: #selector(self.textFieldChanged(_:)), for: .editingChanged)
}
guard !(self.textField?.isFirstResponder ?? false) else {
return
}
self.textField?.becomeFirstResponder()
}
@objc private func cancelPressed() {
self.clearIconView.isHidden = true
self.clearIconButton.isHidden = true
let textField = self.textField
self.textField = nil
self.component?.performAction.invoke(.updateSearchActive(false))
if let textField {
textField.resignFirstResponder()
textField.removeFromSuperview()
}
}
@objc private func clearPressed() {
guard let textField = self.textField else {
return
}
textField.text = ""
self.textFieldChanged(textField)
}
func deactivate() {
if let text = self.textField?.text, !text.isEmpty {
self.textField?.endEditing(true)
} else {
self.cancelPressed()
}
}
public func textFieldDidBeginEditing(_ textField: UITextField) {
}
public func textFieldDidEndEditing(_ textField: UITextField) {
}
public func textFieldShouldReturn(_ textField: UITextField) -> Bool {
textField.endEditing(true)
return false
}
@objc private func textFieldChanged(_ textField: UITextField) {
let text = textField.text ?? ""
self.clearIconView.isHidden = text.isEmpty
self.clearIconButton.isHidden = text.isEmpty
self.placeholderContent.view?.isHidden = !text.isEmpty
self.queryPromise.set(text)
if let params = self.params {
self.update(theme: params.theme, strings: params.strings, size: params.size, transition: .immediate)
}
}
func update(component: SearchBarContentComponent, availableSize: CGSize, transition: ComponentTransition) -> CGSize {
self.component = component
self.update(theme: component.theme, strings: component.strings, size: availableSize, transition: transition)
self.activateTextInput()
return availableSize
}
public func update(theme: PresentationTheme, strings: PresentationStrings, size: CGSize, transition: ComponentTransition) {
let params = Params(
theme: theme,
strings: strings,
size: size
)
if self.params == params {
return
}
let isActiveWithText = true
if self.params?.theme !== theme {
self.iconView.image = generateTintedImage(image: UIImage(bundleImageName: "Components/Search Bar/Loupe"), color: .white)?.withRenderingMode(.alwaysTemplate)
self.iconView.tintColor = theme.rootController.navigationSearchBar.inputIconColor
self.clearIconView.image = generateTintedImage(image: UIImage(bundleImageName: "Components/Search Bar/Clear"), color: .white)?.withRenderingMode(.alwaysTemplate)
self.clearIconView.tintColor = theme.rootController.navigationSearchBar.inputClearButtonColor
}
self.params = params
let sideInset: CGFloat = 10.0
let inputHeight: CGFloat = 36.0
let topInset: CGFloat = (size.height - inputHeight) / 2.0
let sideTextInset: CGFloat = sideInset + 4.0 + 17.0
self.backgroundLayer.backgroundColor = theme.rootController.navigationSearchBar.inputFillColor.cgColor
self.backgroundLayer.cornerRadius = 10.5
let cancelTextSize = self.cancelButtonTitle.update(
transition: .immediate,
component: AnyComponent(Text(
text: strings.Common_Cancel,
font: Font.regular(17.0),
color: theme.rootController.navigationBar.accentTextColor
)),
environment: {},
containerSize: CGSize(width: size.width - 32.0, height: 100.0)
)
let cancelButtonSpacing: CGFloat = 8.0
var backgroundFrame = CGRect(origin: CGPoint(x: sideInset, y: topInset), size: CGSize(width: size.width - sideInset * 2.0, height: inputHeight))
if isActiveWithText {
backgroundFrame.size.width -= cancelTextSize.width + cancelButtonSpacing
}
transition.setFrame(layer: self.backgroundLayer, frame: backgroundFrame)
transition.setFrame(view: self.cancelButton, frame: CGRect(origin: CGPoint(x: backgroundFrame.maxX, y: 0.0), size: CGSize(width: cancelButtonSpacing + cancelTextSize.width, height: size.height)))
let textX: CGFloat = backgroundFrame.minX + sideTextInset
let textFrame = CGRect(origin: CGPoint(x: textX, y: backgroundFrame.minY), size: CGSize(width: backgroundFrame.maxX - textX, height: backgroundFrame.height))
self.textFrame = textFrame
if let image = self.iconView.image {
let iconFrame = CGRect(origin: CGPoint(x: backgroundFrame.minX + 5.0, y: backgroundFrame.minY + floor((backgroundFrame.height - image.size.height) / 2.0)), size: image.size)
transition.setFrame(view: self.iconView, frame: iconFrame)
}
let placeholderSize = self.placeholderContent.update(
transition: transition,
component: AnyComponent(
Text(text: strings.Common_Search, font: Font.regular(17.0), color: theme.rootController.navigationSearchBar.inputPlaceholderTextColor)
),
environment: {},
containerSize: size
)
if let placeholderContentView = self.placeholderContent.view {
if placeholderContentView.superview == nil {
self.addSubview(placeholderContentView)
}
let placeholderContentFrame = CGRect(origin: CGPoint(x: textFrame.minX, y: backgroundFrame.midY - placeholderSize.height / 2.0), size: placeholderSize)
transition.setFrame(view: placeholderContentView, frame: placeholderContentFrame)
}
if let image = self.clearIconView.image {
let iconFrame = CGRect(origin: CGPoint(x: backgroundFrame.maxX - image.size.width - 4.0, y: backgroundFrame.minY + floor((backgroundFrame.height - image.size.height) / 2.0)), size: image.size)
transition.setFrame(view: self.clearIconView, frame: iconFrame)
transition.setFrame(view: self.clearIconButton, frame: iconFrame.insetBy(dx: -8.0, dy: -10.0))
}
if let cancelButtonTitleComponentView = self.cancelButtonTitle.view {
if cancelButtonTitleComponentView.superview == nil {
self.addSubview(cancelButtonTitleComponentView)
cancelButtonTitleComponentView.isUserInteractionEnabled = false
}
transition.setFrame(view: cancelButtonTitleComponentView, frame: CGRect(origin: CGPoint(x: backgroundFrame.maxX + cancelButtonSpacing, y: floor((size.height - cancelTextSize.height) / 2.0)), size: cancelTextSize))
}
if let textField = self.textField {
textField.textColor = theme.rootController.navigationSearchBar.inputTextColor
transition.setFrame(view: textField, frame: CGRect(origin: CGPoint(x: backgroundFrame.minX + sideTextInset, y: backgroundFrame.minY - UIScreenPixel), size: CGSize(width: backgroundFrame.width - sideTextInset - 32.0, height: backgroundFrame.height)))
}
}
}
func makeView() -> View {
return View()
}
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<BrowserNavigationBarEnvironment>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, transition: transition)
}
}
@@ -0,0 +1,85 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import ComponentFlow
import SwiftSignalKit
import TelegramPresentationData
import AccountContext
import BundleIconComponent
import MultilineTextComponent
import UrlEscaping
final class TitleBarContentComponent: Component {
public typealias EnvironmentType = BrowserNavigationBarEnvironment
let theme: PresentationTheme
let title: String
init(
theme: PresentationTheme,
title: String
) {
self.theme = theme
self.title = title
}
static func ==(lhs: TitleBarContentComponent, rhs: TitleBarContentComponent) -> Bool {
if lhs.theme !== rhs.theme {
return false
}
if lhs.title != rhs.title {
return false
}
return true
}
final class View: UIView {
private var titleContent = ComponentView<Empty>()
private var component: TitleBarContentComponent?
init() {
super.init(frame: CGRect())
}
required public init?(coder: NSCoder) {
fatalError()
}
func update(component: TitleBarContentComponent, availableSize: CGSize, environment: Environment<BrowserNavigationBarEnvironment>, transition: ComponentTransition) -> CGSize {
self.component = component
let titleSize = self.titleContent.update(
transition: transition,
component: AnyComponent(
MultilineTextComponent(
text: .plain(NSAttributedString(string: component.title, font: Font.semibold(17.0), textColor: component.theme.rootController.navigationBar.primaryTextColor)),
horizontalAlignment: .center,
truncationType: .end,
maximumNumberOfLines: 1
)
),
environment: {},
containerSize: CGSize(width: availableSize.width - 36.0, height: availableSize.height)
)
let titleContentFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((availableSize.width - titleSize.width) / 2.0), y: floorToScreenPixels((availableSize.height - titleSize.height) / 2.0)), size: titleSize)
if let titleContentView = self.titleContent.view {
if titleContentView.superview == nil {
self.addSubview(titleContentView)
}
transition.setPosition(view: titleContentView, position: titleContentFrame.center)
titleContentView.bounds = CGRect(origin: .zero, size: titleContentFrame.size)
}
return availableSize
}
}
func makeView() -> View {
return View()
}
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<BrowserNavigationBarEnvironment>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, environment: environment, transition: transition)
}
}
@@ -0,0 +1,501 @@
import Foundation
import UIKit
import Display
import ComponentFlow
import BlurredBackgroundComponent
import BundleIconComponent
import TelegramPresentationData
import ContextReferenceButtonComponent
final class BrowserToolbarComponent: CombinedComponent {
let backgroundColor: UIColor
let separatorColor: UIColor
let textColor: UIColor
let bottomInset: CGFloat
let sideInset: CGFloat
let item: AnyComponentWithIdentity<Empty>?
let collapseFraction: CGFloat
init(
backgroundColor: UIColor,
separatorColor: UIColor,
textColor: UIColor,
bottomInset: CGFloat,
sideInset: CGFloat,
item: AnyComponentWithIdentity<Empty>?,
collapseFraction: CGFloat
) {
self.backgroundColor = backgroundColor
self.separatorColor = separatorColor
self.textColor = textColor
self.bottomInset = bottomInset
self.sideInset = sideInset
self.item = item
self.collapseFraction = collapseFraction
}
static func ==(lhs: BrowserToolbarComponent, rhs: BrowserToolbarComponent) -> Bool {
if lhs.backgroundColor != rhs.backgroundColor {
return false
}
if lhs.separatorColor != rhs.separatorColor {
return false
}
if lhs.textColor != rhs.textColor {
return false
}
if lhs.bottomInset != rhs.bottomInset {
return false
}
if lhs.sideInset != rhs.sideInset {
return false
}
if lhs.item != rhs.item {
return false
}
if lhs.collapseFraction != rhs.collapseFraction {
return false
}
return true
}
static var body: Body {
let background = Child(BlurredBackgroundComponent.self)
let separator = Child(Rectangle.self)
let centerItems = ChildMap(environment: Empty.self, keyedBy: AnyHashable.self)
return { context in
let contentHeight: CGFloat = 49.0
let totalHeight = contentHeight + context.component.bottomInset
let offset = context.component.collapseFraction * totalHeight
let size = CGSize(width: context.availableSize.width, height: totalHeight)
let background = background.update(
component: BlurredBackgroundComponent(color: context.component.backgroundColor),
availableSize: CGSize(width: size.width, height: size.height),
transition: context.transition
)
let separator = separator.update(
component: Rectangle(color: context.component.separatorColor, height: UIScreenPixel),
availableSize: CGSize(width: size.width, height: size.height),
transition: context.transition
)
let item = context.component.item.flatMap { item in
return centerItems[item.id].update(
component: item.component,
availableSize: CGSize(width: context.availableSize.width - context.component.sideInset * 2.0, height: contentHeight),
transition: context.transition
)
}
context.add(background
.position(CGPoint(x: size.width / 2.0, y: size.height / 2.0 + offset))
)
context.add(separator
.position(CGPoint(x: size.width / 2.0, y: 0.0 + offset))
)
if let centerItem = item {
context.add(centerItem
.position(CGPoint(x: context.availableSize.width / 2.0, y: contentHeight / 2.0 + offset))
.appear(ComponentTransition.Appear({ _, view, transition in
transition.animatePosition(view: view, from: CGPoint(x: 0.0, y: size.height), to: .zero, additive: true)
}))
.disappear(ComponentTransition.Disappear({ view, transition, completion in
let from = view.center
view.center = from.offsetBy(dx: 0.0, dy: size.height)
transition.animatePosition(view: view, from: from, to: view.center, completion: { _ in
completion()
})
}))
)
}
return size
}
}
}
final class NavigationToolbarContentComponent: CombinedComponent {
let accentColor: UIColor
let textColor: UIColor
let canGoBack: Bool
let canGoForward: Bool
let canOpenIn: Bool
let canShare: Bool
let isDocument: Bool
let performAction: ActionSlot<BrowserScreen.Action>
let performHoldAction: (UIView, ContextGesture?, BrowserScreen.Action) -> Void
init(
accentColor: UIColor,
textColor: UIColor,
canGoBack: Bool,
canGoForward: Bool,
canOpenIn: Bool,
canShare: Bool,
isDocument: Bool,
performAction: ActionSlot<BrowserScreen.Action>,
performHoldAction: @escaping (UIView, ContextGesture?, BrowserScreen.Action) -> Void
) {
self.accentColor = accentColor
self.textColor = textColor
self.canGoBack = canGoBack
self.canGoForward = canGoForward
self.canOpenIn = canOpenIn
self.canShare = canShare
self.isDocument = isDocument
self.performAction = performAction
self.performHoldAction = performHoldAction
}
static func ==(lhs: NavigationToolbarContentComponent, rhs: NavigationToolbarContentComponent) -> Bool {
if lhs.accentColor != rhs.accentColor {
return false
}
if lhs.textColor != rhs.textColor {
return false
}
if lhs.canGoBack != rhs.canGoBack {
return false
}
if lhs.canGoForward != rhs.canGoForward {
return false
}
if lhs.canOpenIn != rhs.canOpenIn {
return false
}
if lhs.canShare != rhs.canShare {
return false
}
if lhs.isDocument != rhs.isDocument {
return false
}
return true
}
static var body: Body {
let back = Child(ContextReferenceButtonComponent.self)
let forward = Child(ContextReferenceButtonComponent.self)
let share = Child(Button.self)
let bookmark = Child(Button.self)
let openIn = Child(Button.self)
let search = Child(Button.self)
let quickLook = Child(Button.self)
return { context in
let availableSize = context.availableSize
let performAction = context.component.performAction
let performHoldAction = context.component.performHoldAction
let sideInset: CGFloat = 5.0
let buttonSize = CGSize(width: 50.0, height: availableSize.height)
var buttonCount = 3
if context.component.canShare {
buttonCount += 1
}
if context.component.canOpenIn {
buttonCount += 1
}
let spacing = (availableSize.width - buttonSize.width * CGFloat(buttonCount) - sideInset * 2.0) / CGFloat(buttonCount - 1)
let canShare = context.component.canShare
let share = share.update(
component: Button(
content: AnyComponent(
BundleIconComponent(
name: "Chat List/NavigationShare",
tintColor: context.component.accentColor
)
),
action: {
if canShare {
performAction.invoke(.share)
}
}
).minSize(buttonSize),
availableSize: buttonSize,
transition: .easeInOut(duration: 0.2)
)
if context.component.isDocument {
if !context.component.canShare {
context.add(share
.position(CGPoint(x: availableSize.width / 2.0, y: 10000.0))
)
} else {
context.add(share
.position(CGPoint(x: availableSize.width / 2.0, y: availableSize.height / 2.0))
)
}
let search = search.update(
component: Button(
content: AnyComponent(
BundleIconComponent(
name: "Chat List/SearchIcon",
tintColor: context.component.accentColor
)
),
action: {
performAction.invoke(.updateSearchActive(true))
}
).minSize(buttonSize),
availableSize: buttonSize,
transition: .easeInOut(duration: 0.2)
)
context.add(search
.position(CGPoint(x: sideInset + search.size.width / 2.0, y: availableSize.height / 2.0))
)
let quickLook = quickLook.update(
component: Button(
content: AnyComponent(
BundleIconComponent(
name: "Instant View/OpenDocument",
tintColor: context.component.accentColor
)
),
action: {
performAction.invoke(.openIn)
}
).minSize(buttonSize),
availableSize: buttonSize,
transition: .easeInOut(duration: 0.2)
)
context.add(quickLook
.position(CGPoint(x: context.availableSize.width - sideInset - quickLook.size.width / 2.0, y: availableSize.height / 2.0))
)
} else {
let canGoBack = context.component.canGoBack
let back = back.update(
component: ContextReferenceButtonComponent(
content: AnyComponent(
BundleIconComponent(
name: "Instant View/Back",
tintColor: canGoBack ? context.component.accentColor : context.component.accentColor.withAlphaComponent(0.4)
)
),
minSize: buttonSize,
action: { view, gesture in
guard canGoBack else {
return
}
if let gesture {
performHoldAction(view, gesture, .navigateBack)
} else {
performAction.invoke(.navigateBack)
}
}
),
availableSize: buttonSize,
transition: .easeInOut(duration: 0.2)
)
context.add(back
.position(CGPoint(x: sideInset + back.size.width / 2.0, y: availableSize.height / 2.0))
)
let canGoForward = context.component.canGoForward
let forward = forward.update(
component: ContextReferenceButtonComponent(
content: AnyComponent(
BundleIconComponent(
name: "Instant View/Forward",
tintColor: canGoForward ? context.component.accentColor : context.component.accentColor.withAlphaComponent(0.4)
)
),
minSize: buttonSize,
action: { view, gesture in
guard canGoForward else {
return
}
if let gesture {
performHoldAction(view, gesture, .navigateForward)
} else {
performAction.invoke(.navigateForward)
}
}
),
availableSize: buttonSize,
transition: .easeInOut(duration: 0.2)
)
context.add(forward
.position(CGPoint(x: sideInset + back.size.width + spacing + forward.size.width / 2.0, y: availableSize.height / 2.0))
)
context.add(share
.position(CGPoint(x: sideInset + back.size.width + spacing + forward.size.width + spacing + share.size.width / 2.0, y: availableSize.height / 2.0))
)
let bookmark = bookmark.update(
component: Button(
content: AnyComponent(
BundleIconComponent(
name: "Instant View/Bookmark",
tintColor: context.component.accentColor
)
),
action: {
performAction.invoke(.openBookmarks)
}
).minSize(buttonSize),
availableSize: buttonSize,
transition: .easeInOut(duration: 0.2)
)
context.add(bookmark
.position(CGPoint(x: sideInset + back.size.width + spacing + forward.size.width + spacing + share.size.width + spacing + bookmark.size.width / 2.0, y: availableSize.height / 2.0))
)
if context.component.canOpenIn {
let openIn = openIn.update(
component: Button(
content: AnyComponent(
BundleIconComponent(
name: "Instant View/Browser",
tintColor: context.component.accentColor
)
),
action: {
performAction.invoke(.openIn)
}
).minSize(buttonSize),
availableSize: buttonSize,
transition: .easeInOut(duration: 0.2)
)
context.add(openIn
.position(CGPoint(x: sideInset + back.size.width + spacing + forward.size.width + spacing + share.size.width + spacing + bookmark.size.width + spacing + openIn.size.width / 2.0, y: availableSize.height / 2.0))
)
}
}
return availableSize
}
}
}
final class SearchToolbarContentComponent: CombinedComponent {
let strings: PresentationStrings
let textColor: UIColor
let index: Int
let count: Int
let isEmpty: Bool
let performAction: ActionSlot<BrowserScreen.Action>
init(
strings: PresentationStrings,
textColor: UIColor,
index: Int,
count: Int,
isEmpty: Bool,
performAction: ActionSlot<BrowserScreen.Action>
) {
self.strings = strings
self.textColor = textColor
self.index = index
self.count = count
self.isEmpty = isEmpty
self.performAction = performAction
}
static func ==(lhs: SearchToolbarContentComponent, rhs: SearchToolbarContentComponent) -> Bool {
if lhs.strings !== rhs.strings {
return false
}
if lhs.textColor != rhs.textColor {
return false
}
if lhs.index != rhs.index {
return false
}
if lhs.count != rhs.count {
return false
}
if lhs.isEmpty != rhs.isEmpty {
return false
}
return true
}
static var body: Body {
let down = Child(Button.self)
let up = Child(Button.self)
let text = Child(Text.self)
return { context in
let availableSize = context.availableSize
let performAction = context.component.performAction
let sideInset: CGFloat = 3.0
let buttonSize = CGSize(width: 50.0, height: availableSize.height)
let down = down.update(
component: Button(
content: AnyComponent(
BundleIconComponent(
name: "Chat/Input/Search/DownButton",
tintColor: context.component.textColor
)
),
isEnabled: context.component.count > 0,
action: {
performAction.invoke(.scrollToNextSearchResult)
}
).minSize(buttonSize),
availableSize: buttonSize,
transition: .easeInOut(duration: 0.2)
)
context.add(down
.position(CGPoint(x: availableSize.width - sideInset - down.size.width / 2.0, y: availableSize.height / 2.0))
)
let up = up.update(
component: Button(
content: AnyComponent(
BundleIconComponent(
name: "Chat/Input/Search/UpButton",
tintColor: context.component.textColor
)
),
isEnabled: context.component.count > 0,
action: {
performAction.invoke(.scrollToPreviousSearchResult)
}
).minSize(buttonSize),
availableSize: buttonSize,
transition: .easeInOut(duration: 0.2)
)
context.add(up
.position(CGPoint(x: availableSize.width - sideInset - down.size.width + 7.0 - up.size.width / 2.0, y: availableSize.height / 2.0))
)
let currentText: String
if context.component.isEmpty {
currentText = ""
} else if context.component.count == 0 {
currentText = context.component.strings.Conversation_SearchNoResults
} else {
currentText = context.component.strings.Items_NOfM("\(context.component.index + 1)", "\(context.component.count)").string
}
let text = text.update(
component: Text(
text: currentText,
font: Font.regular(15.0),
color: context.component.textColor
),
availableSize: availableSize,
transition: .easeInOut(duration: 0.2)
)
context.add(text
.position(CGPoint(x: availableSize.width - sideInset - down.size.width - up.size.width - text.size.width / 2.0, y: availableSize.height / 2.0))
)
return availableSize
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,174 @@
import Foundation
import UIKit
import Display
import ComponentFlow
import TelegramPresentationData
import MultilineTextComponent
final class SectionHeaderComponent: Component {
enum Style {
case blocks
case plain
}
let theme: PresentationTheme
let style: Style
let title: String
let insets: UIEdgeInsets
let actionTitle: String?
let action: (() -> Void)?
init(
theme: PresentationTheme,
style: Style,
title: String,
insets: UIEdgeInsets,
actionTitle: String?,
action: (() -> Void)?
) {
self.theme = theme
self.style = style
self.title = title
self.insets = insets
self.actionTitle = actionTitle
self.action = action
}
static func ==(lhs: SectionHeaderComponent, rhs: SectionHeaderComponent) -> Bool {
if lhs.theme !== rhs.theme {
return false
}
if lhs.style != rhs.style {
return false
}
if lhs.title != rhs.title {
return false
}
if lhs.insets != rhs.insets {
return false
}
if lhs.actionTitle != rhs.actionTitle {
return false
}
return true
}
final class View: UIView {
private let title = ComponentView<Empty>()
private let backgroundView: BlurredBackgroundView
private let action = ComponentView<Empty>()
private var component: SectionHeaderComponent?
private weak var state: EmptyComponentState?
override init(frame: CGRect) {
self.backgroundView = BlurredBackgroundView(color: .clear, enableBlur: true)
super.init(frame: frame)
self.addSubview(self.backgroundView)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func update(component: SectionHeaderComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
let themeUpdated = self.component?.theme !== component.theme
self.component = component
self.state = state
let height: CGFloat = 28.0
let leftInset: CGFloat = 16.0 + component.insets.left
let rightInset: CGFloat = 0.0
let previousTitleFrame = self.title.view?.frame
if themeUpdated {
switch component.style {
case .plain:
self.backgroundView.isHidden = false
self.backgroundView.updateColor(color: component.theme.rootController.navigationBar.blurredBackgroundColor, transition: .immediate)
case .blocks:
self.backgroundView.isHidden = true
}
}
let titleSize = self.title.update(
transition: .immediate,
component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(string: component.title, font: Font.regular(13.0), textColor: component.theme.list.itemSecondaryTextColor))
)),
environment: {},
containerSize: CGSize(width: availableSize.width - leftInset - rightInset, height: 100.0)
)
let titleFrame = CGRect(origin: CGPoint(x: leftInset, y: floor((height - titleSize.height) / 2.0)), size: titleSize)
if let titleView = self.title.view {
if titleView.superview == nil {
titleView.isUserInteractionEnabled = false
self.addSubview(titleView)
}
titleView.frame = titleFrame
if let previousTitleFrame, previousTitleFrame.origin.x != titleFrame.origin.x {
transition.animatePosition(view: titleView, from: CGPoint(x: previousTitleFrame.origin.x - titleFrame.origin.x, y: 0.0), to: CGPoint(), additive: true)
}
}
if let actionTitle = component.actionTitle {
let actionSize = self.action.update(
transition: .immediate,
component: AnyComponent(
Button(content: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(string: actionTitle, font: Font.regular(13.0), textColor: component.theme.list.itemSecondaryTextColor))
)), action: { [weak self] in
if let self, let component = self.component {
component.action?()
}
})
),
environment: {},
containerSize: CGSize(width: availableSize.width - leftInset - rightInset, height: 100.0)
)
if let view = self.action.view {
if view.superview == nil {
self.addSubview(view)
if !transition.animation.isImmediate {
view.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
view.layer.animateScale(from: 0.01, to: 1.0, duration: 0.2)
}
}
let actionFrame = CGRect(origin: CGPoint(x: availableSize.width - leftInset - actionSize.width, y: floor((height - titleSize.height) / 2.0)), size: actionSize)
view.frame = actionFrame
}
} else if let view = self.action.view, view.superview != nil {
if !transition.animation.isImmediate {
view.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { finished in
if finished {
view.removeFromSuperview()
view.layer.removeAllAnimations()
}
})
view.layer.animateScale(from: 1.0, to: 0.01, duration: 0.2, removeOnCompletion: false)
} else {
view.removeFromSuperview()
}
}
let size = CGSize(width: availableSize.width, height: height)
transition.setFrame(view: self.backgroundView, frame: CGRect(origin: CGPoint(), size: size))
self.backgroundView.update(size: size, transition: transition.containedViewLayoutTransition)
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)
}
}
+154
View File
@@ -0,0 +1,154 @@
import Foundation
import UIKit
import Display
import SwiftSignalKit
import Postbox
import TelegramCore
import AccountContext
import TextFormat
import UrlWhitelist
import Svg
private var faviconCache: [String: UIImage] = [:]
func fetchFavicon(context: AccountContext, url: String, size: CGSize) -> Signal<UIImage?, NoError> {
if let icon = faviconCache[url] {
return .single(icon)
}
return context.engine.resources.httpData(url: url)
|> map(Optional.init)
|> `catch` { _ -> Signal<Data?, NoError> in
return .single(nil)
}
|> map { data in
if let data {
if let image = UIImage(data: data) {
return image
} else if url.lowercased().contains(".svg"), let preparedData = prepareSvgImage(data, false), let image = renderPreparedImage(preparedData, size, .clear, UIScreenScale, false) {
return image
}
return nil
} else {
return nil
}
}
|> beforeNext { image in
if let image {
Queue.mainQueue().async {
faviconCache[url] = image
}
}
}
}
func getPrimaryUrl(message: Message) -> String? {
var primaryUrl: String?
if let webPage = message.media.first(where: { $0 is TelegramMediaWebpage }) as? TelegramMediaWebpage, let url = webPage.content.url {
primaryUrl = url
} else {
var entities = message.textEntitiesAttribute?.entities
if entities == nil {
let parsedEntities = generateTextEntities(message.text, enabledTypes: .all)
if !parsedEntities.isEmpty {
entities = parsedEntities
}
}
if let entities {
loop: for entity in entities {
switch entity.type {
case .Url, .Email:
var range = NSRange(location: entity.range.lowerBound, length: entity.range.upperBound - entity.range.lowerBound)
let nsString = message.text as NSString
if range.location + range.length > nsString.length {
range.location = max(0, nsString.length - range.length)
range.length = nsString.length - range.location
}
let tempUrlString = nsString.substring(with: range)
var (urlString, concealed) = parseUrl(url: tempUrlString, wasConcealed: false)
var parsedUrl = URL(string: urlString)
if (parsedUrl == nil || parsedUrl!.host == nil || parsedUrl!.host!.isEmpty) && !urlString.contains("@") {
urlString = "http://" + urlString
parsedUrl = URL(string: urlString)
}
var host: String? = concealed ? urlString : parsedUrl?.host
if host == nil {
host = urlString
}
if let _ = parsedUrl, let _ = host {
primaryUrl = urlString
}
break loop
case let .TextUrl(url):
let messageText = message.text
var range = NSRange(location: entity.range.lowerBound, length: entity.range.upperBound - entity.range.lowerBound)
let nsString = messageText as NSString
if range.location + range.length > nsString.length {
range.location = max(0, nsString.length - range.length)
range.length = nsString.length - range.location
}
var (urlString, concealed) = parseUrl(url: url, wasConcealed: false)
var parsedUrl = URL(string: urlString)
if (parsedUrl == nil || parsedUrl!.host == nil || parsedUrl!.host!.isEmpty) && !urlString.contains("@") {
urlString = "http://" + urlString
parsedUrl = URL(string: urlString)
}
let host: String? = concealed ? urlString : parsedUrl?.host
if let _ = parsedUrl, let _ = host {
primaryUrl = urlString
}
break loop
default:
break
}
}
}
}
return primaryUrl
}
private let asciiChars = CharacterSet(charactersIn: "a".unicodeScalars.first! ... "z".unicodeScalars.first!)
func getDisplayUrl(_ url: String, hostOnly: Bool = false, trim: Bool = true) -> String {
if hostOnly {
var title = url
if let parsedUrl = URL(string: url) {
title = parsedUrl.host ?? url
if title.hasPrefix("www.") {
title.removeSubrange(title.startIndex ..< title.index(title.startIndex, offsetBy: 4))
}
if let decoded = title.idnaDecoded, title != decoded {
if decoded.lowercased().rangeOfCharacter(from: asciiChars) == nil {
title = decoded
}
}
}
return title
} else {
var address = url
if let components = URLComponents(string: address) {
if #available(iOS 16.0, *), let encodedHost = components.encodedHost {
if let decodedHost = components.host, encodedHost != decodedHost {
if decodedHost.lowercased().rangeOfCharacter(from: asciiChars) == nil {
address = address.replacingOccurrences(of: encodedHost, with: decodedHost)
}
}
} else if let encodedHost = components.host {
if let decodedHost = components.host?.idnaDecoded, encodedHost != decodedHost {
if decodedHost.lowercased().rangeOfCharacter(from: asciiChars) == nil {
address = address.replacingOccurrences(of: encodedHost, with: decodedHost)
}
}
}
}
if trim {
address = address.replacingOccurrences(of: "https://www.", with: "")
address = address.replacingOccurrences(of: "https://", with: "")
address = address.replacingOccurrences(of: "tonsite://", with: "")
address = address.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
}
return address
}
}