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,444 @@
import Foundation
import UIKit
import Display
import ComponentFlow
import ComponentDisplayAdapters
import TelegramCore
import AccountContext
import TelegramPresentationData
import PeerListItemComponent
final class ContextResultPanelComponent: Component {
enum Results: Equatable {
case mentions([EnginePeer])
case hashtags(EnginePeer?, [String], String)
var count: Int {
switch self {
case let .mentions(peers):
return peers.count
case let .hashtags(peer, hashtags, query):
var count = hashtags.count
if let _ = peer, query.count >= 4 {
count += 2
}
return count
}
}
}
enum ResultAction {
case mention(EnginePeer)
case hashtag(String)
}
let context: AccountContext
let theme: PresentationTheme
let strings: PresentationStrings
let results: Results
let action: (ResultAction) -> Void
init(
context: AccountContext,
theme: PresentationTheme,
strings: PresentationStrings,
results: Results,
action: @escaping (ResultAction) -> Void
) {
self.context = context
self.theme = theme
self.strings = strings
self.results = results
self.action = action
}
static func ==(lhs: ContextResultPanelComponent, rhs: ContextResultPanelComponent) -> Bool {
if lhs.context !== rhs.context {
return false
}
if lhs.theme !== rhs.theme {
return false
}
if lhs.strings !== rhs.strings {
return false
}
if lhs.results != rhs.results {
return false
}
return true
}
private struct ItemLayout: Equatable {
var containerSize: CGSize
var bottomInset: CGFloat
var topInset: CGFloat
var sideInset: CGFloat
var itemSize: CGSize
var itemCount: Int
var contentSize: CGSize
init(containerSize: CGSize, bottomInset: CGFloat, topInset: CGFloat, sideInset: CGFloat, itemSize: CGSize, itemCount: Int) {
self.containerSize = containerSize
self.bottomInset = bottomInset
self.topInset = topInset
self.sideInset = sideInset
self.itemSize = itemSize
self.itemCount = itemCount
self.contentSize = CGSize(width: containerSize.width, height: topInset + CGFloat(itemCount) * itemSize.height + bottomInset)
}
func visibleItems(for rect: CGRect) -> Range<Int>? {
let offsetRect = rect.offsetBy(dx: 0.0, dy: -self.topInset)
var minVisibleRow = Int(floor((offsetRect.minY) / (self.itemSize.height)))
minVisibleRow = max(0, minVisibleRow)
let maxVisibleRow = Int(ceil((offsetRect.maxY) / (self.itemSize.height)))
let minVisibleIndex = minVisibleRow
let maxVisibleIndex = maxVisibleRow
if maxVisibleIndex >= minVisibleIndex {
return minVisibleIndex ..< (maxVisibleIndex + 1)
} else {
return nil
}
}
func itemFrame(for index: Int) -> CGRect {
return CGRect(origin: CGPoint(x: 0.0, y: self.topInset + CGFloat(index) * self.itemSize.height), size: CGSize(width: self.containerSize.width, height: self.itemSize.height))
}
}
private final class ScrollView: UIScrollView {
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
let result = super.hitTest(point, with: event)
if result === self {
return nil
}
return super.hitTest(point, with: event)
}
override func touchesShouldCancel(in view: UIView) -> Bool {
return true
}
}
final class View: UIView, UIScrollViewDelegate, UIGestureRecognizerDelegate {
private let backgroundView: BlurredBackgroundView
private let scrollView: UIScrollView
private var itemLayout: ItemLayout?
private let measureItem = ComponentView<Empty>()
private var visibleItems: [AnyHashable: ComponentView<Empty>] = [:]
private var ignoreScrolling = false
private var component: ContextResultPanelComponent?
private weak var state: EmptyComponentState?
override init(frame: CGRect) {
self.backgroundView = BlurredBackgroundView(color: .clear, enableBlur: true)
self.scrollView = ScrollView()
self.scrollView.canCancelContentTouches = true
self.scrollView.delaysContentTouches = false
self.scrollView.showsVerticalScrollIndicator = false
self.scrollView.contentInsetAdjustmentBehavior = .never
self.scrollView.alwaysBounceVertical = true
self.scrollView.indicatorStyle = .white
super.init(frame: frame)
self.clipsToBounds = true
self.scrollView.delegate = self
self.addSubview(self.backgroundView)
self.addSubview(self.scrollView)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func animateIn(transition: ComponentTransition) {
let offset = self.scrollView.contentOffset.y * -1.0 + 10.0
ComponentTransition.immediate.setBoundsOrigin(view: self, origin: CGPoint(x: 0.0, y: -offset))
transition.setBoundsOrigin(view: self, origin: CGPoint(x: 0.0, y: 0.0))
}
func animateOut(transition: ComponentTransition, completion: @escaping () -> Void) {
let offset = self.scrollView.contentOffset.y * -1.0 + 10.0
self.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false)
transition.setBoundsOrigin(view: self, origin: CGPoint(x: 0.0, y: -offset), completion: { _ in
completion()
})
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
if !self.ignoreScrolling {
self.updateScrolling(transition: .immediate)
}
}
private func updateScrolling(transition: ComponentTransition) {
guard let component = self.component, let itemLayout = self.itemLayout else {
return
}
let visibleBounds = self.scrollView.bounds.insetBy(dx: 0.0, dy: -200.0)
var validIds: [AnyHashable] = []
if let range = itemLayout.visibleItems(for: visibleBounds) {
for index in range.lowerBound ..< range.upperBound {
guard index < component.results.count else {
continue
}
let itemFrame = itemLayout.itemFrame(for: index)
var itemTransition = transition
let id: AnyHashable
let itemComponent: AnyComponent<Empty>
switch component.results {
case let .mentions(peers):
let peer = peers[index]
id = peer.id
itemComponent = AnyComponent(PeerListItemComponent(
context: component.context,
theme: component.theme,
strings: component.strings,
style: .compact,
sideInset: itemLayout.sideInset,
title: peer.displayTitle(strings: component.strings, displayOrder: .firstLast),
peer: peer,
subtitle: peer.addressName.flatMap { PeerListItemComponent.Subtitle(text: "@\($0)", color: .neutral) },
subtitleAccessory: .none,
presence: nil,
selectionState: .none,
hasNext: index != peers.count - 1,
action: { [weak self] peer, _, _ in
guard let self, let component = self.component else {
return
}
component.action(.mention(peer))
}
))
case let .hashtags(peer, hashtags, query):
var hashtagIndex = index
if let _ = peer, query.count >= 4 {
hashtagIndex -= 2
}
if let peer, let addressName = peer.addressName, hashtagIndex < 0 {
var isGroup = false
if case let .channel(channel) = peer, case .group = channel.info {
isGroup = true
}
id = hashtagIndex
if hashtagIndex == -2 {
itemComponent = AnyComponent(HashtagListItemComponent(
context: component.context,
theme: component.theme,
strings: component.strings,
peer: nil,
title: component.strings.Chat_HashtagSuggestion_UseGeneric_Title("#\(query)").string,
subtitle: component.strings.Chat_HashtagSuggestion_UseGeneric_Text,
hashtag: query,
hasNext: index != hashtags.count - 1,
action: { [weak self] hashtag, _ in
guard let self, let component = self.component else {
return
}
component.action(.hashtag(query))
}
))
} else {
itemComponent = AnyComponent(HashtagListItemComponent(
context: component.context,
theme: component.theme,
strings: component.strings,
peer: peer,
title: component.strings.Chat_HashtagSuggestion_UseLocal_Title("#\(query)@\(addressName)").string,
subtitle: isGroup ? component.strings.Chat_HashtagSuggestion_UseLocal_Group_Text : component.strings.Chat_HashtagSuggestion_UseLocal_Channel_Text,
hashtag: "\(query)@\(addressName)",
hasNext: index != hashtags.count - 1,
action: { [weak self] hashtag, _ in
guard let self, let component = self.component else {
return
}
component.action(.hashtag("\(query)@\(addressName)"))
}
))
}
} else {
let hashtag = hashtags[hashtagIndex]
id = hashtag
itemComponent = AnyComponent(HashtagListItemComponent(
context: component.context,
theme: component.theme,
strings: component.strings,
peer: nil,
title: "#\(hashtag)",
subtitle: nil,
hashtag: hashtag,
hasNext: index != hashtags.count - 1,
action: { [weak self] hashtag, _ in
guard let self, let component = self.component else {
return
}
component.action(.hashtag(hashtag))
}
))
}
}
validIds.append(id)
let visibleItem: ComponentView<Empty>
if let current = self.visibleItems[id] {
visibleItem = current
} else {
if !transition.animation.isImmediate {
itemTransition = .immediate
}
visibleItem = ComponentView()
self.visibleItems[id] = visibleItem
}
let _ = visibleItem.update(
transition: itemTransition,
component: itemComponent,
environment: {},
containerSize: itemFrame.size
)
if let itemView = visibleItem.view {
if itemView.superview == nil {
self.scrollView.addSubview(itemView)
}
itemTransition.setFrame(view: itemView, frame: itemFrame)
}
}
}
var removeIds: [AnyHashable] = []
for (id, visibleItem) in self.visibleItems {
if !validIds.contains(id) {
removeIds.append(id)
if let itemView = visibleItem.view {
itemView.removeFromSuperview()
}
}
}
for id in removeIds {
self.visibleItems.removeValue(forKey: id)
}
let backgroundSize = CGSize(width: self.scrollView.frame.width, height: self.scrollView.frame.height + 20.0)
transition.setFrame(view: self.backgroundView, frame: CGRect(origin: CGPoint(x: 0.0, y: max(0.0, self.scrollView.contentOffset.y * -1.0)), size: backgroundSize))
self.backgroundView.update(size: backgroundSize, cornerRadius: 11.0, transition: transition.containedViewLayoutTransition)
}
func update(component: ContextResultPanelComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
var transition = transition
let previousComponent = self.component
self.component = component
self.state = state
let minimizedHeight = min(availableSize.height, 500.0)
let sideInset: CGFloat = 3.0
self.backgroundView.updateColor(color: UIColor(white: 0.0, alpha: 0.7), transition: transition.containedViewLayoutTransition)
let itemComponent: AnyComponent<Empty>
switch component.results {
case .mentions:
itemComponent = AnyComponent(PeerListItemComponent(
context: component.context,
theme: component.theme,
strings: component.strings,
style: .compact,
sideInset: sideInset,
title: "AAAAAAAAAAAA",
peer: nil,
subtitle: PeerListItemComponent.Subtitle(text: "BBBBBBB", color: .neutral),
subtitleAccessory: .none,
presence: nil,
selectionState: .none,
hasNext: true,
action: { _, _, _ in
}
))
case .hashtags:
itemComponent = AnyComponent(HashtagListItemComponent(
context: component.context,
theme: component.theme,
strings: component.strings,
peer: nil,
title: "AAAAAAAAAAAA",
subtitle: nil,
hashtag: "",
hasNext: true,
action: { _, _ in
}
))
}
let measureItemSize = self.measureItem.update(
transition: .immediate,
component: itemComponent,
environment: {},
containerSize: CGSize(width: availableSize.width, height: 1000.0)
)
if previousComponent?.results != component.results {
transition = transition.withUserData(PeerListItemComponent.TransitionHint(synchronousLoad: true))
}
let itemLayout = ItemLayout(
containerSize: CGSize(width: availableSize.width, height: minimizedHeight),
bottomInset: 0.0,
topInset: 0.0,
sideInset: sideInset,
itemSize: measureItemSize,
itemCount: component.results.count
)
self.itemLayout = itemLayout
let scrollContentSize = itemLayout.contentSize
self.ignoreScrolling = true
transition.setFrame(view: self.scrollView, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: availableSize.width, height: minimizedHeight)))
let visibleTopContentHeight = min(scrollContentSize.height, measureItemSize.height * 3.5)
let topInset = availableSize.height - visibleTopContentHeight
let scrollContentInsets = UIEdgeInsets(top: topInset, left: 0.0, bottom: 0.0, right: 0.0)
let scrollIndicatorInsets = UIEdgeInsets(top: topInset + 17.0, left: 0.0, bottom: 19.0, right: 0.0)
if self.scrollView.contentInset != scrollContentInsets {
self.scrollView.contentInset = scrollContentInsets
}
if self.scrollView.verticalScrollIndicatorInsets != scrollIndicatorInsets {
self.scrollView.verticalScrollIndicatorInsets = scrollIndicatorInsets
}
if self.scrollView.contentSize != scrollContentSize {
self.scrollView.contentSize = scrollContentSize
}
self.ignoreScrolling = false
self.updateScrolling(transition: transition)
return availableSize
}
}
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,510 @@
import Foundation
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import ComponentFlow
import SwiftSignalKit
import AccountContext
import TelegramCore
import Postbox
import MultilineTextComponent
import AvatarNode
import TelegramPresentationData
import CheckNode
import TelegramStringFormatting
import AppBundle
import PeerPresenceStatusManager
import EmojiStatusComponent
import ContextUI
import EmojiTextAttachmentView
import TextFormat
import PhotoResources
import ListSectionComponent
import ListItemSwipeOptionContainer
private let avatarFont = avatarPlaceholderFont(size: 15.0)
public final class HashtagListItemComponent: Component {
public final class TransitionHint {
public let synchronousLoad: Bool
public init(synchronousLoad: Bool) {
self.synchronousLoad = synchronousLoad
}
}
public final class InlineAction: Equatable {
public enum Color: Equatable {
case destructive
}
public let id: AnyHashable
public let title: String
public let color: Color
public let action: () -> Void
public init(id: AnyHashable, title: String, color: Color, action: @escaping () -> Void) {
self.id = id
self.title = title
self.color = color
self.action = action
}
public static func ==(lhs: InlineAction, rhs: InlineAction) -> Bool {
if lhs === rhs {
return true
}
if lhs.id != rhs.id {
return false
}
if lhs.title != rhs.title {
return false
}
if lhs.color != rhs.color {
return false
}
return true
}
}
public final class InlineActionsState: Equatable {
public let actions: [InlineAction]
public init(actions: [InlineAction]) {
self.actions = actions
}
public static func ==(lhs: InlineActionsState, rhs: InlineActionsState) -> Bool {
if lhs === rhs {
return true
}
if lhs.actions != rhs.actions {
return false
}
return true
}
}
let context: AccountContext
let theme: PresentationTheme
let strings: PresentationStrings
let peer: EnginePeer?
let title: String
let subtitle: String?
let hashtag: String
let hasNext: Bool
let action: (String, HashtagListItemComponent.View) -> Void
let contextAction: ((String, ContextExtractedContentContainingView, ContextGesture) -> Void)?
let inlineActions: InlineActionsState?
public init(
context: AccountContext,
theme: PresentationTheme,
strings: PresentationStrings,
peer: EnginePeer?,
title: String,
subtitle: String?,
hashtag: String,
hasNext: Bool,
action: @escaping (String, HashtagListItemComponent.View) -> Void,
contextAction: ((String, ContextExtractedContentContainingView, ContextGesture) -> Void)? = nil,
inlineActions: InlineActionsState? = nil
) {
self.context = context
self.theme = theme
self.strings = strings
self.peer = peer
self.title = title
self.subtitle = subtitle
self.hashtag = hashtag
self.hasNext = hasNext
self.action = action
self.contextAction = contextAction
self.inlineActions = inlineActions
}
public static func ==(lhs: HashtagListItemComponent, rhs: HashtagListItemComponent) -> Bool {
if lhs.context !== rhs.context {
return false
}
if lhs.theme !== rhs.theme {
return false
}
if lhs.strings !== rhs.strings {
return false
}
if lhs.peer != rhs.peer {
return false
}
if lhs.title != rhs.title {
return false
}
if lhs.subtitle != rhs.subtitle {
return false
}
if lhs.hashtag != rhs.hashtag {
return false
}
if lhs.hasNext != rhs.hasNext {
return false
}
if lhs.inlineActions != rhs.inlineActions {
return false
}
return true
}
public final class View: ContextControllerSourceView, ListSectionComponent.ChildView {
public let extractedContainerView: ContextExtractedContentContainingView
private let containerButton: HighlightTrackingButton
private let swipeOptionContainer: ListItemSwipeOptionContainer
private let iconBackgroundLayer = SimpleLayer()
private let iconLayer = SimpleLayer()
private let title = ComponentView<Empty>()
private var label = ComponentView<Empty>()
private let separatorLayer: SimpleLayer
private var avatarNode: AvatarNode?
private let badgeBackgroundLayer = SimpleLayer()
private var component: HashtagListItemComponent?
private weak var state: EmptyComponentState?
public var avatarFrame: CGRect {
if let avatarNode = self.avatarNode {
return avatarNode.frame
} else {
return CGRect(origin: CGPoint(), size: CGSize())
}
}
public var titleFrame: CGRect? {
return self.title.view?.frame
}
public var labelFrame: CGRect? {
guard let value = self.label.view?.frame else {
return nil
}
return value
}
private var isExtractedToContextMenu: Bool = false
public var customUpdateIsHighlighted: ((Bool) -> Void)?
public var enumerateSiblings: (((UIView) -> Void) -> Void)?
public private(set) var separatorInset: CGFloat = 0.0
override init(frame: CGRect) {
self.separatorLayer = SimpleLayer()
self.iconBackgroundLayer.cornerRadius = 15.0
self.badgeBackgroundLayer.cornerRadius = 4.0
self.extractedContainerView = ContextExtractedContentContainingView()
self.containerButton = HighlightTrackingButton()
self.containerButton.layer.anchorPoint = CGPoint()
self.containerButton.isExclusiveTouch = true
self.swipeOptionContainer = ListItemSwipeOptionContainer(frame: CGRect())
super.init(frame: frame)
self.addSubview(self.extractedContainerView)
self.targetViewForActivationProgress = self.extractedContainerView.contentView
self.extractedContainerView.contentView.addSubview(self.swipeOptionContainer)
self.swipeOptionContainer.addSubview(self.containerButton)
self.layer.addSublayer(self.separatorLayer)
self.containerButton.addTarget(self, action: #selector(self.pressed), for: .touchUpInside)
self.extractedContainerView.isExtractedToContextPreviewUpdated = { [weak self] value in
guard let self else {
return
}
self.containerButton.clipsToBounds = value
self.containerButton.backgroundColor = nil
self.containerButton.layer.cornerRadius = value ? 10.0 : 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.hashtag, self.extractedContainerView, gesture)
}
self.containerButton.highligthedChanged = { [weak self] highlighted in
guard let self else {
return
}
if let customUpdateIsHighlighted = self.customUpdateIsHighlighted {
customUpdateIsHighlighted(highlighted)
}
}
self.swipeOptionContainer.updateRevealOffset = { [weak self] offset, transition in
guard let self else {
return
}
transition.setBounds(view: self.containerButton, bounds: CGRect(origin: CGPoint(x: -offset, y: 0.0), size: self.containerButton.bounds.size))
}
self.swipeOptionContainer.revealOptionSelected = { [weak self] option, _ in
guard let self, let component = self.component else {
return
}
guard let inlineActions = component.inlineActions else {
return
}
self.swipeOptionContainer.setRevealOptionsOpened(false, animated: true)
if let inlineAction = inlineActions.actions.first(where: { $0.id == option.key }) {
inlineAction.action()
}
}
self.containerButton.layer.addSublayer(self.iconBackgroundLayer)
self.iconBackgroundLayer.addSublayer(self.iconLayer)
self.containerButton.layer.addSublayer(self.badgeBackgroundLayer)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@objc private func pressed() {
guard let component = self.component else {
return
}
component.action("\(component.hashtag) ", self)
}
func update(component: HashtagListItemComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
var synchronousLoad = false
if let hint = transition.userData(TransitionHint.self) {
synchronousLoad = hint.synchronousLoad
}
self.isGestureEnabled = false
let themeUpdated = self.component?.theme !== component.theme
self.component = component
self.state = state
let labelData: (String, UIColor)
if let subtitle = component.subtitle {
labelData = (subtitle, component.theme.list.itemSecondaryTextColor)
} else {
labelData = ("", .clear)
}
let contextInset: CGFloat
if self.isExtractedToContextMenu {
contextInset = 12.0
} else {
contextInset = 0.0
}
let height: CGFloat = 42.0
let titleFont: UIFont = Font.semibold(14.0)
let subtitleFont: UIFont = Font.regular(14.0)
let verticalInset: CGFloat = 1.0
let leftInset: CGFloat = 55.0
let rightInset: CGFloat = 16.0
let avatarSize: CGFloat = 30.0
let avatarFrame = CGRect(origin: CGPoint(x: 12.0, y: floorToScreenPixels((height - verticalInset * 2.0 - avatarSize) / 2.0)), size: CGSize(width: avatarSize, height: avatarSize))
if let peer = component.peer {
let avatarNode: AvatarNode
if let current = self.avatarNode {
avatarNode = current
} else {
avatarNode = AvatarNode(font: avatarFont)
avatarNode.isLayerBacked = false
avatarNode.isUserInteractionEnabled = false
self.avatarNode = avatarNode
self.containerButton.layer.insertSublayer(avatarNode.layer, at: 0)
}
if avatarNode.bounds.isEmpty {
avatarNode.frame = avatarFrame
} else {
transition.setFrame(layer: avatarNode.layer, frame: avatarFrame)
}
if peer.smallProfileImage != nil {
avatarNode.setPeerV2(
context: component.context,
theme: component.theme,
peer: peer,
authorOfMessage: nil,
overrideImage: nil,
emptyColor: nil,
clipStyle: .round,
synchronousLoad: synchronousLoad,
displayDimensions: CGSize(width: avatarSize, height: avatarSize)
)
} else {
avatarNode.setPeer(context: component.context, theme: component.theme, peer: peer, clipStyle: .round, synchronousLoad: synchronousLoad, displayDimensions: CGSize(width: avatarSize, height: avatarSize))
}
self.iconBackgroundLayer.isHidden = true
} else {
self.iconBackgroundLayer.isHidden = false
}
let previousTitleFrame = self.title.view?.frame
let titleAvailableWidth = availableSize.width - leftInset - rightInset
let titleSize = self.title.update(
transition: .immediate,
component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(string: component.title, font: titleFont, textColor: component.theme.list.itemPrimaryTextColor))
)),
environment: {},
containerSize: CGSize(width: titleAvailableWidth, height: 100.0)
)
let labelAvailableWidth = availableSize.width - leftInset - rightInset
let labelColor: UIColor = labelData.1
let labelSize = self.label.update(
transition: .immediate,
component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(string: labelData.0, font: subtitleFont, textColor: labelColor))
)),
environment: {},
containerSize: CGSize(width: labelAvailableWidth, height: 100.0)
)
let titleVerticalOffset: CGFloat = 0.0
let centralContentHeight: CGFloat
if labelSize.height > 0.0 {
centralContentHeight = titleSize.height + labelSize.height
} else {
centralContentHeight = titleSize.height
}
let titleFrame = CGRect(origin: CGPoint(x: leftInset, y: titleVerticalOffset + floor((height - verticalInset * 2.0 - 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
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 labelView = self.label.view {
let labelFrame = CGRect(origin: CGPoint(x: titleFrame.minX, y: titleFrame.maxY), size: labelSize)
if labelView.superview == nil {
labelView.isUserInteractionEnabled = false
labelView.layer.anchorPoint = CGPoint()
self.containerButton.addSubview(labelView)
labelView.center = labelFrame.origin
} else {
transition.setPosition(view: labelView, position: labelFrame.origin)
}
labelView.bounds = CGRect(origin: CGPoint(), size: labelFrame.size)
}
if self.iconLayer.contents == nil {
self.iconLayer.contents = UIImage(bundleImageName: "Chat/Hashtag/SuggestHashtag")?.cgImage
}
if themeUpdated {
let accentColor = UIColor(rgb: 0x0088ff)
self.separatorLayer.backgroundColor = component.theme.list.itemPlainSeparatorColor.cgColor
self.iconBackgroundLayer.backgroundColor = accentColor.cgColor
self.iconLayer.layerTintColor = UIColor.white.cgColor
self.badgeBackgroundLayer.backgroundColor = accentColor.cgColor
}
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 iconSize = CGSize(width: 30.0, height: 30.0)
self.iconBackgroundLayer.frame = CGRect(origin: CGPoint(x: 12.0, y: floor((height - 30.0) / 2.0)), size: iconSize)
self.iconLayer.frame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: 30.0, height: 30.0))
let resultBounds = CGRect(origin: CGPoint(), size: CGSize(width: availableSize.width, height: height))
transition.setFrame(view: self.extractedContainerView, frame: resultBounds)
transition.setFrame(view: self.extractedContainerView.contentView, frame: resultBounds)
self.extractedContainerView.contentRect = resultBounds
let containerFrame = CGRect(origin: CGPoint(x: contextInset, y: verticalInset), size: CGSize(width: availableSize.width - contextInset * 2.0, height: height - verticalInset * 2.0))
let swipeOptionContainerFrame = CGRect(origin: .zero, size: CGSize(width: availableSize.width, height: height))
transition.setFrame(view: self.swipeOptionContainer, frame: swipeOptionContainerFrame)
transition.setPosition(view: self.containerButton, position: containerFrame.origin)
transition.setBounds(view: self.containerButton, bounds: CGRect(origin: self.containerButton.bounds.origin, size: containerFrame.size))
self.separatorInset = leftInset
self.swipeOptionContainer.updateLayout(size: swipeOptionContainerFrame.size, leftInset: 0.0, rightInset: 0.0)
var rightOptions: [ListItemSwipeOptionContainer.Option] = []
if let inlineActions = component.inlineActions {
rightOptions = inlineActions.actions.map { action in
let color: UIColor
let textColor: UIColor
switch action.color {
case .destructive:
color = component.theme.list.itemDisclosureActions.destructive.fillColor
textColor = component.theme.list.itemDisclosureActions.destructive.foregroundColor
}
return ListItemSwipeOptionContainer.Option(
key: action.id,
title: action.title,
icon: .none,
color: color,
textColor: textColor
)
}
}
self.swipeOptionContainer.setRevealOptions(([], rightOptions))
return CGSize(width: availableSize.width, height: height)
}
}
public func makeView() -> View {
return View(frame: CGRect())
}
public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}
@@ -0,0 +1,335 @@
import Foundation
import SwiftSignalKit
import TelegramCore
import TextFieldComponent
import ChatContextQuery
import AccountContext
import TelegramUIPreferences
import SearchPeerMembers
func textInputStateContextQueryRangeAndType(inputState: TextFieldComponent.InputState) -> [(NSRange, PossibleContextQueryTypes, NSRange?)] {
return textInputStateContextQueryRangeAndType(inputText: inputState.inputText, selectionRange: inputState.selectionRange)
}
func inputContextQueries(_ inputState: TextFieldComponent.InputState) -> [ChatPresentationInputQuery] {
let inputString: NSString = inputState.inputText.string as NSString
var result: [ChatPresentationInputQuery] = []
for (possibleQueryRange, possibleTypes, additionalStringRange) in textInputStateContextQueryRangeAndType(inputText: inputState.inputText, selectionRange: inputState.selectionRange) {
let query = inputString.substring(with: possibleQueryRange)
if possibleTypes == [.emoji] {
result.append(.emoji(query.basicEmoji.0))
} else if possibleTypes == [.hashtag] {
result.append(.hashtag(query))
} else if possibleTypes == [.mention] {
let types: ChatInputQueryMentionTypes = [.members]
result.append(.mention(query: query, types: types))
} else if possibleTypes == [.command] {
result.append(.command(query))
} else if possibleTypes == [.contextRequest], let additionalStringRange = additionalStringRange {
let additionalString = inputString.substring(with: additionalStringRange)
result.append(.contextRequest(addressName: query, query: additionalString))
}
// else if possibleTypes == [.emojiSearch], !query.isEmpty, let inputLanguage = chatPresentationInterfaceState.interfaceState.inputLanguage {
// result.append(.emojiSearch(query: query, languageCode: inputLanguage, range: possibleQueryRange))
// }
}
return result
}
func contextQueryResultState(context: AccountContext, inputState: TextFieldComponent.InputState, availableTypes: [ChatPresentationInputQueryKind], chatLocation: ChatLocation?, currentQueryStates: inout [ChatPresentationInputQueryKind: (ChatPresentationInputQuery, Disposable)]) -> [ChatPresentationInputQueryKind: ChatContextQueryUpdate] {
let inputQueries = inputContextQueries(inputState).filter({ query in
return availableTypes.contains(query.kind)
})
var updates: [ChatPresentationInputQueryKind: ChatContextQueryUpdate] = [:]
for query in inputQueries {
let previousQuery = currentQueryStates[query.kind]?.0
if previousQuery != query {
let signal = updatedContextQueryResultStateForQuery(context: context, chatLocation: chatLocation, inputQuery: query, previousQuery: previousQuery)
updates[query.kind] = .update(query, signal)
}
}
for currentQueryKind in currentQueryStates.keys {
var found = false
inner: for query in inputQueries {
if query.kind == currentQueryKind {
found = true
break inner
}
}
if !found {
updates[currentQueryKind] = .remove
}
}
return updates
}
private func updatedContextQueryResultStateForQuery(context: AccountContext, chatLocation: ChatLocation?, inputQuery: ChatPresentationInputQuery, previousQuery: ChatPresentationInputQuery?) -> Signal<(ChatPresentationInputQueryResult?) -> ChatPresentationInputQueryResult?, ChatContextQueryError> {
switch inputQuery {
case let .emoji(query):
var signal: Signal<(ChatPresentationInputQueryResult?) -> ChatPresentationInputQueryResult?, ChatContextQueryError> = .complete()
if let previousQuery = previousQuery {
switch previousQuery {
case .emoji:
break
default:
signal = .single({ _ in return .stickers([]) })
}
} else {
signal = .single({ _ in return .stickers([]) })
}
let stickerConfiguration = context.account.postbox.preferencesView(keys: [PreferencesKeys.appConfiguration])
|> map { preferencesView -> StickersSearchConfiguration in
let appConfiguration: AppConfiguration = preferencesView.values[PreferencesKeys.appConfiguration]?.get(AppConfiguration.self) ?? .defaultValue
return StickersSearchConfiguration.with(appConfiguration: appConfiguration)
}
let stickerSettings = context.sharedContext.accountManager.transaction { transaction -> StickerSettings in
let stickerSettings: StickerSettings = transaction.getSharedData(ApplicationSpecificSharedDataKeys.stickerSettings)?.get(StickerSettings.self) ?? .defaultSettings
return stickerSettings
}
let stickers: Signal<(ChatPresentationInputQueryResult?) -> ChatPresentationInputQueryResult?, ChatContextQueryError> = combineLatest(stickerConfiguration, stickerSettings)
|> castError(ChatContextQueryError.self)
|> mapToSignal { stickerConfiguration, stickerSettings -> Signal<[FoundStickerItem], ChatContextQueryError> in
let scope: SearchStickersScope
switch stickerSettings.emojiStickerSuggestionMode {
case .none:
scope = []
case .all:
if stickerConfiguration.disableLocalSuggestions {
scope = [.remote]
} else {
scope = [.installed, .remote]
}
case .installed:
scope = [.installed]
}
return context.engine.stickers.searchStickers(query: nil, emoticon: [query.basicEmoji.0], scope: scope)
|> map { items -> [FoundStickerItem] in
return items.items
}
|> castError(ChatContextQueryError.self)
}
|> map { stickers -> (ChatPresentationInputQueryResult?) -> ChatPresentationInputQueryResult? in
return { _ in
return .stickers(stickers)
}
}
return signal |> then(stickers)
case let .hashtag(query):
var signal: Signal<(ChatPresentationInputQueryResult?) -> ChatPresentationInputQueryResult?, ChatContextQueryError> = .complete()
if let previousQuery = previousQuery {
switch previousQuery {
case .hashtag:
break
default:
signal = .single({ _ in return .hashtags([], query) })
}
} else {
signal = .single({ _ in return .hashtags([], query) })
}
let hashtags: Signal<(ChatPresentationInputQueryResult?) -> ChatPresentationInputQueryResult?, ChatContextQueryError> = context.engine.messages.recentlyUsedHashtags()
|> map { hashtags -> (ChatPresentationInputQueryResult?) -> ChatPresentationInputQueryResult? in
let normalizedQuery = query.lowercased()
var result: [String] = []
for hashtag in hashtags {
if hashtag.lowercased().hasPrefix(normalizedQuery) {
result.append(hashtag)
}
}
return { _ in return .hashtags(result, query) }
}
|> castError(ChatContextQueryError.self)
return signal |> then(hashtags)
case let .mention(query, types):
var signal: Signal<(ChatPresentationInputQueryResult?) -> ChatPresentationInputQueryResult?, ChatContextQueryError> = .complete()
if let previousQuery = previousQuery {
switch previousQuery {
case .mention:
break
default:
signal = .single({ _ in return .mentions([]) })
}
} else {
signal = .single({ _ in return .mentions([]) })
}
let normalizedQuery = query.lowercased()
if let chatLocation, let peerId = chatLocation.peerId {
let inlineBots: Signal<[(EnginePeer, Double)], NoError> = types.contains(.contextBots) ? context.engine.peers.recentlyUsedInlineBots() : .single([])
let strings = context.sharedContext.currentPresentationData.with({ $0 }).strings
let participants = combineLatest(inlineBots, searchPeerMembers(context: context, peerId: peerId, chatLocation: chatLocation, query: query, scope: .mention))
|> map { inlineBots, peers -> (ChatPresentationInputQueryResult?) -> ChatPresentationInputQueryResult? in
let filteredInlineBots = inlineBots.sorted(by: { $0.1 > $1.1 }).filter { peer, rating in
if rating < 0.14 {
return false
}
if peer.indexName.matchesByTokens(normalizedQuery) {
return true
}
if let addressName = peer.addressName, addressName.lowercased().hasPrefix(normalizedQuery) {
return true
}
return false
}.map { $0.0 }
let inlineBotPeerIds = Set(filteredInlineBots.map { $0.id })
let filteredPeers = peers.filter { peer in
if inlineBotPeerIds.contains(peer.id) {
return false
}
if !types.contains(.accountPeer) && peer.id == context.account.peerId {
return false
}
return true
}
var sortedPeers = filteredInlineBots
sortedPeers.append(contentsOf: filteredPeers.sorted(by: { lhs, rhs in
let result = lhs.indexName.stringRepresentation(lastNameFirst: true).compare(rhs.indexName.stringRepresentation(lastNameFirst: true))
return result == .orderedAscending
}))
sortedPeers = sortedPeers.filter { peer in
return !peer.displayTitle(strings: strings, displayOrder: .firstLast).isEmpty
}
return { _ in return .mentions(sortedPeers) }
}
|> castError(ChatContextQueryError.self)
return signal |> then(participants)
} else {
if normalizedQuery.isEmpty {
let peers: Signal<(ChatPresentationInputQueryResult?) -> ChatPresentationInputQueryResult?, ChatContextQueryError> = context.engine.peers.recentPeers()
|> map { recentPeers -> (ChatPresentationInputQueryResult?) -> ChatPresentationInputQueryResult? in
if case let .peers(peers) = recentPeers {
let peers = peers.filter { peer in
return peer.addressName != nil
}.compactMap { EnginePeer($0) }
return { _ in return .mentions(peers) }
} else {
return { _ in return .mentions([]) }
}
}
|> castError(ChatContextQueryError.self)
return signal |> then(peers)
} else {
let peers: Signal<(ChatPresentationInputQueryResult?) -> ChatPresentationInputQueryResult?, ChatContextQueryError> = context.engine.contacts.searchLocalPeers(query: normalizedQuery)
|> map { peersAndPresences -> (ChatPresentationInputQueryResult?) -> ChatPresentationInputQueryResult? in
let peers = peersAndPresences.filter { peer in
if let peer = peer.peer, case .user = peer, peer.addressName != nil {
return true
} else {
return false
}
}.compactMap { $0.peer }
return { _ in return .mentions(peers) }
}
|> castError(ChatContextQueryError.self)
return signal |> then(peers)
}
}
case let .emojiSearch(query, languageCode, range):
let hasPremium = context.engine.data.subscribe(TelegramEngine.EngineData.Item.Peer.Peer(id: context.account.peerId))
|> map { peer -> Bool in
guard case let .user(user) = peer else {
return false
}
return user.isPremium
}
|> distinctUntilChanged
if query.isSingleEmoji {
return combineLatest(
context.account.postbox.itemCollectionsView(orderedItemListCollectionIds: [], namespaces: [Namespaces.ItemCollection.CloudEmojiPacks], aroundIndex: nil, count: 10000000),
hasPremium
)
|> map { view, hasPremium -> [(String, TelegramMediaFile?, String)] in
var result: [(String, TelegramMediaFile?, String)] = []
for entry in view.entries {
guard let item = entry.item as? StickerPackItem, !item.file.isPremiumEmoji || hasPremium else {
continue
}
let stringRepresentations = item.getStringRepresentationsOfIndexKeys()
for stringRepresentation in stringRepresentations {
if stringRepresentation == query {
result.append((stringRepresentation, item.file._parse(), stringRepresentation))
break
}
}
}
return result
}
|> map { result -> (ChatPresentationInputQueryResult?) -> ChatPresentationInputQueryResult? in
return { _ in return .emojis(result, range) }
}
|> castError(ChatContextQueryError.self)
} else {
var signal = context.engine.stickers.searchEmojiKeywords(inputLanguageCode: languageCode, query: query, completeMatch: query.count < 2)
if !languageCode.lowercased().hasPrefix("en") {
signal = signal
|> mapToSignal { keywords in
return .single(keywords)
|> then(
context.engine.stickers.searchEmojiKeywords(inputLanguageCode: "en-US", query: query, completeMatch: query.count < 3)
|> map { englishKeywords in
return keywords + englishKeywords
}
)
}
}
return signal
|> castError(ChatContextQueryError.self)
|> mapToSignal { keywords -> Signal<(ChatPresentationInputQueryResult?) -> ChatPresentationInputQueryResult?, ChatContextQueryError> in
return combineLatest(
context.account.postbox.itemCollectionsView(orderedItemListCollectionIds: [], namespaces: [Namespaces.ItemCollection.CloudEmojiPacks], aroundIndex: nil, count: 10000000),
hasPremium
)
|> map { view, hasPremium -> [(String, TelegramMediaFile?, String)] in
var result: [(String, TelegramMediaFile?, String)] = []
var allEmoticons: [String: String] = [:]
for keyword in keywords {
for emoticon in keyword.emoticons {
allEmoticons[emoticon] = keyword.keyword
}
}
for entry in view.entries {
guard let item = entry.item as? StickerPackItem, !item.file.isPremiumEmoji || hasPremium else {
continue
}
let stringRepresentations = item.getStringRepresentationsOfIndexKeys()
for stringRepresentation in stringRepresentations {
if let keyword = allEmoticons[stringRepresentation] {
result.append((stringRepresentation, item.file._parse(), keyword))
break
}
}
}
for keyword in keywords {
for emoticon in keyword.emoticons {
result.append((emoticon, nil, keyword.keyword))
}
}
return result
}
|> map { result -> (ChatPresentationInputQueryResult?) -> ChatPresentationInputQueryResult? in
return { _ in return .emojis(result, range) }
}
|> castError(ChatContextQueryError.self)
}
}
default:
return .complete()
}
}
@@ -0,0 +1,359 @@
import Foundation
import UIKit
import Display
import ComponentFlow
import AppBundle
import TextFieldComponent
import BundleIconComponent
import AccountContext
import TelegramPresentationData
import ChatPresentationInterfaceState
import SwiftSignalKit
import LottieComponent
import HierarchyTrackingLayer
import ManagedAnimationNode
import AudioWaveformComponent
import UniversalMediaPlayer
private final class PlayPauseIconNode: ManagedAnimationNode {
enum State: Equatable {
case play
case pause
}
private let duration: Double = 0.35
private var iconState: State = .pause
init() {
super.init(size: CGSize(width: 28.0, height: 28.0))
self.enqueueState(.play, animated: false)
}
func enqueueState(_ state: State, animated: Bool) {
guard self.iconState != state else {
return
}
let previousState = self.iconState
self.iconState = state
switch previousState {
case .pause:
switch state {
case .play:
if animated {
self.trackTo(item: ManagedAnimationItem(source: .local("anim_playpause"), frames: .range(startFrame: 41, endFrame: 83), duration: self.duration))
} else {
self.trackTo(item: ManagedAnimationItem(source: .local("anim_playpause"), frames: .range(startFrame: 0, endFrame: 0), duration: 0.01))
}
case .pause:
break
}
case .play:
switch state {
case .pause:
if animated {
self.trackTo(item: ManagedAnimationItem(source: .local("anim_playpause"), frames: .range(startFrame: 0, endFrame: 41), duration: self.duration))
} else {
self.trackTo(item: ManagedAnimationItem(source: .local("anim_playpause"), frames: .range(startFrame: 41, endFrame: 41), duration: 0.01))
}
case .play:
break
}
}
}
}
private func textForDuration(seconds: Int32) -> String {
if seconds >= 60 * 60 {
return String(format: "%d:%02d:%02d", seconds / 3600, seconds / 60 % 60)
} else {
return String(format: "%d:%02d", seconds / 60, seconds % 60)
}
}
public final class MediaPreviewPanelComponent: Component {
public let context: AccountContext
public let theme: PresentationTheme
public let strings: PresentationStrings
public let mediaPreview: ChatRecordedMediaPreview
public let insets: UIEdgeInsets
public init(
context: AccountContext,
theme: PresentationTheme,
strings: PresentationStrings,
mediaPreview: ChatRecordedMediaPreview,
insets: UIEdgeInsets
) {
self.context = context
self.theme = theme
self.strings = strings
self.mediaPreview = mediaPreview
self.insets = insets
}
public static func ==(lhs: MediaPreviewPanelComponent, rhs: MediaPreviewPanelComponent) -> Bool {
if lhs.context !== rhs.context {
return false
}
if lhs.theme !== rhs.theme {
return false
}
if lhs.strings !== rhs.strings {
return false
}
if lhs.mediaPreview != rhs.mediaPreview {
return false
}
if lhs.insets != rhs.insets {
return false
}
return true
}
public final class View: UIView {
private var component: MediaPreviewPanelComponent?
private weak var state: EmptyComponentState?
public let vibrancyContainer: UIView
private let trackingLayer: HierarchyTrackingLayer
private let timerFont: UIFont
private let timerText = ComponentView<Empty>()
private var timerTextValue: String = "0:00"
private let playPauseIconButton: HighlightableButton
private let playPauseIconNode: PlayPauseIconNode
private let waveform = ComponentView<Empty>()
private let vibrancyWaveform = ComponentView<Empty>()
private var mediaPlayer: MediaPlayer?
private let mediaPlayerStatus = Promise<MediaPlayerStatus?>(nil)
private var mediaPlayerStatusDisposable: Disposable?
override init(frame: CGRect) {
self.trackingLayer = HierarchyTrackingLayer()
self.timerFont = Font.with(size: 15.0, design: .camera, traits: .monospacedNumbers)
self.vibrancyContainer = UIView()
self.playPauseIconButton = HighlightableButton()
self.playPauseIconNode = PlayPauseIconNode()
self.playPauseIconNode.isUserInteractionEnabled = false
super.init(frame: frame)
self.layer.addSublayer(self.trackingLayer)
self.playPauseIconButton.addSubview(self.playPauseIconNode.view)
self.addSubview(self.playPauseIconButton)
self.playPauseIconButton.addTarget(self, action: #selector(self.playPauseButtonPressed), for: .touchUpInside)
self.mediaPlayerStatusDisposable = (self.mediaPlayerStatus.get()
|> deliverOnMainQueue).start(next: { [weak self] status in
guard let self else {
return
}
if let status {
switch status.status {
case .playing, .buffering(_, true, _, _):
self.playPauseIconNode.enqueueState(.play, animated: true)
default:
self.playPauseIconNode.enqueueState(.pause, animated: true)
}
//self.timerTextValue = textForDuration(seconds: component.mediaPreview.duration)
} else {
self.playPauseIconNode.enqueueState(.play, animated: true)
}
})
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
self.mediaPlayerStatusDisposable?.dispose()
}
public func animateIn() {
self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25)
}
public func animateOut(transition: ComponentTransition, completion: @escaping () -> Void) {
let vibrancyContainer = self.vibrancyContainer
transition.setAlpha(view: vibrancyContainer, alpha: 0.0, completion: { [weak vibrancyContainer] _ in
vibrancyContainer?.removeFromSuperview()
})
transition.setAlpha(view: self, alpha: 0.0, completion: { _ in
completion()
})
}
@objc private func playPauseButtonPressed() {
guard let component = self.component, case let .audio(audio) = component.mediaPreview else {
return
}
if let mediaPlayer = self.mediaPlayer {
mediaPlayer.togglePlayPause()
} else {
let mediaManager = component.context.sharedContext.mediaManager
let mediaPlayer = MediaPlayer(
audioSessionManager: mediaManager.audioSession,
postbox: component.context.account.postbox,
userLocation: .other,
userContentType: .audio,
resourceReference: .standalone(resource: audio.resource),
streamable: .none,
video: false,
preferSoftwareDecoding: false,
enableSound: true,
fetchAutomatically: true
)
mediaPlayer.actionAtEnd = .action { [weak mediaPlayer] in
mediaPlayer?.seek(timestamp: 0.0)
}
self.mediaPlayer = mediaPlayer
self.mediaPlayerStatus.set(mediaPlayer.status |> map(Optional.init))
mediaPlayer.play()
}
}
func update(component: MediaPreviewPanelComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
if self.component == nil, case let .audio(audio) = component.mediaPreview {
self.timerTextValue = textForDuration(seconds: audio.duration)
}
self.component = component
self.state = state
let timerTextSize = self.timerText.update(
transition: .immediate,
component: AnyComponent(Text(text: self.timerTextValue, font: self.timerFont, color: .white)),
environment: {},
containerSize: CGSize(width: 100.0, height: 100.0)
)
if let timerTextView = self.timerText.view {
if timerTextView.superview == nil {
self.addSubview(timerTextView)
timerTextView.layer.anchorPoint = CGPoint(x: 1.0, y: 0.5)
}
let timerTextFrame = CGRect(origin: CGPoint(x: availableSize.width - component.insets.right - 8.0, y: component.insets.top + floor((availableSize.height - component.insets.top - component.insets.bottom - timerTextSize.height) * 0.5)), size: timerTextSize)
transition.setPosition(view: timerTextView, position: CGPoint(x: timerTextFrame.minX, y: timerTextFrame.midY))
timerTextView.bounds = CGRect(origin: CGPoint(), size: timerTextFrame.size)
}
let playPauseSize = CGSize(width: 28.0, height: 28.0)
var playPauseFrame = CGRect(origin: CGPoint(x: component.insets.left + 8.0, y: component.insets.top + floor((availableSize.height - component.insets.top - component.insets.bottom - playPauseSize.height) * 0.5)), size: playPauseSize)
let playPauseButtonFrame = playPauseFrame.insetBy(dx: -8.0, dy: -8.0)
playPauseFrame = playPauseFrame.offsetBy(dx: -playPauseButtonFrame.minX, dy: -playPauseButtonFrame.minY)
transition.setFrame(view: self.playPauseIconButton, frame: playPauseButtonFrame)
transition.setFrame(view: self.playPauseIconNode.view, frame: playPauseFrame)
let waveformFrame = CGRect(origin: CGPoint(x: component.insets.left + 47.0, y: component.insets.top + floor((availableSize.height - component.insets.top - component.insets.bottom - 24.0) * 0.5)), size: CGSize(width: availableSize.width - component.insets.right - 47.0 - (component.insets.left + 47.0), height: 24.0))
if case let .audio(audio) = component.mediaPreview {
let _ = self.waveform.update(
transition: transition,
component: AnyComponent(AudioWaveformComponent(
backgroundColor: UIColor.white.withAlphaComponent(0.1),
foregroundColor: UIColor.white.withAlphaComponent(1.0),
shimmerColor: nil,
style: .middle,
samples: audio.waveform.samples,
peak: audio.waveform.peak,
status: self.mediaPlayerStatus.get() |> map { value -> MediaPlayerStatus in
if let value {
return value
} else {
return MediaPlayerStatus(
generationTimestamp: 0.0,
duration: 0.0,
dimensions: CGSize(),
timestamp: 0.0,
baseRate: 1.0,
seekId: 0,
status: .paused,
soundEnabled: true
)
}
},
isViewOnceMessage: false,
seek: { [weak self] timestamp in
guard let self, let mediaPlayer = self.mediaPlayer else {
return
}
mediaPlayer.seek(timestamp: timestamp)
},
updateIsSeeking: { [weak self] isSeeking in
guard let self, let mediaPlayer = self.mediaPlayer else {
return
}
if isSeeking {
mediaPlayer.pause()
} else {
mediaPlayer.play()
}
}
)),
environment: {},
containerSize: waveformFrame.size
)
let _ = self.vibrancyWaveform.update(
transition: transition,
component: AnyComponent(AudioWaveformComponent(
backgroundColor: .white,
foregroundColor: .white,
shimmerColor: nil,
style: .middle,
samples: audio.waveform.samples,
peak: audio.waveform.peak,
status: .complete(),
isViewOnceMessage: false,
seek: nil,
updateIsSeeking: nil
)),
environment: {},
containerSize: waveformFrame.size
)
}
if let waveformView = self.waveform.view as? AudioWaveformComponent.View {
if waveformView.superview == nil {
waveformView.enableScrubbing = true
self.addSubview(waveformView)
}
transition.setFrame(view: waveformView, frame: waveformFrame)
}
if let vibrancyWaveformView = self.vibrancyWaveform.view {
if vibrancyWaveformView.superview == nil {
self.vibrancyContainer.addSubview(vibrancyWaveformView)
}
transition.setFrame(view: vibrancyWaveformView, frame: waveformFrame)
}
transition.setFrame(view: self.vibrancyContainer, frame: CGRect(origin: CGPoint(), size: availableSize))
return availableSize
}
}
public func makeView() -> View {
return View(frame: CGRect())
}
public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}
@@ -0,0 +1,494 @@
import Foundation
import UIKit
import Display
import ComponentFlow
import AppBundle
import TextFieldComponent
import BundleIconComponent
import AccountContext
import TelegramPresentationData
import ChatPresentationInterfaceState
import SwiftSignalKit
import LottieComponent
import HierarchyTrackingLayer
public final class MediaRecordingPanelComponent: Component {
public let theme: PresentationTheme
public let strings: PresentationStrings
public let audioRecorder: ManagedAudioRecorder?
public let videoRecordingStatus: InstantVideoControllerRecordingStatus?
public let isRecordingLocked: Bool
public let cancelFraction: CGFloat
public let inputInsets: UIEdgeInsets
public let insets: UIEdgeInsets
public let cancelAction: () -> Void
public init(
theme: PresentationTheme,
strings: PresentationStrings,
audioRecorder: ManagedAudioRecorder?,
videoRecordingStatus: InstantVideoControllerRecordingStatus?,
isRecordingLocked: Bool,
cancelFraction: CGFloat,
inputInsets: UIEdgeInsets,
insets: UIEdgeInsets,
cancelAction: @escaping () -> Void
) {
self.theme = theme
self.strings = strings
self.audioRecorder = audioRecorder
self.videoRecordingStatus = videoRecordingStatus
self.isRecordingLocked = isRecordingLocked
self.cancelFraction = cancelFraction
self.inputInsets = inputInsets
self.insets = insets
self.cancelAction = cancelAction
}
public static func ==(lhs: MediaRecordingPanelComponent, rhs: MediaRecordingPanelComponent) -> Bool {
if lhs.theme !== rhs.theme {
return false
}
if lhs.strings !== rhs.strings {
return false
}
if lhs.audioRecorder !== rhs.audioRecorder {
return false
}
if lhs.videoRecordingStatus !== rhs.videoRecordingStatus {
return false
}
if lhs.isRecordingLocked != rhs.isRecordingLocked {
return false
}
if lhs.cancelFraction != rhs.cancelFraction {
return false
}
if lhs.inputInsets != rhs.inputInsets {
return false
}
if lhs.insets != rhs.insets {
return false
}
return true
}
public final class View: UIView {
private var component: MediaRecordingPanelComponent?
private weak var state: EmptyComponentState?
public let vibrancyContainer: UIView
private let trackingLayer: HierarchyTrackingLayer
private let indicator = ComponentView<Empty>()
private let cancelContainerView: UIView
private let vibrancyCancelContainerView: UIView
private let cancelIconView: UIImageView
private let vibrancyCancelIconView: UIImageView
private let vibrancyCancelText = ComponentView<Empty>()
private let cancelText = ComponentView<Empty>()
private let vibrancyCancelButtonText = ComponentView<Empty>()
private let cancelButtonText = ComponentView<Empty>()
private var cancelButton: HighlightableButton?
private let timerFont: UIFont
private let timerText = ComponentView<Empty>()
private var timerTextDisposable: Disposable?
private var timerTextValue: String = "0:00,00"
override init(frame: CGRect) {
self.trackingLayer = HierarchyTrackingLayer()
self.cancelIconView = UIImageView()
self.vibrancyCancelIconView = UIImageView()
self.timerFont = Font.with(size: 15.0, design: .camera, traits: .monospacedNumbers)
self.vibrancyContainer = UIView()
self.cancelContainerView = UIView()
self.vibrancyCancelContainerView = UIView()
super.init(frame: frame)
self.layer.addSublayer(self.trackingLayer)
self.cancelContainerView.addSubview(self.cancelIconView)
self.vibrancyCancelContainerView.addSubview(self.vibrancyCancelIconView)
self.vibrancyContainer.addSubview(self.vibrancyCancelContainerView)
self.addSubview(self.cancelContainerView)
self.trackingLayer.didEnterHierarchy = { [weak self] in
guard let self else {
return
}
self.updateAnimations()
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
self.timerTextDisposable?.dispose()
}
private func updateAnimations() {
guard let component = self.component else {
return
}
if let indicatorView = self.indicator.view {
if indicatorView.layer.animation(forKey: "recording") == nil {
let animation = CAKeyframeAnimation(keyPath: "opacity")
animation.values = [1.0 as NSNumber, 1.0 as NSNumber, 0.0 as NSNumber]
animation.keyTimes = [0.0 as NSNumber, 0.4546 as NSNumber, 0.9091 as NSNumber, 1 as NSNumber]
animation.duration = 0.5
animation.autoreverses = true
animation.repeatCount = Float.infinity
indicatorView.layer.add(animation, forKey: "recording")
}
}
if !component.isRecordingLocked, self.cancelContainerView.layer.animation(forKey: "recording") == nil {
let animation = CAKeyframeAnimation(keyPath: "position.x")
animation.values = [-5.0 as NSNumber, 5.0 as NSNumber, 0.0 as NSNumber]
animation.keyTimes = [0.0 as NSNumber, 0.4546 as NSNumber, 0.9091 as NSNumber, 1 as NSNumber]
animation.duration = 1.5
animation.autoreverses = true
animation.isAdditive = true
animation.repeatCount = Float.infinity
self.cancelContainerView.layer.add(animation, forKey: "recording")
self.vibrancyCancelContainerView.layer.add(animation, forKey: "recording")
}
}
public func animateIn() {
guard let component = self.component else {
return
}
if let indicatorView = self.indicator.view {
indicatorView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25)
indicatorView.layer.animatePosition(from: CGPoint(x: component.inputInsets.left - component.insets.left, y: 0.0), to: CGPoint(), duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, additive: true)
}
if let timerTextView = self.timerText.view {
timerTextView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25)
timerTextView.layer.animatePosition(from: CGPoint(x: component.inputInsets.left - component.insets.left, y: 0.0), to: CGPoint(), duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, additive: true)
}
self.cancelContainerView.layer.animatePosition(from: CGPoint(x: self.bounds.width, y: 0.0), to: CGPoint(), duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, additive: true)
self.vibrancyCancelContainerView.layer.animatePosition(from: CGPoint(x: self.bounds.width, y: 0.0), to: CGPoint(), duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, additive: true)
}
public func animateOut(transition: ComponentTransition, dismissRecording: Bool, completion: @escaping () -> Void) {
guard let component = self.component else {
completion()
return
}
if let indicatorView = self.indicator.view as? LottieComponent.View, let _ = indicatorView.layer.animation(forKey: "recording") {
let fromAlpha = indicatorView.layer.presentation()?.opacity ?? indicatorView.layer.opacity
indicatorView.layer.removeAnimation(forKey: "recording")
indicatorView.layer.animateAlpha(from: CGFloat(fromAlpha), to: 1.0, duration: 0.2)
}
if dismissRecording {
if let indicatorView = self.indicator.view as? LottieComponent.View {
indicatorView.playOnce(completion: { [weak indicatorView] in
if let indicatorView {
let transition = ComponentTransition(animation: .curve(duration: 0.3, curve: .spring))
transition.setScale(view: indicatorView, scale: 0.001)
}
completion()
})
} else {
completion()
}
} else {
if let indicatorView = self.indicator.view as? LottieComponent.View {
transition.setPosition(view: indicatorView, position: indicatorView.center.offsetBy(dx: component.inputInsets.left - component.insets.left, dy: 0.0))
transition.setAlpha(view: indicatorView, alpha: 0.0)
}
}
if let timerTextView = self.timerText.view {
transition.setAlpha(view: timerTextView, alpha: 0.0, completion: { _ in
if !dismissRecording {
completion()
}
})
transition.setScale(view: timerTextView, scale: 0.001)
transition.setPosition(view: timerTextView, position: timerTextView.center.offsetBy(dx: component.inputInsets.left - component.insets.left, dy: 0.0))
}
transition.setAlpha(view: self.cancelContainerView, alpha: 0.0)
transition.setAlpha(view: self.vibrancyCancelContainerView, alpha: 0.0)
}
@objc private func cancelButtonPressed() {
guard let component = self.component else {
return
}
component.cancelAction()
}
func update(component: MediaRecordingPanelComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
let previousComponent = self.component
self.component = component
self.state = state
if previousComponent?.audioRecorder !== component.audioRecorder || previousComponent?.videoRecordingStatus !== component.videoRecordingStatus {
self.timerTextDisposable?.dispose()
if let audioRecorder = component.audioRecorder {
var updateNow = false
self.timerTextDisposable = audioRecorder.recordingState.start(next: { [weak self] state in
Queue.mainQueue().async {
guard let self else {
return
}
switch state {
case .paused(let duration), .recording(let duration, _):
let currentAudioDurationSeconds = Int(duration)
let currentAudioDurationMilliseconds = Int(duration * 100.0) % 100
let text: String
if currentAudioDurationSeconds >= 60 * 60 {
text = String(format: "%d:%02d:%02d,%02d", currentAudioDurationSeconds / 3600, currentAudioDurationSeconds / 60 % 60, currentAudioDurationSeconds % 60, currentAudioDurationMilliseconds)
} else {
text = String(format: "%d:%02d,%02d", currentAudioDurationSeconds / 60, currentAudioDurationSeconds % 60, currentAudioDurationMilliseconds)
}
if self.timerTextValue != text {
self.timerTextValue = text
}
if updateNow {
self.state?.updated(transition: .immediate)
}
case .stopped:
break
}
}
})
updateNow = true
} else if let videoRecordingStatus = component.videoRecordingStatus {
var updateNow = false
self.timerTextDisposable = videoRecordingStatus.duration.start(next: { [weak self] duration in
Queue.mainQueue().async {
guard let self else {
return
}
let currentAudioDurationSeconds = Int(duration)
let currentAudioDurationMilliseconds = Int(duration * 100.0) % 100
let text: String
if currentAudioDurationSeconds >= 60 * 60 {
text = String(format: "%d:%02d:%02d,%02d", currentAudioDurationSeconds / 3600, currentAudioDurationSeconds / 60 % 60, currentAudioDurationSeconds % 60, currentAudioDurationMilliseconds)
} else {
text = String(format: "%d:%02d,%02d", currentAudioDurationSeconds / 60, currentAudioDurationSeconds % 60, currentAudioDurationMilliseconds)
}
if self.timerTextValue != text {
self.timerTextValue = text
}
if updateNow {
self.state?.updated(transition: .immediate)
}
}
})
updateNow = true
}
}
let indicatorSize = self.indicator.update(
transition: .immediate,
component: AnyComponent(LottieComponent(
content: LottieComponent.AppBundleContent(name: "BinRed"),
color: UIColor(rgb: 0xFF3B30),
startingPosition: .begin
)),
environment: {},
containerSize: CGSize(width: 40.0, height: 40.0)
)
if let indicatorView = self.indicator.view {
if indicatorView.superview == nil {
self.addSubview(indicatorView)
}
transition.setFrame(view: indicatorView, frame: CGRect(origin: CGPoint(x: 5.0, y: component.insets.top + floor((availableSize.height - component.insets.top - component.insets.bottom - indicatorSize.height) * 0.5)), size: indicatorSize))
}
let timerTextSize = self.timerText.update(
transition: .immediate,
component: AnyComponent(Text(text: self.timerTextValue, font: self.timerFont, color: .white)),
environment: {},
containerSize: CGSize(width: 100.0, height: 100.0)
)
if let timerTextView = self.timerText.view {
if timerTextView.superview == nil {
self.addSubview(timerTextView)
timerTextView.layer.anchorPoint = CGPoint(x: 0.0, y: 0.5)
}
let timerTextFrame = CGRect(origin: CGPoint(x: 40.0, y: component.insets.top + floor((availableSize.height - component.insets.top - component.insets.bottom - timerTextSize.height) * 0.5)), size: timerTextSize)
transition.setPosition(view: timerTextView, position: CGPoint(x: timerTextFrame.minX, y: timerTextFrame.midY))
timerTextView.bounds = CGRect(origin: CGPoint(), size: timerTextFrame.size)
}
if self.cancelIconView.image == nil {
let image = UIImage(bundleImageName: "Chat/Input/Text/AudioRecordingCancelArrow")?.withRenderingMode(.alwaysTemplate)
self.cancelIconView.image = image
self.vibrancyCancelIconView.image = image
}
self.cancelIconView.tintColor = UIColor(white: 1.0, alpha: 0.3)
self.vibrancyCancelIconView.tintColor = .black
let cancelTextSize = self.cancelText.update(
transition: .immediate,
component: AnyComponent(Text(text: component.strings.Conversation_SlideToCancel, font: Font.regular(15.0), color: UIColor(rgb: 0xffffff, alpha: 0.3))),
environment: {},
containerSize: CGSize(width: max(30.0, availableSize.width - 100.0), height: 44.0)
)
let _ = self.vibrancyCancelText.update(
transition: .immediate,
component: AnyComponent(Text(text: component.strings.Conversation_SlideToCancel, font: Font.regular(15.0), color: .black)),
environment: {},
containerSize: CGSize(width: max(30.0, availableSize.width - 100.0), height: 44.0)
)
let cancelButtonTextSize = self.cancelButtonText.update(
transition: .immediate,
component: AnyComponent(Text(text: component.strings.Common_Cancel, font: Font.regular(17.0), color: .white)),
environment: {},
containerSize: CGSize(width: max(30.0, availableSize.width - 100.0), height: 44.0)
)
let _ = self.vibrancyCancelButtonText.update(
transition: .immediate,
component: AnyComponent(Text(text: component.strings.Common_Cancel, font: Font.regular(17.0), color: .clear)),
environment: {},
containerSize: CGSize(width: max(30.0, availableSize.width - 100.0), height: 44.0)
)
var textFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - cancelTextSize.width) * 0.5), y: component.insets.top + floor((availableSize.height - component.insets.top - component.insets.bottom - cancelTextSize.height) * 0.5)), size: cancelTextSize)
let cancelButtonTextFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - cancelButtonTextSize.width) * 0.5), y: component.insets.top + floor((availableSize.height - component.insets.top - component.insets.bottom - cancelButtonTextSize.height) * 0.5)), size: cancelButtonTextSize)
let bandingStart: CGFloat = 0.0
let bandedOffset = abs(component.cancelFraction) - bandingStart
let range: CGFloat = 300.0
let coefficient: CGFloat = 0.4
let mappedCancelFraction = bandingStart + (1.0 - (1.0 / ((bandedOffset * coefficient / range) + 1.0))) * range
textFrame.origin.x -= mappedCancelFraction * 0.5
if component.isRecordingLocked {
if self.cancelContainerView.layer.animation(forKey: "recording") != nil {
if let presentation = self.cancelContainerView.layer.presentation() {
transition.animatePosition(view: self.cancelContainerView, from: presentation.position, to: CGPoint())
transition.animatePosition(view: self.vibrancyCancelContainerView, from: presentation.position, to: CGPoint())
}
self.cancelContainerView.layer.removeAnimation(forKey: "recording")
self.vibrancyCancelContainerView.layer.removeAnimation(forKey: "recording")
}
}
if let cancelTextView = self.cancelText.view {
if cancelTextView.superview == nil {
self.cancelContainerView.addSubview(cancelTextView)
}
transition.setPosition(view: cancelTextView, position: textFrame.center)
transition.setBounds(view: cancelTextView, bounds: CGRect(origin: CGPoint(), size: textFrame.size))
transition.setAlpha(view: cancelTextView, alpha: !component.isRecordingLocked ? 1.0 : 0.0)
transition.setScale(view: cancelTextView, scale: !component.isRecordingLocked ? 1.0 : 0.001)
}
if let vibrancyCancelTextView = self.vibrancyCancelText.view {
if vibrancyCancelTextView.superview == nil {
self.vibrancyCancelContainerView.addSubview(vibrancyCancelTextView)
}
transition.setPosition(view: vibrancyCancelTextView, position: textFrame.center)
transition.setBounds(view: vibrancyCancelTextView, bounds: CGRect(origin: CGPoint(), size: textFrame.size))
transition.setAlpha(view: vibrancyCancelTextView, alpha: !component.isRecordingLocked ? 1.0 : 0.0)
transition.setScale(view: vibrancyCancelTextView, scale: !component.isRecordingLocked ? 1.0 : 0.001)
}
if let cancelButtonTextView = self.cancelButtonText.view {
if cancelButtonTextView.superview == nil {
self.cancelContainerView.addSubview(cancelButtonTextView)
}
transition.setPosition(view: cancelButtonTextView, position: cancelButtonTextFrame.center)
transition.setBounds(view: cancelButtonTextView, bounds: CGRect(origin: CGPoint(), size: cancelButtonTextFrame.size))
transition.setAlpha(view: cancelButtonTextView, alpha: component.isRecordingLocked ? 1.0 : 0.0)
transition.setScale(view: cancelButtonTextView, scale: component.isRecordingLocked ? 1.0 : 0.001)
}
if let vibrancyCancelButtonTextView = self.vibrancyCancelButtonText.view {
if vibrancyCancelButtonTextView.superview == nil {
self.vibrancyCancelContainerView.addSubview(vibrancyCancelButtonTextView)
}
transition.setPosition(view: vibrancyCancelButtonTextView, position: cancelButtonTextFrame.center)
transition.setBounds(view: vibrancyCancelButtonTextView, bounds: CGRect(origin: CGPoint(), size: cancelButtonTextFrame.size))
transition.setAlpha(view: vibrancyCancelButtonTextView, alpha: component.isRecordingLocked ? 1.0 : 0.0)
transition.setScale(view: vibrancyCancelButtonTextView, scale: component.isRecordingLocked ? 1.0 : 0.001)
}
if component.isRecordingLocked {
let cancelButton: HighlightableButton
if let current = self.cancelButton {
cancelButton = current
} else {
cancelButton = HighlightableButton()
self.cancelButton = cancelButton
self.addSubview(cancelButton)
cancelButton.highligthedChanged = { [weak self] highlighted in
guard let self else {
return
}
if highlighted {
self.cancelContainerView.alpha = 0.6
self.vibrancyCancelContainerView.alpha = 0.6
} else {
self.cancelContainerView.alpha = 1.0
self.vibrancyCancelContainerView.alpha = 1.0
self.cancelContainerView.layer.animateAlpha(from: 0.6, to: 1.0, duration: 0.2)
self.vibrancyCancelContainerView.layer.animateAlpha(from: 0.6, to: 1.0, duration: 0.2)
}
}
cancelButton.addTarget(self, action: #selector(self.cancelButtonPressed), for: .touchUpInside)
}
cancelButton.frame = CGRect(origin: CGPoint(x: cancelButtonTextFrame.minX - 8.0, y: 0.0), size: CGSize(width: cancelButtonTextFrame.width + 8.0 * 2.0, height: availableSize.height))
} else if let cancelButton = self.cancelButton {
cancelButton.removeFromSuperview()
}
if let image = self.cancelIconView.image {
let iconFrame = CGRect(origin: CGPoint(x: textFrame.minX - 4.0 - image.size.width, y: textFrame.minY + floor((textFrame.height - image.size.height) * 0.5)), size: image.size)
transition.setPosition(view: self.cancelIconView, position: iconFrame.center)
transition.setBounds(view: self.cancelIconView, bounds: CGRect(origin: CGPoint(), size: iconFrame.size))
transition.setAlpha(view: self.cancelIconView, alpha: !component.isRecordingLocked ? 1.0 : 0.0)
transition.setScale(view: self.cancelIconView, scale: !component.isRecordingLocked ? 1.0 : 0.001)
transition.setPosition(view: self.vibrancyCancelIconView, position: iconFrame.center)
transition.setBounds(view: self.vibrancyCancelIconView, bounds: CGRect(origin: CGPoint(), size: iconFrame.size))
transition.setAlpha(view: self.vibrancyCancelIconView, alpha: !component.isRecordingLocked ? 1.0 : 0.0)
transition.setScale(view: self.vibrancyCancelIconView, scale: !component.isRecordingLocked ? 1.0 : 0.001)
}
self.updateAnimations()
transition.setFrame(view: self.vibrancyContainer, frame: CGRect(origin: CGPoint(), size: availableSize))
return availableSize
}
}
public func makeView() -> View {
return View(frame: CGRect())
}
public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}
@@ -0,0 +1,522 @@
import Foundation
import UIKit
import Display
import ComponentFlow
import ComponentDisplayAdapters
import SwiftSignalKit
import TelegramCore
import AccountContext
import TelegramPresentationData
import PeerListItemComponent
import EmojiTextAttachmentView
import TextFormat
import ContextUI
import StickerPeekUI
import UndoUI
final class StickersResultPanelComponent: Component {
let context: AccountContext
let theme: PresentationTheme
let strings: PresentationStrings
let files: [TelegramMediaFile]
let action: (TelegramMediaFile) -> Void
let present: (ViewController) -> Void
let presentInGlobalOverlay: (ViewController) -> Void
init(
context: AccountContext,
theme: PresentationTheme,
strings: PresentationStrings,
files: [TelegramMediaFile],
action: @escaping (TelegramMediaFile) -> Void,
present: @escaping (ViewController) -> Void,
presentInGlobalOverlay: @escaping (ViewController) -> Void
) {
self.context = context
self.theme = theme
self.strings = strings
self.files = files
self.action = action
self.present = present
self.presentInGlobalOverlay = presentInGlobalOverlay
}
static func ==(lhs: StickersResultPanelComponent, rhs: StickersResultPanelComponent) -> Bool {
if lhs.context !== rhs.context {
return false
}
if lhs.theme !== rhs.theme {
return false
}
if lhs.strings !== rhs.strings {
return false
}
if lhs.files != rhs.files {
return false
}
return true
}
private struct ItemLayout: Equatable {
var containerSize: CGSize
var bottomInset: CGFloat
var topInset: CGFloat
var sideInset: CGFloat
var itemSize: CGSize
var itemSpacing: CGFloat
var itemsPerRow: Int
var itemCount: Int
var contentSize: CGSize
init(containerSize: CGSize, bottomInset: CGFloat, topInset: CGFloat, sideInset: CGFloat, itemSize: CGSize, itemSpacing: CGFloat, itemsPerRow: Int, itemCount: Int) {
self.containerSize = containerSize
self.bottomInset = bottomInset
self.topInset = topInset
self.sideInset = sideInset
self.itemSize = itemSize
self.itemSpacing = itemSpacing
self.itemsPerRow = itemsPerRow
self.itemCount = itemCount
let rowsCount = ceil(CGFloat(itemCount) / CGFloat(itemsPerRow))
self.contentSize = CGSize(width: containerSize.width, height: topInset + rowsCount * (itemSize.height + itemSpacing) - itemSpacing + bottomInset)
}
func visibleItems(for rect: CGRect) -> Range<Int>? {
let offsetRect = rect.offsetBy(dx: 0.0, dy: -self.topInset)
var minVisibleRow = Int(floor((offsetRect.minY) / (self.itemSize.height + self.itemSpacing)))
minVisibleRow = max(0, minVisibleRow)
let maxVisibleRow = Int(ceil((offsetRect.maxY) / (self.itemSize.height + self.itemSpacing)))
let minVisibleIndex = minVisibleRow * self.itemsPerRow
let maxVisibleIndex = maxVisibleRow * self.itemsPerRow + self.itemsPerRow
if maxVisibleIndex >= minVisibleIndex {
return minVisibleIndex ..< (maxVisibleIndex + 1)
} else {
return nil
}
}
func itemFrame(for index: Int) -> CGRect {
let rowIndex = Int(floor(CGFloat(index) / CGFloat(self.itemsPerRow)))
let columnIndex = index % self.itemsPerRow
return CGRect(origin: CGPoint(x: self.sideInset + CGFloat(columnIndex) * (self.itemSize.width + self.itemSpacing), y: self.topInset + CGFloat(rowIndex) * (self.itemSize.height + self.itemSpacing)), size: self.itemSize)
}
}
private final class ScrollView: UIScrollView {
override func touchesShouldCancel(in view: UIView) -> Bool {
return true
}
}
final class View: UIView, UIScrollViewDelegate, UIGestureRecognizerDelegate {
private let backgroundView: BlurredBackgroundView
private let containerView: UIView
private let scrollView: UIScrollView
private var itemLayout: ItemLayout?
private var visibleLayers: [EngineMedia.Id: InlineStickerItemLayer] = [:]
private var fadingMaskLayer: FadingMaskLayer?
private var ignoreScrolling = false
private var component: StickersResultPanelComponent?
private weak var state: EmptyComponentState?
override init(frame: CGRect) {
self.backgroundView = BlurredBackgroundView(color: .clear, enableBlur: true)
self.backgroundView.isUserInteractionEnabled = false
self.containerView = UIView()
self.scrollView = ScrollView()
self.scrollView.canCancelContentTouches = true
self.scrollView.delaysContentTouches = false
self.scrollView.showsVerticalScrollIndicator = false
self.scrollView.contentInsetAdjustmentBehavior = .never
self.scrollView.alwaysBounceVertical = true
self.scrollView.indicatorStyle = .white
super.init(frame: frame)
self.clipsToBounds = true
self.scrollView.delegate = self
self.addSubview(self.backgroundView)
self.addSubview(self.containerView)
self.containerView.addSubview(self.scrollView)
self.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:))))
let peekRecognizer = PeekControllerGestureRecognizer(contentAtPoint: { [weak self] point in
if let self, let component = self.component {
let presentationData = component.strings
let convertedPoint = self.scrollView.convert(point, from: self)
guard self.scrollView.bounds.contains(convertedPoint) else {
return nil
}
var selectedLayer: InlineStickerItemLayer?
for (_, layer) in self.visibleLayers {
if layer.frame.contains(convertedPoint) {
selectedLayer = layer
break
}
}
if let selectedLayer, let file = selectedLayer.file {
return component.context.engine.stickers.isStickerSaved(id: file.fileId)
|> deliverOnMainQueue
|> map { [weak self] isStarred -> (UIView, CGRect, PeekControllerContent)? in
if let self, let component = self.component {
let menuItems: [ContextMenuItem] = []
let _ = menuItems
let _ = presentationData
// if strongSelf.peerId != strongSelf.context.account.peerId && strongSelf.peerId?.namespace != Namespaces.Peer.SecretChat {
// menuItems.append(.action(ContextMenuActionItem(text: strongSelf.strings.Conversation_SendMessage_SendSilently, icon: { theme in
// return generateTintedImage(image: UIImage(bundleImageName: "Chat/Input/Menu/SilentIcon"), color: theme.actionSheet.primaryTextColor)
// }, action: { _, f in
// if let strongSelf = self, let peekController = strongSelf.peekController {
// if let animationNode = (peekController.contentNode as? StickerPreviewPeekContentNode)?.animationNode {
// let _ = controllerInteraction.sendSticker(.standalone(media: item.file), true, false, nil, true, animationNode.view, animationNode.bounds, nil, [])
// } else if let imageNode = (peekController.contentNode as? StickerPreviewPeekContentNode)?.imageNode {
// let _ = controllerInteraction.sendSticker(.standalone(media: item.file), true, false, nil, true, imageNode.view, imageNode.bounds, nil, [])
// }
// }
// f(.default)
// })))
// }
//
// menuItems.append(.action(ContextMenuActionItem(text: strongSelf.strings.Conversation_SendMessage_ScheduleMessage, icon: { theme in
// return generateTintedImage(image: UIImage(bundleImageName: "Chat/Input/Menu/ScheduleIcon"), color: theme.actionSheet.primaryTextColor)
// }, action: { _, f in
// if let strongSelf = self, let peekController = strongSelf.peekController {
// if let animationNode = (peekController.contentNode as? StickerPreviewPeekContentNode)?.animationNode {
// let _ = controllerInteraction.sendSticker(.standalone(media: item.file), false, true, nil, true, animationNode.view, animationNode.bounds, nil, [])
// } else if let imageNode = (peekController.contentNode as? StickerPreviewPeekContentNode)?.imageNode {
// let _ = controllerInteraction.sendSticker(.standalone(media: item.file), false, true, nil, true, imageNode.view, imageNode.bounds, nil, [])
// }
// }
// f(.default)
// })))
// menuItems.append(
// .action(ContextMenuActionItem(text: isStarred ? presentationData.strings.Stickers_RemoveFromFavorites : presentationData.strings.Stickers_AddToFavorites, icon: { theme in generateTintedImage(image: isStarred ? UIImage(bundleImageName: "Chat/Context Menu/Unfave") : UIImage(bundleImageName: "Chat/Context Menu/Fave"), color: theme.contextMenu.primaryColor) }, action: { [weak self] _, f in
// f(.default)
//
// if let self, let component = self.component {
// let _ = (component.context.engine.stickers.toggleStickerSaved(file: file, saved: !isStarred)
// |> deliverOnMainQueue).start(next: { [weak self] result in
// guard let self, let component = self.component else {
// return
// }
// switch result {
// case .generic:
// let controller = UndoOverlayController(presentationData: presentationData, content: .sticker(context: component.context, file: file, loop: true, title: nil, text: !isStarred ? presentationData.strings.Conversation_StickerAddedToFavorites : presentationData.strings.Conversation_StickerRemovedFromFavorites, undoText: nil, customAction: nil), elevatedLayout: false, action: { _ in return false })
// component.presentInGlobalOverlay(controller)
// case let .limitExceeded(limit, premiumLimit):
// let premiumConfiguration = PremiumConfiguration.with(appConfiguration: component.context.currentAppConfiguration.with { $0 })
// let text: String
// if limit == premiumLimit || premiumConfiguration.isPremiumDisabled {
// text = presentationData.strings.Premium_MaxFavedStickersFinalText
// } else {
// text = presentationData.strings.Premium_MaxFavedStickersText("\(premiumLimit)").string
// }
//
// let controller = UndoOverlayController(presentationData: presentationData, content: .sticker(context: component.context, file: file, loop: true, title: presentationData.strings.Premium_MaxFavedStickersTitle("\(limit)").string, text: text, undoText: nil, customAction: nil), elevatedLayout: false, action: { [weak self] action in
// if let self, let component = self.component {
// if case .info = action {
// let controller = component.context.sharedContext.makePremiumIntroController(context: component.context, source: .savedStickers)
// // strongSelf.getControllerInteraction?()?.navigationController()?.pushViewController(controller)
// return true
// }
// }
// return false
// })
// component.presentInGlobalOverlay(controller)
// }
// })
// }
// }))
// )
//
// menuItems.append(
// .action(ContextMenuActionItem(text: presentationData.strings.StickerPack_ViewPack, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Sticker"), color: theme.contextMenu.primaryColor) }, action: { [weak self] _, f in
// f(.default)
//
// if let self, let component = self.component {
// loop: for attribute in file.attributes {
// switch attribute {
// case let .Sticker(_, packReference, _):
// if let packReference = packReference {
// let controller = component.context.sharedContext.makeStickerPackScreen(context: component.context, mainStickerPack: packReference, stickerPacks: [packReference], parentNavigationController: nil, sendSticker: { [weak self] file, sourceNode, sourceRect in
// if let self, let component = self.component {
// component.action(file)
// return true
// } else {
// return false
// }
// })
// component.present(controller)
// }
// break loop
// default:
// break
// }
// }
// }
// }))
// )
return (self, self.scrollView.convert(selectedLayer.frame, to: self), StickerPreviewPeekContent(context: component.context, theme: component.theme, strings: component.strings, item: .pack(file), menu: menuItems, openPremiumIntro: { [weak self] in
guard let self, let component = self.component else {
return
}
let controller = component.context.sharedContext.makePremiumIntroController(context: component.context, source: .stickers, forceDark: false, dismissed: nil)
component.present(controller)
}))
} else {
return nil
}
}
}
}
return nil
}, present: { [weak self] content, sourceView, sourceRect in
if let self, let component = self.component {
let presentationData = component.context.sharedContext.currentPresentationData.with { $0 }.withUpdated(theme: component.theme)
let controller = PeekController(presentationData: presentationData, content: content, sourceView: {
return (sourceView, sourceRect)
})
component.presentInGlobalOverlay(controller)
return controller
}
return nil
}, updateContent: { [weak self] content in
if let self {
var item: TelegramMediaFile?
if let content = content as? StickerPreviewPeekContent, case let .pack(contentItem) = content.item {
item = contentItem
}
let _ = item
let _ = self
//strongSelf.updatePreviewingItem(file: item, animated: true)
}
})
self.addGestureRecognizer(peekRecognizer)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@objc private func tapGesture(_ recognizer: UITapGestureRecognizer) {
if case .ended = recognizer.state {
let location = recognizer.location(in: self.scrollView)
if self.scrollView.bounds.contains(location) {
var closestFile: (file: TelegramMediaFile, distance: CGFloat)?
for (_, itemLayer) in self.visibleLayers {
guard let file = itemLayer.file else {
continue
}
if itemLayer.frame.contains(location) {
closestFile = (file, 0.0)
}
}
if let (file, _) = closestFile {
self.component?.action(file)
}
}
}
}
func animateIn(transition: ComponentTransition) {
let offset = self.scrollView.contentOffset.y * -1.0 + 10.0
ComponentTransition.immediate.setBoundsOrigin(view: self, origin: CGPoint(x: 0.0, y: -offset))
transition.setBoundsOrigin(view: self, origin: CGPoint(x: 0.0, y: 0.0))
}
func animateOut(transition: ComponentTransition, completion: @escaping () -> Void) {
let offset = self.scrollView.contentOffset.y * -1.0 + 10.0
self.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false)
transition.setBoundsOrigin(view: self, origin: CGPoint(x: 0.0, y: -offset), completion: { _ in
completion()
})
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
if !self.ignoreScrolling {
self.updateScrolling(transition: .immediate)
}
}
private func updateScrolling(transition: ComponentTransition) {
guard let component = self.component, let itemLayout = self.itemLayout else {
return
}
let visibleBounds = self.scrollView.bounds.insetBy(dx: 0.0, dy: -200.0)
var synchronousLoad = false
if let hint = transition.userData(PeerListItemComponent.TransitionHint.self) {
synchronousLoad = hint.synchronousLoad
}
var visibleIds = Set<EngineMedia.Id>()
if let range = itemLayout.visibleItems(for: visibleBounds) {
for index in range.lowerBound ..< range.upperBound {
guard index < component.files.count else {
continue
}
let itemFrame = itemLayout.itemFrame(for: index)
let item = component.files[index]
visibleIds.insert(item.fileId)
let itemLayer: InlineStickerItemLayer
if let current = self.visibleLayers[item.fileId] {
itemLayer = current
itemLayer.dynamicColor = .white
} else {
itemLayer = InlineStickerItemLayer(
context: component.context,
userLocation: .other,
attemptSynchronousLoad: synchronousLoad,
emoji: ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: nil, fileId: item.fileId.id, file: item),
file: item,
cache: component.context.animationCache,
renderer: component.context.animationRenderer,
placeholderColor: UIColor(rgb: 0xffffff).mixedWith(UIColor(rgb: 0x1c1c1d), alpha: 0.9),
pointSize: itemFrame.size,
dynamicColor: .white
)
self.visibleLayers[item.fileId] = itemLayer
self.scrollView.layer.addSublayer(itemLayer)
}
itemLayer.frame = itemFrame
itemLayer.isVisibleForAnimations = true
}
}
var removedIds: [EngineMedia.Id] = []
for (id, itemLayer) in self.visibleLayers {
if !visibleIds.contains(id) {
itemLayer.removeFromSuperlayer()
removedIds.append(id)
}
}
for id in removedIds {
self.visibleLayers.removeValue(forKey: id)
}
let backgroundSize = CGSize(width: self.scrollView.frame.width, height: self.scrollView.frame.height + 20.0)
transition.setFrame(view: self.backgroundView, frame: CGRect(origin: CGPoint(x: 0.0, y: max(0.0, self.scrollView.contentOffset.y * -1.0)), size: backgroundSize))
self.backgroundView.update(size: backgroundSize, cornerRadius: 11.0, transition: transition.containedViewLayoutTransition)
}
func update(component: StickersResultPanelComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
//let itemUpdated = self.component?.results != component.results
self.component = component
self.state = state
let minimizedHeight = min(availableSize.height, 500.0)
self.backgroundView.updateColor(color: UIColor(white: 0.0, alpha: 0.7), transition: transition.containedViewLayoutTransition)
let itemsPerRow = min(8, max(5, Int(availableSize.width / 80)))
let sideInset: CGFloat = 2.0
let itemSpacing: CGFloat = 2.0
let itemSize = floor((availableSize.width - sideInset * 2.0 - itemSpacing * (CGFloat(itemsPerRow) - 1.0)) / CGFloat(itemsPerRow))
let itemLayout = ItemLayout(
containerSize: CGSize(width: availableSize.width, height: minimizedHeight),
bottomInset: 40.0,
topInset: 9.0,
sideInset: sideInset,
itemSize: CGSize(width: itemSize, height: itemSize),
itemSpacing: itemSpacing,
itemsPerRow: itemsPerRow,
itemCount: component.files.count
)
self.itemLayout = itemLayout
let scrollContentSize = itemLayout.contentSize
self.ignoreScrolling = true
transition.setFrame(view: self.scrollView, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: availableSize.width, height: minimizedHeight)))
let visibleTopContentHeight = min(scrollContentSize.height, itemSize * 3.0 + 19.0)
let topInset = availableSize.height - visibleTopContentHeight
let scrollContentInsets = UIEdgeInsets(top: topInset, left: 0.0, bottom: 19.0, right: 0.0)
let scrollIndicatorInsets = UIEdgeInsets(top: topInset + 17.0, left: 0.0, bottom: 19.0, right: 0.0)
if self.scrollView.contentInset != scrollContentInsets {
self.scrollView.contentInset = scrollContentInsets
}
if self.scrollView.verticalScrollIndicatorInsets != scrollIndicatorInsets {
self.scrollView.verticalScrollIndicatorInsets = scrollIndicatorInsets
}
if self.scrollView.contentSize != scrollContentSize {
self.scrollView.contentSize = scrollContentSize
}
let maskLayer: FadingMaskLayer
if let current = self.fadingMaskLayer {
maskLayer = current
} else {
maskLayer = FadingMaskLayer()
self.fadingMaskLayer = maskLayer
}
if self.containerView.layer.mask == nil {
self.containerView.layer.mask = maskLayer
}
maskLayer.frame = CGRect(origin: .zero, size: self.scrollView.frame.size)
self.containerView.frame = CGRect(origin: .zero, size: availableSize)
self.ignoreScrolling = false
self.updateScrolling(transition: transition)
return availableSize
}
}
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 final class FadingMaskLayer: SimpleLayer {
let gradientLayer = SimpleLayer()
let fillLayer = SimpleLayer()
override func layoutSublayers() {
let gradientHeight: CGFloat = 110.0
if self.gradientLayer.contents == nil {
self.addSublayer(self.gradientLayer)
self.addSublayer(self.fillLayer)
let gradientImage = generateGradientImage(size: CGSize(width: 1.0, height: gradientHeight), colors: [UIColor.white, UIColor.white, UIColor.white.withAlphaComponent(0.0), UIColor.white.withAlphaComponent(0.0)], locations: [0.0, 0.4, 0.9, 1.0], direction: .vertical)
self.gradientLayer.contents = gradientImage?.cgImage
self.gradientLayer.contentsGravity = .resize
self.fillLayer.backgroundColor = UIColor.white.cgColor
}
self.fillLayer.frame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: self.bounds.width, height: self.bounds.height - gradientHeight))
self.gradientLayer.frame = CGRect(origin: CGPoint(x: 0.0, y: self.bounds.height - gradientHeight), size: CGSize(width: self.bounds.width, height: gradientHeight))
}
}
@@ -0,0 +1,137 @@
import Foundation
import UIKit
import Display
import ComponentFlow
public final class TimeoutContentComponent: Component {
public let color: UIColor
public let accentColor: UIColor
public let isSelected: Bool
public let value: String
public init(
color: UIColor,
accentColor: UIColor,
isSelected: Bool,
value: String
) {
self.color = color
self.accentColor = accentColor
self.isSelected = isSelected
self.value = value
}
public static func ==(lhs: TimeoutContentComponent, rhs: TimeoutContentComponent) -> Bool {
if lhs.color != rhs.color {
return false
}
if lhs.accentColor != rhs.accentColor {
return false
}
if lhs.isSelected != rhs.isSelected {
return false
}
if lhs.value != rhs.value {
return false
}
return true
}
public final class View: UIView {
private var component: TimeoutContentComponent?
private weak var state: EmptyComponentState?
private let background: UIImageView
private let foreground: UIImageView
private let text = ComponentView<Empty>()
override init(frame: CGRect) {
self.background = UIImageView(image: UIImage(bundleImageName: "Media Editor/Timeout"))
self.foreground = UIImageView()
super.init(frame: frame)
self.addSubview(self.background)
self.addSubview(self.foreground)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func update(component: TimeoutContentComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
let previousComponent = self.component
self.component = component
self.state = state
let size = CGSize(width: 20.0, height: 20.0)
if previousComponent?.accentColor != component.accentColor {
self.foreground.image = generateFilledCircleImage(diameter: size.width, color: component.accentColor)
}
var updated = false
if let previousComponent {
if previousComponent.isSelected != component.isSelected {
updated = true
}
if previousComponent.value != component.value {
if let textView = self.text.view, let snapshotView = textView.snapshotView(afterScreenUpdates: false) {
snapshotView.frame = textView.frame
self.addSubview(snapshotView)
snapshotView.layer.animatePosition(from: .zero, to: CGPoint(x: 0.0, y: -3.0), duration: 0.2, removeOnCompletion: false, additive: true)
snapshotView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { _ in
snapshotView.removeFromSuperview()
})
textView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
textView.layer.animatePosition(from: CGPoint(x: 0.0, y: 3.0), to: .zero, duration: 0.2, additive: true)
}
}
}
let fontSize: CGFloat
let textOffset: CGFloat
if component.value.count == 1 {
fontSize = 12.0
textOffset = UIScreenPixel
} else {
fontSize = 10.0
textOffset = -UIScreenPixel
}
let font = Font.with(size: fontSize, design: .round, weight: .semibold)
let textSize = self.text.update(
transition: .immediate,
component: AnyComponent(Text(text: component.value, font: font, color: .white)),
environment: {},
containerSize: CGSize(width: 100.0, height: 100.0)
)
if let textView = self.text.view {
if textView.superview == nil {
self.addSubview(textView)
}
let textFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - textSize.width) / 2.0) + UIScreenPixel, y: floorToScreenPixels((size.height - textSize.height) / 2.0) + textOffset), size: textSize)
transition.setPosition(view: textView, position: textFrame.center)
textView.bounds = CGRect(origin: CGPoint(), size: textFrame.size)
}
self.background.frame = CGRect(origin: .zero, size: size)
self.foreground.bounds = CGRect(origin: .zero, size: size)
self.foreground.center = CGPoint(x: size.width / 2.0, y: size.height / 2.0)
let foregroundTransition: ComponentTransition = updated ? .easeInOut(duration: 0.2) : transition
foregroundTransition.setScale(view: self.foreground, scale: component.isSelected ? 1.0 : 0.001)
return size
}
}
public func makeView() -> View {
return View(frame: CGRect())
}
public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}